diff --git a/.env.example b/.env.example index 817b6c614..324966a8d 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,25 @@ SHARE_LINK_RATE_LIMIT_REQUESTS=30 SHARE_LINK_RATE_LIMIT_WINDOW_SECONDS=60 SHARE_LINK_RATE_LIMIT_MAX_KEYS=10000 +# Optional durable job signal backend. Database rows remain authoritative. +# Migration dispatch uses a separate key and publishes only migration-run UUIDs; +# no SQL, plan payload, connection string, or credential crosses this queue. +# JOB_QUEUE_BACKEND=valkey +# VALKEY_URL=redis://valkey:6379/0 +# VALKEY_QUEUE_KEY=pg-erd-cloud:job-queue +# VALKEY_MIGRATION_RUN_QUEUE_KEY=pg-erd-cloud:migration-run-queue +# VALKEY_MIGRATION_RUN_PROCESSING_KEY=pg-erd-cloud:migration-run-processing +# VALKEY_MIGRATION_RUN_LEASE_TOKEN_KEY=pg-erd-cloud:migration-run-lease-token +# MIGRATION_RUN_SIGNAL_LEASE_SECONDS=60 +# The identifier-only migration outbox relay is opt-in and requires the Valkey +# backend above. It publishes run UUIDs only; it starts no consumer or executor. +MIGRATION_DISPATCH_RELAY_ENABLED=false +MIGRATION_DISPATCH_RELAY_POLL_INTERVAL_SECONDS=1.0 + +# Transitional compatibility only. Leave false so browser-authored legacy DDL +# cannot persist; structured forward-engineering apply is not implemented. +LEGACY_PERSISTENT_APPLY_ENABLED=false + # Optional: OIDC (Casdoor) issuer; if set backend verifies JWTs # OIDC_ISSUER=http://localhost:8002 # OIDC_AUDIENCE=erd-local diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 153677e9a..75f7ae4c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -43,6 +45,203 @@ jobs: PYTHONPATH: . run: pytest -q + postgres-integration: + name: PostgreSQL ${{ matrix.major }} + Valkey dual-lease acceptance + runs-on: ubuntu-latest + services: + valkey: + image: valkey/valkey@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec + ports: + - 6379:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + strategy: + fail-fast: false + matrix: + include: + - major: "14" + image: postgres@sha256:f1341c01408dc7278e9d365ed4f860cd3f87dd16b4464ac326fc0f422083a579 + - major: "15" + image: postgres@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f + - major: "16" + image: postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + - major: "17" + image: postgres@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 + - major: "18" + image: postgres@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Start PostgreSQL with ephemeral credentials + shell: bash + run: | + set -euo pipefail + password="$(openssl rand -hex 24)" + preflight_password="$(openssl rand -hex 24)" + app_secret="$(openssl rand -hex 32)" + echo "::add-mask::$password" + echo "::add-mask::$preflight_password" + echo "::add-mask::$app_secret" + + credentials_file="$RUNNER_TEMP/postgres.env" + umask 077 + { + echo "POSTGRES_DB=pg_erd_cloud_test" + echo "POSTGRES_PASSWORD=$password" + echo "POSTGRES_USER=postgres" + } > "$credentials_file" + docker run --detach --name pg-erd-cloud-postgres \ + --env-file "$credentials_file" \ + --publish 5432:5432 \ + "${{ matrix.image }}" + rm -f "$credentials_file" + + encoded_password="$(PASSWORD="$password" python3 - <<'PY' + import os + from urllib.parse import quote + + print(quote(os.environ["PASSWORD"], safe="")) + PY + )" + database_url="postgresql+asyncpg://postgres:${encoded_password}@127.0.0.1:5432/pg_erd_cloud_test" + echo "::add-mask::$database_url" + { + echo "APP_SECRET=$app_secret" + echo "DATABASE_URL=$database_url" + echo "POSTGRES_INTEGRATION_URL=$database_url" + } >> "$GITHUB_ENV" + + # The official image briefly starts a temporary server while it + # initializes a new PGDATA directory. A readiness probe can succeed + # against that server immediately before the entrypoint restarts it. + # Wait for the stable image marker before probing the final server. + init_complete="PostgreSQL init process complete; ready for start up." + for attempt in {1..20}; do + postgres_logs="$(docker logs pg-erd-cloud-postgres 2>&1 || true)" + if [[ "$postgres_logs" == *"$init_complete"* ]]; then + break + fi + if [ "$attempt" -eq 20 ]; then + printf '%s\n' "$postgres_logs" + exit 1 + fi + sleep 2 + done + + for attempt in {1..20}; do + if docker exec pg-erd-cloud-postgres \ + pg_isready -U postgres -d pg_erd_cloud_test; then + break + fi + if [ "$attempt" -eq 20 ]; then + docker logs pg-erd-cloud-postgres + exit 1 + fi + sleep 2 + done + + docker exec pg-erd-cloud-postgres \ + psql -v ON_ERROR_STOP=1 -U postgres -d postgres \ + -c "CREATE DATABASE pg_erd_cloud_sandbox" + docker exec pg-erd-cloud-postgres \ + psql -v ON_ERROR_STOP=1 -U postgres -d postgres \ + -c "CREATE DATABASE pg_erd_cloud_target" + docker exec pg-erd-cloud-postgres \ + psql -v ON_ERROR_STOP=1 -U postgres -d postgres \ + -c "CREATE ROLE cwl_erd_preflight LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD '$preflight_password'; ALTER ROLE cwl_erd_preflight SET default_transaction_read_only = on; ALTER ROLE cwl_erd_preflight SET statement_timeout = '5s'; REVOKE ALL ON DATABASE pg_erd_cloud_target FROM PUBLIC; GRANT CONNECT ON DATABASE pg_erd_cloud_target TO cwl_erd_preflight; REVOKE CREATE, TEMPORARY ON DATABASE pg_erd_cloud_target FROM cwl_erd_preflight" + encoded_preflight_password="$(PASSWORD="$preflight_password" python3 - <<'PY' + import os + from urllib.parse import quote + + print(quote(os.environ["PASSWORD"], safe="")) + PY + )" + sandbox_database_url="postgresql+asyncpg://postgres:${encoded_password}@127.0.0.1:5432/pg_erd_cloud_sandbox" + target_database_url="postgresql+asyncpg://postgres:${encoded_password}@127.0.0.1:5432/pg_erd_cloud_target" + preflight_database_url="postgresql+asyncpg://cwl_erd_preflight:${encoded_preflight_password}@127.0.0.1:5432/pg_erd_cloud_target" + echo "::add-mask::$sandbox_database_url" + echo "::add-mask::$target_database_url" + echo "::add-mask::$preflight_database_url" + { + echo "POSTGRES_SANDBOX_INTEGRATION_URL=$sandbox_database_url" + echo "POSTGRES_TARGET_INTEGRATION_URL=$target_database_url" + echo "POSTGRES_PREFLIGHT_INTEGRATION_URL=$preflight_database_url" + } >> "$GITHUB_ENV" + + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.10" + + - name: Install backend deps + working-directory: backend + run: python -m pip install --require-hashes -r requirements-dev.lock + + - name: Apply metadata migrations + working-directory: backend + env: + PYTHONPATH: . + run: alembic upgrade head + + - name: Verify PostgreSQL and Valkey dual-lease recovery + working-directory: backend + env: + EXPECTED_POSTGRES_MAJOR: ${{ matrix.major }} + PYTHONPATH: . + VALKEY_INTEGRATION_URL: redis://127.0.0.1:6379/0 + run: pytest -q tests/test_postgres_migration_run_integration.py + + valkey-integration: + name: Valkey 8 queue signal boundary + runs-on: ubuntu-latest + services: + valkey: + image: valkey/valkey@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec + ports: + - 6379:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.10" + + - name: Install backend deps + working-directory: backend + run: python -m pip install --require-hashes -r requirements-dev.lock + + - name: Verify real Valkey signal isolation + working-directory: backend + env: + PYTHONPATH: . + VALKEY_INTEGRATION_URL: redis://127.0.0.1:6379/0 + run: pytest -q tests/test_valkey_queue_integration.py + frontend: runs-on: ubuntu-latest steps: @@ -53,6 +252,8 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..8dc78f998 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,215 @@ +# pg-erd-cloud Architecture + +## Status legend + +- **Implemented**: present in production source and covered by repository tests. +- **Partial**: a safe bounded subset exists; unsupported behavior fails closed. +- **Planned**: approved design only; the product must not claim runtime support. + +## Bounded contexts + +```mermaid +flowchart TD + UI[ERD editor and review UI] --> API[FastAPI control plane] + API --> MODEL[(schema_model / schema_model_revision)] + API --> COMPILER[Canonical model and plan compiler] + COMPILER --> PLAN[(migration_plan)] + PLAN -. partial core .-> SANDBOX[Isolated PostgreSQL validator] + PLAN -. planned .-> PREFLIGHT[Read-only target preflight] + PREFLIGHT -. planned .-> EXECUTOR[Durable migration executor] + EXECUTOR -. planned .-> TARGET[(Target PostgreSQL)] + TARGET --> INTROSPECTOR[PostgreSQL introspector] + INTROSPECTOR --> SNAPSHOT[(schema_snapshot / schema_snapshot_data)] +``` + +Solid arrows are current control-plane or reverse-engineering paths. Dotted +arrows are accepted target architecture and do not claim deployed runtime +support. + +## Component status + +| Component | Responsibility | Status | +|---|---|---| +| React/Vite ERD editor | Snapshot visualization, editing, export | **Implemented existing product**; typed browser transport is **Partially implemented** for immutable plan retrieval, exact dry-run/apply intent creation, run polling, and version-bound cancellation; the plan review panel is **Partially implemented** as a read-only provenance/risk/blocker/statement surface with no action authority; fixed loading/error/retry behavior and stale-response suppression is **Partially implemented** around exact plan retrieval; the Forward Engineering modal shell is **Partially implemented** with dialog focus/Escape/restoration behavior and one bounded dry-run intent action; the dry-run intent control is **Partially implemented** with server `can_dry_run`/blocker gating, exact plan digest submission, single-flight protection, and same-key ambiguous-failure retry, but it adds no browser SQL, target credential, worker, or apply authority; the apply intent control is **Partially implemented** with exact passed-dry-run/plan/base binding, typed target confirmation, conditional destructive acknowledgement, single-flight protection, and immutable-body same-key retry, but the server persists only a non-dispatched intent; the run status and audit panel is **Partially implemented** as an optional read-only exact-run view that announces state and renders verified event-chain metadata without rendering generic evidence payloads; sequential terminal-aware polling is **Partially implemented** and stops after the first terminal response; the cancellation intent control is **Partially implemented** for non-terminal exact state versions with single-flight submission, accepted-state refresh, and refresh-only handling of ambiguous results; Forward UI remains **Planned** | +| DBML parser and DDL export | Convert authenticated design text into snapshot JSON and reviewable PostgreSQL/Snowflake DDL | **Implemented export boundary**; PostgreSQL identifiers fail closed on malformed/ambiguous/NUL/over-63-byte input, preserve valid doubled quotes and punctuation, and pass through the dialect-owned identifier renderer. It opens no target connection and grants no apply authority. | +| FastAPI control plane | Auth, tenancy, revisions, plan creation | **Partially implemented** | +| Canonical model/compiler | Validate, hash, compile operations/blockers | **Implemented for narrow v1 subset** | +| Metadata PostgreSQL | Snapshots, models, revisions, plans, jobs | Phase 1 entities, run/event/outbox storage, verified polling, dry-run creation/cancellation acknowledgement, terminal no-replay settlement, lease-bound hashed worker-attempt primitives, and the exact dual-lease adapter **Implemented**; application worker wiring **Planned** | +| Isolated PostgreSQL validator | Exact-plan executable dry run | Signed-plan/version/base/transaction/convergence execution core and `complete_isolated_dry_run` server-derived success CAS **Partially implemented**; provisioning, dependency materialization, isolation proof, cleanup, and worker **Planned** | +| Live preflight/apply worker | Read-only evidence, locked execution, recovery | Bounded structured read-query, canonical snapshot/base-digest comparison, DB-durable hashed attempt acquire/renew/finish primitives, deterministic structured existing-table lock-plan compilation, and a signed-plan pre-apply revalidation manifest are **Implemented**; `execute_bound_live_preflight` binds a caller-owned capture callback and checks to one read-only repeatable-read transaction, and `complete_live_preflight` derives the only valid terminal CAS classification. Consumer-to-attempt binding is **Implemented** as an execution-neutral dual-lease adapter. The identifier-only live-reader request carries the exact refreshed run state version; `guard_live_preflight_handoff` matches its full run/plan/project/target/active-attempt tuple, cancellation, version, lease, digest and expiry in one fresh database statement, while `load_guarded_live_preflight_target` additionally binds the exact project-owned encrypted DSN ciphertext/nonce to the exact succeeded base snapshot UUID and validated schema filter under that same predicate. `make_stored_postgres_live_preflight_factory` then decrypts only guarded material in memory, opens through the existing DNS/SSRF/TLS-pinned connector, scopes capture to the same acquired connection, sanitizes failures, and closes it. PostgreSQL 14–18 acceptance composes its metadata/decryption/same-connection lifecycle with an explicit test-only loopback connector because the production guard correctly rejects the private CI target. The provider is not wired into startup and does not prove deployed credentials or network isolation. The manifest binds exact plan/base/target/version metadata to lock targets, structured database `CREATE`/schema `CREATE`/table `OWNER` requirements, checks, and zero/no-op or one ordered all-transactional segment. Fixed parameterized privilege-probe compilation, pure observation assessment, and caller-owned same-connection read-only capture are **Implemented** bounded primitives; the capture re-derives the manifest from the signed plan, observes a strict snapshot plus every privilege/precondition position in one repeatable-read transaction, and returns only non-authorizing facts. The compilers parse no rendered SQL; capture acquires no advisory/object lock. The remaining observation-to-target-open gap, unmodified guarded-route integration, deployed credential/network constraints, application startup wiring, worker execution, target lock acquisition, in-lock repetition, transaction execution/rollback proof, and apply remain **Planned**. | +| External target PostgreSQL | Reverse source and future apply target | Reverse **Implemented**; target apply workflow **Planned** | + +The stored-PostgreSQL preflight provider now performs post-connect +revalidation: after guarded connection acquisition it repeats the exact +encrypted target/snapshot/attempt lookup and requires an identical result +before any target read. It closes the connection on mismatch without granting +capture authority. A concurrent change after that second check remains +possible and is bounded by the exact attempt lease; this is not a live-apply +or production-readiness claim. + +`make_stored_postgres_durable_dry_run_attempt_handler` now binds this provider +to the durable attempt handler with one session-factory identity for both run +metadata and credential-bearing target lookup. A divergent consumer factory +fails before either authority is used. Sandbox lifecycle remains injected; +application startup/consumer registration and apply remain Planned. +The PostgreSQL 14–18 recovery matrix enters through this composition; only its +predecessor-crash wrapper and private-CI loopback connector remain test seams. + +The pre-apply observation capture owns no target credential or durable attempt +binding and acquires no advisory/object lock. The separately scoped live- +preflight provider does not change that apply-time authority boundary. + +Modal orchestration keeps one active run audit identity: an accepted dry-run +replaces the supplied run surface for that open session, while closing and +reopening the modal discards the session-created identity and restores the +caller-supplied run. This avoids duplicate polling/live regions without making +the browser an execution authority. + +The browser is an intent and review surface, never a SQL authority. The API +persists immutable model revisions. The compiler validates a deliberately small +PostgreSQL 14–18 model, renders a structured transactional plan, associates +each statement with dependencies, privileges, preconditions and operational +risk, and binds the plan to an exact model revision, connection and succeeded +snapshot. Mixed-case, Unicode, reserved-word and quoted identifiers are +preserved and quoted server-side. + +## Current implementation boundary + +Implemented in the initial safe vertical slice: + +- optimistic versioned model persistence using a strong revision-UUID `ETag` + and `If-Match` token (the content digest remains separate); +- canonical model hashing independent of OIDs and capture timestamps; +- immutable server-side plans for schemas, tables, columns, nullability, types + and creation-time primary keys; +- explicit risk, lock, rewrite/scan/data-loss, privilege and precondition data; +- one read-only repeatable-read catalog snapshot with an explicit capability + contract version, plus a strict adapter/compiler that reject stale or lossy input; +- optimistic compare-and-swap run transitions that update one exact state + version and append the matching sanitized evidence event in the caller's + transaction; dry-run `passed`/`drifted` transitions revalidate immutable plan + integrity, require the canonical observed base digest, enforce match/mismatch + semantics, and persist that digest on both the run and chained event; +- an internal PostgreSQL conflict-winner writer that creates or reuses one + exact, unexpired, executable dry-run intent and atomically persists its + sequence-one event plus identifier-only dispatch outbox; +- an editor-authorized `POST /api/migration-plans/{plan_uuid}/dry-runs` + boundary that requires the exact reviewed digest and bounded + `Idempotency-Key`, then returns only the queued durable identity without + publishing the outbox or signaling a worker; +- a deployer-authorized `POST /api/migration-plans/{plan_uuid}/apply-runs` + boundary that binds an exact immutable plan, same-plan passed dry run and + observed base, typed target connection name, actor, idempotency key, and the + exact destructive-confirmation requirement into a queued durable intent and + hash-chained genesis event; it deliberately creates no dispatch, worker + signal, credential access, SQL execution, or DDL authority; +- apply-intent creation locks the plan's schema-model row `FOR UPDATE` and + rejects `stale_revision` unless the plan-bound revision UUID, number, digest, + model, and project still match the current exact authority; +- lock-scoped relay primitives that claim one due dispatch with + `FOR UPDATE SKIP LOCKED`, increment its attempt in the caller-owned + transaction, publish only `migration_run_uuid` to a dedicated Valkey sorted + set, and publish-state CAS only that exact identifier-only claim; +- an explicit opt-in application lifecycle repeatedly invokes that bounded + publisher in one fresh metadata transaction per claim, rolls failed + publication back, idles at a positive configured interval, and shuts down + cooperatively; it does not load plans, consume signals, or execute SQL; +- UUID-only ready signals can be atomically moved to an isolated processing + set with a bounded exact lease-token, renewable only before expiry, reclaimed + after expiry, acknowledged only by the current token, or released for a + scheduled retry. These are + execution-neutral consumer contract and consumer-safety primitives only: no + application consumer lifecycle or migration worker exists; +- DB-durable `migration_run_attempt` history serializes acquisition on the run, + stores only SHA-256 hashes of bounded worker identity and the opaque signal + lease token, permits one active attempt per run, reclaims only expired owners, + renews monotonically by exact CAS while the run remains executable, and + finishes only an unexpired exact owner. Consumer-to-attempt binding is + **Implemented** by an execution-neutral dual-lease adapter, but no application + startup task, credential, sandbox, target, or DDL authority exists; +- same-state, version-incrementing cancellation intent that forces a worker to + observe cancellation before its next CAS transition can win; +- metadata-only terminal cancellation acknowledgement after a failed attempt + acquisition locks and reloads the run, requires the persisted intent, and + records `cancelled` before exact signal acknowledgement; already-terminal + redelivery is settled without replaying sandbox or live preflight; +- an editor-authorized `POST /api/migration-runs/{run_uuid}/cancel` boundary + that binds the exact state version, actor, and request correlation identity + to that cancellation event and returns only stable sanitized error codes; +- a versioned SHA-256 event chain anchored on each run row; polling recomputes + every link and fails closed on payload, ordering, predecessor, or anchor drift; +- a bounded live-preflight primitive compiles only the plan's structured + `table_is_empty`, `no_null_values`, and `castable_values` preconditions into + quoted PostgreSQL reads, executes them in one read-only repeatable-read + transaction with server/client timeouts, and returns boolean-only evidence; + `execute_bound_live_preflight` additionally runs a caller-owned fresh + snapshot callback and those checks in the same read-only repeatable-read + transaction, returning the canonical observed digest and plan-base match; + `capture_postgres_snapshot` is the reusable query-only callback for an + already-authorized PostgreSQL connection and deliberately owns no + transaction, commit, rollback, connection open, or connection close; it + rejects a missing caller transaction before any catalog query or optional + Citus savepoint; + `complete_live_preflight` accepts only that exact bounded result shape and + derives `drifted`, `failed`, or `passed` plus bounded aggregate evidence for + the existing durable CAS; neither function owns credentials, application + worker wiring, or DDL authority; +- an execution-only isolated-dry-run primitive accepts no DSN or browser SQL, + verifies the immutable plan/compiler/PostgreSQL-major/base bindings, executes + only the compiler-owned all-transactional statement list with bounded + timeouts, rolls failures back with fixed diagnostics, and requires a fresh + strict snapshot to equal `target_digest`; PostgreSQL 14–18 CI supplies the + real catalog round trip. `complete_isolated_dry_run` accepts only that exact + success shape, revalidates it against the stored plan, and derives the fixed + `live_preflight_running` CAS; sandbox provisioning, dependency + materialization, network isolation, cleanup, and worker wiring remain absent; +- `viewer < editor < deployer < owner`, with persistent legacy SQL apply + restricted to `deployer`. + +Partial: the current compiler rejects foreign keys, indexes, defaults, +identity/generated columns, existing-primary-key changes, views, triggers, +partitions, extensions and distributed tables. This is a release blocker for +general forward engineering, not a silent omission. + +Consumer-to-attempt binding is **Implemented** without execution authority. +Planned: isolated sandbox lifecycle, application startup wiring, deployed +in-flight process cancellation, and worker execution, +live-preflight credential binding around the durable attempt, plan approval, +idempotent apply, post-commit re-introspection, and the complete accessible +frontend apply/recovery flow. The caller-owned same-transaction snapshot +primitive is **Implemented** without credential or worker authority. The approved detailed design is in +`docs/superpowers/specs/2026-08-09-forward-engineering-design.md`. + +## Trust and deployment boundaries + +- Application PostgreSQL stores control-plane metadata; it must never be used + as the DDL sandbox. +- Target credentials remain encrypted and are decrypted only inside guarded + connection paths. Plans and queue payloads store identifiers and digests, + never DSNs. +- The isolated validator must have no route or credentials to production. +- Live target operations retain SSRF target validation, verified TLS where + configured, bounded timeouts and deterministic `search_path` handling. +- Other ContextualWisdomLab services integrate through versioned APIs; they do + not share database ownership or bypass project authorization. + +## References + +Authoritative detail: [PRD](docs/PRD.md), [TRD](docs/TRD.md), +[ADR index](docs/adr/README.md), [v1 contract](docs/contracts/forward-engineering-v1.md), +[UML](docs/UML.md), and [metadata ERD](docs/DATA_MODEL.md). The +[Figma FigJam board](https://www.figma.com/board/MLWimuWoOWhatQ239QihfP) is a +non-authoritative visual companion. + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +Explicit locking*. https://www.postgresql.org/docs/18/explicit-locking.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +Transactions*. https://www.postgresql.org/docs/18/tutorial-transactions.html + +Rae, I., Rollins, E., Shute, J., Sodhi, S., & Vingralek, R. (2013). Online, +asynchronous schema change in F1. *Proceedings of the VLDB Endowment, 6*(11), +1045–1056. https://doi.org/10.14778/2536222.2536230 + +Research applicability and limits are recorded in +[Standards and evidence](docs/STANDARDS.md#research-use-and-limits). diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..f2424cdb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,123 @@ # Changelog ## Unreleased + +- [BE/CI] DBML parsing now treats only LF as a record delimiter so Unicode + separators inside quoted identifiers remain data and round-trip safely. +- [FE/Security] Pin the transitive Nano ID dependency to `3.3.18` or newer to + remove the high-severity zero-size generator denial-of-service advisory. +- [FE/CI] Serialize Vitest files and allow the orchestration suite enough time + to run deterministically under constrained CI workers. + +- Bound guarded PostgreSQL live-preflight connection acquisition to an + injected finite timeout in `(0, 60]` seconds and propagate it through the + durable-attempt and UUID-only consumer composition. Invalid values fail while + constructing the capability, before metadata lookup, decryption, target I/O, + or SQL authority. + +- Add one explicit stored-PostgreSQL migration-run composition that binds the + durable dry-run attempt capability to exact worker-attempt leasing before it + can be injected into the UUID-only signal consumer. The caller still supplies + sandbox lifecycle and startup registration; no SQL or apply authority is + added. + +- Bind the concrete stored-PostgreSQL live-preflight provider to the durable + dry-run attempt handler through one explicit repository composition. Durable + run metadata and credential-bearing target lookup must use the same session + factory; a divergent consumer factory fails before metadata or target I/O. + Sandbox lifecycle and application startup remain injected and Planned, and + this adds no SQL or apply authority. +- PostgreSQL 14–18 recovery acceptance now enters through that production + composition entry point. Its existing test-only wrapper still injects the + predecessor crash, while successor recovery uses the concrete guarded + provider without replaying committed sandbox DDL. + +- Revalidate the exact guarded live-preflight target metadata after connection + acquisition and close the connection without target reads when the stored + target, snapshot scope, run state, cancellation, or attempt lease changed. + +- [FE/Worker/PostgreSQL/Security/CI/Docs] PostgreSQL 14–18 recovery acceptance now stores the restricted target as real encrypted metadata and composes the concrete stored-target provider for exact active-attempt/succeeded-snapshot lookup, in-memory decryption, same-acquired-connection capture, cleanup, and successor takeover without sandbox replay. The matrix replaces only the provider connector with an explicit test-only loopback seam because the production DNS/SSRF guard correctly rejects the private CI target; unmodified guarded-route integration, deployed credential/network isolation, startup wiring, process recovery, SQL apply, and readiness remain Planned. +- [FE/Worker/Security/Docs] Added an unwired concrete stored-PostgreSQL live-preflight provider. It releases and decrypts target material only after the exact active-attempt/succeeded-snapshot guard, opens through the existing DNS/SSRF/TLS-pinned connector, restricts snapshot capture to that same acquired connection and validated schema scope, sanitizes acquisition failures, propagates cancellation/process control, and always closes the target. The durable handler remains its only intended consumer and executes only the structured read-only preflight core. Deployed least-privilege credentials/network isolation, unmodified guarded-route integration, startup wiring, worker operation, SQL apply, and readiness remain Planned. +- [FE/Worker/Security/Docs] The guarded live-target lookup now binds encrypted target material to the exact succeeded, completed base snapshot from the same project and connection and returns its validated optional schema filter. Missing, cross-scope, unfinished, malformed, or non-succeeded snapshot scope fails with the same fixed non-reflecting error before decryption or target access. The representation hides credential bytes and the schema filter; this lookup itself performs no decryption or target access. +- [FE/Worker/Security/Docs] Added `load_guarded_live_preflight_target`, a single-query stored-target resolver that reuses the exact live run/plan/project/active-attempt/cancellation/version/lease/digest/expiry predicate and joins the exact project-owned connection before releasing only encrypted DSN ciphertext and its 12-byte nonce. Secret-bearing bytes are excluded from representations, malformed storage or query failures return one fixed non-reflecting error, and cancellation propagates. It performs no decryption, target connection, provider composition, startup wiring, SQL, or apply. + +- [BE/PostgreSQL/FE/CI/Docs] PostgreSQL introspection now exposes `capture_postgres_snapshot` as a query-only callback for an already-authorized caller-owned connection and transaction. The normal DSN path still owns its SSRF-guarded connection and read-only repeatable-read transaction, while live-preflight composition can reuse the exact same connection for strict snapshot evidence and bounded checks without opening, committing, rolling back, or closing another connection. A missing caller transaction fails before catalog queries or optional Citus savepoint access. Unit coverage proves lifecycle non-ownership and the missing-transaction failure, and PostgreSQL 14–18 acceptance uses the callback through the restricted live-preflight connection. Stored-target/attempt credential resolution, concrete provider composition, startup wiring, and apply remain Planned. + +- [BE/PostgreSQL/Security/Docs] 인증된 DBML 변환 경로가 quoted identifier를 한 번만 decode하고 PostgreSQL dialect renderer에서 다시 인용합니다. doubled quote, Unicode, 예약어, 공백, 점, 세미콜론과 comment marker는 식별자 데이터로 보존하면서 NUL, 닫히지 않은 quote, 빈/과다 path segment, 63 UTF-8 byte 초과, 4,096자 초과 line을 고정 비반사 `422`로 fail-closed 처리합니다. 파생 PK/FK 이름도 SHA-256 suffix로 63 byte 안에 결정적으로 제한합니다. PostgreSQL 14–18 acceptance는 공격처럼 보이는 quoted name으로 생성 DDL을 실제 실행하고 의도한 relation 하나만 생성되는지 검증하며, 이는 export 안전 경계이지 live apply 권한이나 production readiness 주장이 아닙니다. + +- [BE/Performance/Security] DBML parser 직접 호출도 인증 route와 같은 총 524,288자 및 10,000-line resource limit을 fail-closed로 적용합니다. relation별 증분 counter가 `column_position`을 O(N)으로 계산해 1,000-column/multi-relation 입력에서 growing-list 재순회를 제거합니다. + +- [BE/Security/Docs] `ApplySqlIn.sql` now rejects NUL, DEL, and non-text C0 controls at a route-owned request boundary before authentication or metadata-session dependencies while preserving tab, LF, CR, Unicode text, and the 262,144-character limit. Validation failures on the sensitive legacy route return a fixed `422` without reflecting SQL, `RequestValidationError.body`, or secret-like literals; the conservative DDL parser remains the authorization boundary, so this is transport/log-integrity hardening rather than an SQL-injection claim. + +- [BE/Security/Docs] Legacy `apply-sql` now defaults persistent `dry_run=false` requests to a fixed `403` before stored-target credential access. Operators must explicitly set `LEGACY_PERSISTENT_APPLY_ENABLED=true` in addition to deployer authorization to retain the transitional compatibility path; rollback-only validation and the endpoint shape remain available. This switch is containment for new requests, not structured apply readiness or proof about in-flight database outcome. + +- [CI] Restored current mypy and TypeScript compatibility without changing runtime behavior: pre-apply privilege and precondition loops now use distinct typed locals, live-preflight stage coverage asserts the refreshed durable state version, apply-intent UUID mocks satisfy the browser UUID contract, and asynchronous diagram/apply-intent UI coverage waits for its observable server state before asserting filter and orchestration outcomes. + +- [FE/Worker/Security/CI/Docs] Added a provider-callable, execution-neutral live-preflight handoff guard. One fresh metadata statement fails closed unless the exact run, plan, project, stored target, active unexpired attempt UUID/number, uncancelled `live_preflight_running` state/version, plan digest, and expiry all still match. The guard returns no credential, route, connection, plan JSON, or SQL and sanitizes query failures. PostgreSQL 14–18 acceptance invokes it before the test provider opens its constrained target, rejects the interrupted first attempt at the exact lease-expiry boundary, and accepts the exact successor attempt. Concrete provider composition, credential/route binding, the remaining observation-to-target-open gap, startup wiring, and target access remain Planned. + +- [FE/Worker/Security/Docs] Live-preflight capability requests now bind the stored target and exact durable attempt UUID to the server-refreshed expected run state version. This remains identifier-only and enables a future provider to reject a stale handoff without receiving plan JSON, SQL, DSN, credential, PostgreSQL major, or digest data. Atomic provider-side state/attempt validation, credential resolution, target routing, startup wiring, and apply remain Planned. + +- [BE/PostgreSQL/Security/Docs] Added bounded same-connection pre-apply observation capture. It re-derives the manifest from the exact signed plan, starts one read-only repeatable-read transaction on a caller-owned connection, captures the strict snapshot, executes every fixed privilege probe and structured precondition in manifest order, and returns only the pure non-authorizing assessment. Invalid timeouts fail before target access; callback/driver failures roll back and expose a fixed secret-safe error. The PostgreSQL 14–18 matrix now contains acceptance for base match, owner privilege success, and a negative table-empty fact; exact-head CI remains authoritative evidence. Stored-target/attempt credential binding, advisory/object locks, in-lock repetition, DDL, and apply remain Planned. + +- [BE/PostgreSQL/Security/Docs] Added target-free compilation of exact signed-plan privilege requirements into fixed parameterized PostgreSQL catalog reads. The public compiler re-derives the manifest from the exact signed plan and expected digest, so a caller-built manifest cannot redirect an otherwise valid database/schema `CREATE` or ordinary-table `OWNER` probe; identifiers remain query data. The PostgreSQL 14–18 matrix proves table-owner success and independently constrained read-only-role denial. No production connection, credential binding, query execution, lock proof, or apply authority is added. + +- [BE/Security/Docs] Added a pure manifest-bound pre-apply observation assessment. It accepts only the exact plan digest and one complete ordered row per structured privilege requirement and precondition, rejects missing/extra/renamed/mismatched/non-boolean evidence, and derives explicit base-match, privilege, and precondition facts. It opens no connection, captures no target state, proves no lock or freshness, and grants no apply authority. + +- [BE/Docs] Extended the target-free signed pre-apply manifest with structured compiler-v1 privilege requirements. `CREATE SCHEMA` binds database `CREATE`, `CREATE TABLE` binds schema `CREATE`, and existing-table changes bind table `OWNER`; weaker, unknown, reordered, or duplicated labels fail closed. This opens no target connection and performs no role or privilege observation, credential access, dispatch, SQL, or DDL. + +- [BE/CI/PostgreSQL] Added test-only PostgreSQL 14–18 acceptance for the pre-apply manifest. Against a uniquely quoted Unicode fixture, it acquires the compiled `ACCESS EXCLUSIVE` table lock, observes a concurrent insert terminate at a bounded server timeout, executes the bound table-empty check while the lock is held, rolls back, and then observes the insert succeed. This is ephemeral compiler/database-semantics evidence; it adds no production target connection, credential, lock service, executor, or DDL authority. + +- [BE/Security] Added an execution-neutral pre-apply revalidation manifest. It verifies the exact stored plan digest and strict v1 contract, binds supported PostgreSQL major plus base/target digests to deterministic existing-table lock targets, structured boolean data checks, and zero segments for no-op work or one ordered all-transactional segment for non-empty v1 work. It rejects tampering, contract drift, unsupported versions, review-only proposals, cross-table preconditions, or any precondition not covered by its statement lock. It opens no target connection, acquires no lock, starts no transaction, observes no target state, checks no privilege, dispatches no work, and executes no SQL/DDL; same-connection in-lock revalidation, transaction/rollback execution, and apply remain Planned. + +- [BE/FE] Added an execution-neutral pre-apply lock-plan compiler. It consumes only immutable structured statement kinds, object references, transaction flags, and reviewed risk metadata; deterministically sorts and deduplicates existing-table `ACCESS EXCLUSIVE` targets; preserves quoted mixed-case/Unicode identifiers; and fails closed for missing/unknown compiler versions, blockers, unknown/non-transactional operations, tampered lock modes, invalid identifiers, and oversized plans. It does not connect to a target, acquire locks, dispatch work, execute SQL/DDL, or grant apply authority; those stages remain Planned. + +- [CI/PostgreSQL] PostgreSQL 14–18 live-preflight lock-wait acceptance now clears the lock-holder transaction's cached statistics snapshot before each `pg_stat_activity` observation. This preserves the real lock/timeout/disconnect proof while preventing an early PostgreSQL statistics view from remaining stale for the entire polling loop. + +- [CI/Frontend] App orchestration coverage now waits for diagram rows and the successor project's metadata effects before driving terminal-poll and stale-request assertions, removing runner-speed races without weakening either behavior check. + +- [CI/PostgreSQL] PostgreSQL 14–18 acceptance startup now waits for the official image's init-complete marker before probing readiness. This prevents a transient `pg_isready` success against the temporary initialization server from racing the entrypoint restart between fixture-creation commands. + +- [FE/Worker] Provider-neutral durable dry-run handler에 sandbox와 live-preflight 전체 stage cancellation deadline을 추가했습니다. provider 획득·실행·snapshot capture가 제한을 넘으면 in-flight coroutine에 취소를 요청하고 cooperative capability context cleanup을 기다린 뒤 고정된 비밀 비포함 오류만 반환합니다. 결정적 failure test는 취소를 억제하는 provider가 configured deadline 이후에도 handler와 capability를 유지하다가 스스로 반환한 뒤에만 cleanup되는 한계를 증명합니다. Python 프로세스 내부 timeout은 이 provider를 강제 종료하지 못하므로, 구체 provider의 cancellation conformance, process isolation/외부 kill, credential, network isolation, startup wiring 및 배포 worker operation은 여전히 Planned입니다. + +- [BE/Security] 데이터베이스 connection 생성 시 PostgreSQL·MySQL/MariaDB·Snowflake별 기존 SSRF guard를 연결 전에 한 번 더 실행합니다. 제한된 loopback/private/link-local/reserved 주소, 허용되지 않은 hostname, DSN scheme·host 형식 오류는 credential 암호화나 metadata 영속화 전에 고정된 `422` 오류로 거부하며, 실제 probe/introspection/apply 경계의 DNS 재검증과 IP pinning도 그대로 유지합니다. 이는 application 방어를 보강하지만 배포 egress policy 증거를 대신하지 않습니다. + +- [FE/UI] Exact `passed` dry-run의 plan UUID·digest·observed base가 현재 검토 계획과 모두 일치할 때만 Forward Engineering modal에 비실행 apply intent 확인 form을 표시합니다. 배포자는 대상 connection 이름을 직접 정확히 입력하고, destructive plan이면 별도 확인해야 합니다. 첫 제출 뒤에는 confirmation body와 idempotency key를 함께 고정해 모호한 응답을 다른 target/승인값으로 재사용하지 않으며, 브라우저는 SQL·DSN·credential을 전송하지 않습니다. 접수 결과는 dispatch가 없는 durable intent일 뿐이고 worker, live DDL, recovery, convergence 권한은 여전히 Planned입니다. + +- [FE/UI] Forward Engineering modal이 기존 실행을 표시하는 동안 새 dry-run 의도를 접수하면 단일 active run identity로 전환합니다. 독립 실행 가능한 plan review surface의 기본 polling 동작은 유지하면서 modal orchestration은 이전 run audit surface를 교체해 중복 live region, 상충하는 취소 control, 두 개의 polling loop를 만들지 않습니다. Modal을 닫았다 다시 열면 그 세션에서 생성된 실행 identity를 폐기하고 호출자가 제공한 현재 run으로 복원해 이전 실행 audit을 재사용하지 않습니다. Apply, credential, target 또는 SQL 권한은 추가하지 않습니다. + +- [BE/DB/UI/CI/Docs] Forward Engineering dry-run 취소 의도를 worker metadata 경계에서 terminal `cancelled`로 확인합니다. Attempt 획득이 취소 의도로 거부되면 run 행을 잠가 exact state-version/flag CAS와 빈 증거의 `cancellation_acknowledged` 이벤트를 영속화한 뒤에만 UUID signal을 확인합니다. 이미 terminal인 signal 재전달은 새 attempt, sandbox, live preflight 없이 정리하며, 두 경로 모두 남은 active attempt를 같은 transaction에서 `abandoned`로 기록합니다. Queued 취소는 실행을 시작했다는 `started_at`을 기록하지 않습니다. Alembic `0013_migration_run_cancellation`은 run/event 상태 check에 `cancelled`를 추가하고, UI는 대기 중인 취소 요청과 완료된 취소를 구분합니다. PostgreSQL 14–18 매트릭스는 새 check를 실제 catalog에서 검증하며 restrictive-FK 순서로 test fixture를 정리해 셀 내부 오염을 방지합니다. Deployed worker startup, in-flight process interruption, apply executor와 live DDL 권한은 여전히 Planned입니다. + +- [BE/Recovery] Forward Engineering dual-lease consumer에서 durable handler와 attempt heartbeat가 같은 scheduler turn에 종료되는 경합을 수정했습니다. Handler 완료를 먼저 관찰한 경우에도 exact attempt completion CAS가 최종 소유권을 판정하므로, terminal run 전이 직후 heartbeat가 종료되어 성공한 run signal이 미확인 상태로 남는 일을 방지합니다. Durable-attempt와 Valkey signal heartbeat의 provider 예외는 원문을 폐기하고 고정 lease-loss 오류로 치환합니다. 실제 lease 상실·만료는 completion CAS에서 계속 fail-closed로 거부됩니다. 이 변경은 application worker wiring이나 apply 권한을 추가하지 않습니다. + +- [FE/UI/API/Docs] Forward Engineering의 typed browser transport와 독립 실행 가능한 plan review panel 기반을 추가했습니다. 기존 서버 계약만 사용해 immutable plan 조회, exact plan digest와 idempotency key 기반 dry-run intent, passed dry-run·typed target·destructive 확인 기반 비실행 apply intent, durable run polling, exact state-version cancellation을 호출합니다. Apply intent body는 네 개의 허용 필드만 새 객체로 직렬화해 호출자가 추가한 SQL을 전송하지 않습니다. Review panel은 immutable provenance, risk, blocker, structured executable statement와 review-only proposal을 접근 가능한 영역으로 표시하지만 자체 실행 권한은 갖지 않습니다. Wrapper는 접근 가능한 loading/fixed-error/retry 상태를 제공하고 plan ID 변경 뒤 도착한 성공·실패 응답을 모두 폐기합니다. 전용 modal shell은 focus 진입·trap·복귀, Escape와 명시적 닫기를 제공하고, 서버가 runnable로 판정한 blocker 없는 plan에만 bounded dry-run intent control을 표시합니다. 이 control은 exact UUID/digest만 보내고 synchronous single-flight를 강제하며, 결과가 모호한 재시도에는 같은 idempotency key를 유지하고 stale plan 응답은 폐기합니다. Apply/target/credential/SQL 권한은 추가하지 않습니다. 선택적인 read-only run status/audit panel은 exact run을 안전하게 불러와 상태 의미·취소 의도·고정 오류 코드를 알리고, 서버가 검증한 이벤트 해시 체인 메타데이터만 표시하며 generic evidence payload는 렌더링하지 않습니다. 비종료 상태는 이전 응답이 끝난 뒤에만 다시 조회하고 최초 terminal 응답에서 폴링을 멈춥니다. non-terminal이며 기존 취소 의도가 없는 run에는 exact state-version cancellation control을 표시하고 한 번만 제출합니다. 접수 후에는 verified 상태를 다시 읽으며 결과가 모호하면 자동 재요청 없이 상태 새로고침만 제공합니다. 폴링이 더 최신 non-terminal state version을 반환해도 진행 중 취소 요청의 single-flight guard는 run identity가 바뀌거나 요청이 종료될 때까지 유지됩니다. 전체 Forward UI와 browser E2E/accessibility evidence는 여전히 Planned입니다. + +- [BE/DB/API/Docs] Forward Engineering에 실행 권한이 없는 apply intent 경계를 추가했습니다. `deployer`만 exact plan digest, 동일 plan의 `passed` dry-run UUID와 observed base digest, 정확히 입력한 target connection 이름, plan이 요구하는 destructive 확인값, actor와 idempotency key를 제출할 수 있습니다. 서버는 plan의 schema-model 행을 `FOR UPDATE`로 잠그고 exact revision UUID/number/digest가 여전히 current인지 확인해, 새 revision과 경합한 오래된 plan은 `stale_revision`으로 거부합니다. Alembic `0012_apply_intent_confirmation`은 passed dry-run self-FK, confirmation digest, destructive confirmation을 영속화하고 dry-run/apply별 nullability를 DB check로 강제합니다. Apply intent는 hash-chained genesis evidence만 만들며 dispatch, Valkey signal, credential 접근, target 연결, SQL 또는 DDL 실행 권한을 만들지 않습니다. PostgreSQL 14–18 매트릭스는 실제 마이그레이션과 영속화 및 dispatch 부재를 검증합니다. Executor, apply-time drift/lock/precondition 재검증, 실행, 복구와 verification은 여전히 Planned입니다. + +- [BE/CI/Docs] Forward Engineering Valkey signal renewal now rejects an exact-token owner atomically when its processing lease is already expired, even before a successor reclaims the signal. Unit contracts pass the authoritative current timestamp into the Lua CAS, real Valkey acceptance covers the exact expiry boundary and post-expiry renewal, and every PostgreSQL 14–18 composed-store cell rejects renewal of the intentionally expired signal before successor takeover. This closes the gap with PostgreSQL durable-attempt renewal, which already required an unexpired exact owner. +- [BE/CI/Docs] PostgreSQL 14–18 매트릭스의 각 셀에 digest-pinned Valkey 8을 결합하고 production dual-lease consumer를 두 실제 저장소 경계에서 검증합니다. 고정 비밀 비노출 handler 실패가 PostgreSQL attempt 1을 `abandoned`로 commit하고 정확한 Valkey lease만 재예약하는지, 재시도가 monotonic attempt 2를 `completed`로 commit하는지 증명합니다. 이어서 1초 signal/attempt lease를 acknowledge/finish하지 않은 채 실제로 만료시키고, successor가 두 저장소를 함께 회수해 attempt 3을 `abandoned`, attempt 4를 `completed`로 만들고 run을 `passed`로 전이한 뒤 stale signal을 거부하고 ready/processing/token 상태를 모두 비우는지 검증합니다. 이는 in-process ephemeral composed-store 복구 증거이며 process/container restart, application startup wiring, credential binding, deployed failover, sandbox/target worker 실행 또는 production apply readiness 증거는 아닙니다. +- [BE/Docs] Forward Engineering의 UUID-only Valkey signal claim과 PostgreSQL `migration_run_attempt` 소유권을 묶는 execution-neutral dual-lease adapter를 추가했습니다. Adapter는 별도 metadata transaction에서 exact attempt를 먼저 commit하고, injected handler 실행 중 fresh transaction으로 heartbeat를 갱신하며, durable ownership 상실 시 handler를 취소하고, exact 완료가 commit된 뒤에만 바깥 consumer가 signal을 acknowledge할 수 있게 합니다. Handler/driver 세부 오류는 고정 비밀 비노출 오류로 치환됩니다. Application startup wiring, credential binding, sandbox/target worker execution과 apply 권한은 여전히 Planned입니다. +- [BE/CI] Forward Engineering에 `migration_run_attempt` DB 영속 소유권 기반을 추가했습니다. 실행 가능한 취소되지 않은 dry-run 행을 잠가 한 run당 하나의 active attempt만 허용하고, 만료된 소유자만 `abandoned`로 전환한 뒤 단조 증가 attempt 번호를 발급합니다. 원시 worker identity와 Valkey signal token은 저장하지 않고 SHA-256 해시만 저장하며, 갱신은 실행 가능한 run의 정확한 만료 전 소유자만 단조롭게 연장하고 종료도 정확한 만료 전 소유자만 `completed`/`abandoned`로 CAS합니다. PostgreSQL 14–18 통합 테스트는 마이그레이션, stale-token 거부, terminal-run 갱신 거부, exact-owner 종료, rollback cleanup을 검증합니다. Application consumer/startup 연결, credential routing, sandbox/target 실행 권한은 여전히 Planned입니다. +- [BE/CI] 제한된 live-preflight 자격 증명의 실제 PostgreSQL 14–18 장애 증거를 보강했습니다. 별도 관리자 세션이 대상 테이블의 `ACCESS EXCLUSIVE` 잠금을 유지한 상태에서 구조화된 read precondition이 bounded `statement_timeout`으로 종료되는지, SELECT 권한이 없는 테이블 접근이 고정 오류로 거부되는지, `pg_terminate_backend`로 연결이 끊겨도 드라이버·DSN 세부정보 없는 고정 오류만 반환하는지 검증합니다. 복구 가능한 실패에서는 preflight 트랜잭션이 닫혀 동일 연결을 안전하게 재사용할 수 있어야 하고, 강제 종료에서는 연결이 닫힌 상태로 남아야 합니다. 이는 CI 격리 증거이며 배포된 credential/worker/attempt 바인딩이나 production readiness를 의미하지 않습니다. +- [BE/CI] Forward Engineering isolated dry-run 실행 코어를 추가했습니다. DSN이나 브라우저 SQL을 받지 않고 저장된 plan digest·compiler version·PostgreSQL major·엄격한 materialized base digest·지원 operation kind·전량 transactional 속성을 검증한 뒤 compiler-owned statement를 server/client 양쪽 bounded timeout의 단일 트랜잭션에서 실행합니다. Version·snapshot·transaction-start·statement·commit·rollback-cleanup 실패는 원본 driver 예외의 cause/context를 남기지 않는 고정 비밀 비노출 오류로 정규화하고 cancellation을 보존하며, fresh strict snapshot이 `target_digest`에 수렴해야만 성공합니다. `complete_isolated_dry_run`은 exact result shape를 저장된 plan의 PostgreSQL major·statement count·base/target digest와 다시 대조하고 호출자 선택 상태 없이 고정 `live_preflight_running` CAS와 aggregate evidence만 생성합니다. Digest-pinned PostgreSQL 14–18 CI는 migrated metadata database와 구분된 전용 ephemeral sandbox database에서 실제 DDL→catalog round trip과 durable success bridge를 검증합니다. Deployed sandbox provisioning, dependency materialization, network/egress isolation proof, cleanup, durable worker binding은 여전히 Planned이며 현재 결과는 release evidence가 아닙니다. +- [BE] Forward Engineering dry-run의 `live_preflight_running -> passed | drifted` CAS 전이는 lowercase SHA-256 `observed_base_digest`를 필수로 받고, 저장된 plan JSON/column/run digest 무결성을 재검증한 뒤 일치할 때만 `passed`, 불일치할 때만 `drifted`를 허용합니다. 관측 digest는 run row와 같은 hash-chained event evidence에 원자적으로 결합되며, worker evidence에 포함된 snake/camel/kebab 변형의 동명 필드는 중첩 위치에서도 거부됩니다. Durable hashed attempt primitive는 Implemented이고 consumer/credential/execution 결합은 여전히 Planned입니다. +- [BE/CI] Forward Engineering live-preflight의 실행 중립 primitive를 추가했습니다. 저장된 계획의 구조화 `table_is_empty`·`no_null_values`·`castable_values`만 PostgreSQL 인용 read query로 컴파일하고, 최대 1,000개·parameter-bound transaction-local `statement_timeout`·client timeout·단일 read-only repeatable-read transaction·prepared-statement parsing/execution·boolean-only evidence·고정 비밀 비노출 오류를 강제합니다. `execute_bound_live_preflight`는 caller-owned fresh snapshot callback과 구조화 검사를 같은 read-only repeatable-read transaction에 묶고 관측 digest 및 exact plan-base match만 반환합니다. `complete_live_preflight`는 run/plan 무결성과 만료를 다시 검증하고 저장된 모든 plan precondition과 정확히 일치할 때만 서버 분류와 aggregate evidence를 durable CAS에 전달합니다. PostgreSQL 14–18 CI는 별도 ephemeral target에서 read-only preflight 및 durable result bridge를 검증합니다. Hashed attempt ownership은 Implemented이며 deployed target credential, consumer-to-attempt wiring, sandbox lifecycle, worker execution과 apply는 여전히 Planned입니다. +- [BE] Forward Engineering UUID-only Valkey 신호에 bounded processing lease를 추가했습니다. Due signal claim은 expired lease를 제한적으로 회수하고 exact lease-token을 별도 저장하며, 현재 token만 acknowledge 또는 scheduled retry release할 수 있어 stale claimant가 successor lease를 완료하지 못합니다. Consumer lifecycle, plan/credential loading, target access, worker execution은 여전히 Planned입니다. +- [BE] Forward Engineering identifier-only dispatch에 opt-in scheduled relay lifecycle을 추가했습니다. 각 claim은 새 metadata transaction에서 처리되고 exact-attempt acknowledgement 후에만 commit되며, 실패는 rollback·고정 비밀 비노출 로그·bounded polling으로 처리됩니다. Valkey 없는 활성화는 startup에서 거부되고 shutdown은 모든 background task를 cancel/await합니다. Queue consumer, plan loading, sandbox/target SQL execution은 여전히 Planned입니다. +- [BE/CI] 동일 `Idempotency-Key`로 취소된 dry-run을 재조회할 때 저장된 `cancellation_requested`를 정확히 반환합니다. PostgreSQL 통합 작업은 런타임마다 일회성 credential과 encryption key를 생성하고 checkout credential persistence를 끄며, 로컬 통합 테스트는 URL 또는 기대 PostgreSQL major가 없으면 원인을 명시해 skip합니다. +- [CI] Digest-pinned real Valkey 8 서비스에서 generic job과 migration run 신호가 서로 다른 sorted set에 UUID만 저장하고, generic pop이 migration 신호를 소비하지 않음을 production adapter 경계로 검증합니다. Scheduled publisher lifecycle은 Implemented이며 deployment failover·consumer·worker 실행은 여전히 Planned입니다. +- [BE] Forward Engineering dry-run intent API: editor 이상이 immutable plan UUID, exact `plan_digest`, bounded `Idempotency-Key`를 제출하면 `POST /api/migration-plans/{migration_plan_uuid}/dry-runs`가 database conflict winner를 통해 하나의 queued run/event identity를 원자적으로 생성 또는 재사용하고 `202`를 반환합니다. nonmember IDOR를 숨기고 viewer를 거부하며 actor·request correlation을 evidence에 결합합니다. 이 경계는 worker를 신호하거나 SQL을 실행하지 않으며 sandbox/preflight/apply는 여전히 Planned입니다. +- [BE] Forward Engineering cancellation API: editor 이상이 exact `state_version`을 제출하면 `POST /api/migration-runs/{migration_run_uuid}/cancel`이 동일 상태의 versioned CAS event를 원자적으로 기록하고 `202`를 반환합니다. nonmember identity는 숨기고 viewer를 거부하며, actor·안전한 request correlation ID를 tamper-evident evidence에 결합하고 stale/terminal/integrity 실패는 고정된 비밀 비노출 코드로 응답합니다. worker/apply는 아직 Planned입니다. +- [BE] 만료된 불변 migration plan이 동일 입력의 재컴파일을 영구 차단하지 않도록, 만료 후 30일이 지난 파생 plan 중 durable run 이력이 없는 항목만 authorized project 범위에서 정리합니다. run evidence가 있는 plan은 보존하며, run state OpenAPI enum 정합화·중복 인덱스 제거·CAS 이후 ORM state 동기화와 경계 회귀 테스트를 추가했습니다. +- [BE] 백그라운드 작업 실패 시 예외 문자열이나 알 수 없는 job type 값을 `job_queue.last_error`에 저장하지 않고 고정된 오류 코드만 기록하여 DSN·credential·SQL·샘플 데이터 누출을 차단합니다. +- [BE] Forward Engineering durable-run 기반: `migration_run`·`migration_run_event` 영속화, 실행 권한이나 DSN/SQL/plan payload 없이 run UUID만 연결하는 원자적 `migration_run_dispatch` outbox, project/plan/run-kind/plan-digest/actor를 묶는 versioned request digest, idempotency·상태·event type·before/after state·모든 SHA-256 evidence field·순서 DB 제약, dry-run/apply 상태 전이 계약, SQL·credential 필드와 PostgreSQL 연결 문자열 값을 거부하는 bounded evidence canonicalizer, exact state/version/prior-event-digest를 갱신하고 같은 transaction에 append-only event를 기록하는 optimistic CAS writer, run UUID·순서·상태·evidence·actor·UTC 시각·이전 digest를 묶는 versioned SHA-256 event chain과 run anchor, unexpired executable plan만 database conflict winner로 생성·재사용하는 내부 dry-run writer, `FOR UPDATE SKIP LOCKED` 기반 due-order dispatch claim, 전용 Valkey key에 run UUID만 발행하는 bounded publisher, exact-attempt publish-state CAS, handler 성공 뒤에만 exact lease를 acknowledge하고 sanitized 실패는 그 lease만 bounded retry로 release하는 execution-neutral consumer contract, stale worker 전이를 차단하는 versioned cancellation intent, IDOR-masked event count·canonical genesis·exact transition graph·cancellation flag/event 일치·chronology·evidence·digest chain을 검증하는 run polling API를 추가했습니다. Scheduled publisher lifecycle과 execution-neutral consumer contract는 Implemented이며 application consumer wiring·migration worker·실제 dry-run/apply는 아직 Planned입니다. +- [BE] Forward Engineering 1단계: 브라우저 SQL 대신 버전형 `schema_model`·불변 `schema_model_revision`을 저장하고, 서버가 타깃 connection/snapshot에 결합된 구조화 `migration_plan`을 컴파일합니다. PostgreSQL 식별자 의미·위험·lock/rewrite/data-loss·권한·precondition을 보존하며 미지원 객체는 전량 fail-closed 처리합니다. +- [BE] 프로젝트 멤버가 저장된 불변 `migration_plan`을 IDOR-masked `GET /api/migration-plans/{migration_plan_uuid}` 경로로 다시 열어 프로젝트·모델 revision·connection·snapshot·capability·actor·생성 시각까지 결합된 실행 정체성을 검토할 수 있습니다. +- [BE] 저장된 계획을 조회할 때 canonical plan digest를 재계산하고 별도 저장된 statement/compiler/base/target 결합값까지 대조하여 변조·불일치를 fail-closed `409`로 차단합니다. +- [BE] 프로젝트 역할을 `viewer < editor < deployer < owner`로 확장하고, 기존 `apply-sql`의 실제 반영(`dry_run=false`)은 deployer 이상만 허용합니다. +- [Docs] Forward Engineering의 현재 구현/계획 경계를 PRD·TRD·Architecture·ADR로 명문화했습니다. + - [BE] 🔒 **Cryptography 50+ 보안 경계 갱신**: `pyproject.toml`과 두 hash-locked 요구사항 파일을 동일한 Cryptography 50+ 해석으로 정합화하여 PKCS#7 오류·타이밍 구분으로 인한 CVE-2026-69247 완화를 실제 설치·검증 경로에 반영했습니다. + - [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다. - [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다. - [BE] 🛠️ **함수 인덱스 중복 오탐 수정**: `lower(email)` 등 expression index를 평문 컬럼 인덱스의 중복으로 잘못 판단하지 않도록 괄호 파서를 강화했습니다. diff --git a/CLAUDE.md b/CLAUDE.md index e13a3f78a..cf9f00b6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -pg-erd-cloud is a PostgreSQL-focused cloud ERD (entity-relationship diagram) collaboration/sharing service — currently a runnable MVP skeleton. It reverse-engineers a target PostgreSQL database (optionally Snowflake) into JSON schema snapshots, renders them as an interactive ERD (React Flow), and forward-engineers snapshots into DDL exports (PostgreSQL or Snowflake dialect), schema diffs/migration SQL, DBML/Mermaid exports, and "DB reversing spec" documents (markdown draft, LLM prompt, or live LLM draft via an OpenAI-compatible provider configured with `LLM_API_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL`). Project owners can create share links for unauthenticated read/export access. +pg-erd-cloud is a PostgreSQL-focused cloud ERD (entity-relationship diagram) collaboration/sharing service. It reverse-engineers a target PostgreSQL database (optionally Snowflake) into JSON schema snapshots, renders them as an interactive ERD (React Flow), and forward-engineers snapshots into DDL exports (PostgreSQL or Snowflake dialect), schema diffs/migration SQL, DBML/Mermaid exports, and "DB reversing spec" documents. The safe live Forward Engineering workflow is partially implemented: versioned models, immutable server-compiled plans, durable run state, and provider-neutral isolated-dry-run/live-preflight execution cores exist. Concrete sandbox lifecycle and credential providers, application worker wiring, durable apply/recovery, convergence verification, and the complete frontend journey remain release-blocking planned work. See `ARCHITECTURE.md`, `docs/PRD.md`, and `docs/TRD.md`. Repository docs are mixed-language: README.md and CHANGELOG.md are Korean; CONTRIBUTING.md, SECURITY.md, and most of docs/ are English. @@ -84,11 +84,13 @@ Three deployable pieces in one repo: ### Backend layout (backend/app/) -- `api/` — FastAPI routers (projects, connections, snapshots, share, diagram_views, annotations, api_keys, auth_routes, me), all mounted in `main.py` under `/api`. -- `jobs/` — Postgres-backed job queue (`JobQueue` table). Reverse engineering never blocks the request path: the API enqueues a `snapshot` job, and an in-process worker task (started in the FastAPI lifespan in `main.py`) claims and executes it. Optional Valkey/Redis (`valkey_queue.py`) is only a wake-up signal; Postgres remains the source of truth. +- `api/` — FastAPI routers (projects, connections, snapshots, share, diagram_views, annotations, api_keys, auth_routes, me, schema_models, migration_plans, migration_runs), all mounted in `main.py` under `/api`. +- `jobs/` — Postgres-backed job queue (`JobQueue` table). Reverse engineering never blocks the request path: the API enqueues a `snapshot` job, and an in-process worker task (started in the FastAPI lifespan in `main.py`) claims and executes it. Forward Engineering adds a UUID-only migration dispatch relay, durable attempt leases, terminal cancellation/no-replay signal settlement, and provider-neutral dry-run/preflight orchestration; concrete sandbox and target credential providers are intentionally not wired into application startup yet. Optional Valkey/Redis (`valkey_queue.py`) is only a wake-up signal; Postgres remains the source of truth. - `pg_introspect/` — pg_catalog-based introspection of the *target* PostgreSQL: schemas/tables/columns, PK/FK/UNIQUE/CHECK, indexes. Index access methods are discovered dynamically from `pg_am`/`pg_class.relam` and index DDL is preserved losslessly via `pg_get_indexdef()` — do not hardcode an index-type list (project principle, see README). Also synthesizes safe `example_value` column hints from name/type metadata only (never samples real table data). - `snowflake_introspect/` — optional Snowflake reverse engineering (INFORMATION_SCHEMA; requires the `snowflake` extra). -- `ddl/` — forward engineering: snapshot → DDL export with dialect mapping, migration SQL, migration-safety checks. +- `forward/` — partial Forward Engineering control plane: immutable schema-model revisions and structured plans, bounded isolated-dry-run/live-preflight execution cores, durable run/event state transitions, and fail-closed capability contracts. It is not a production apply executor. +- `ddl/` — export-oriented snapshot → DDL helpers with dialect mapping and migration-safety checks; these exports do not grant execution authority. +- `forward/` — server-authoritative canonical schema models, snapshot adapter, deterministic structured migration-plan compiler, risk/precondition metadata, and digests. Unsupported semantics fail closed. - `diff/` — snapshot-to-snapshot schema diff. - `spec/` — reversing-spec generation, naming lint, data dictionary, relationship inference, LLM integration. - Cross-cutting: `auth.py` (OIDC/Casdoor JWT verification when `OIDC_ISSUER` is set, plus API keys and token revocation), `csrf.py`, `rate_limit.py` (in-memory fixed-window; global `/api/*` limit plus a stricter separate limit for public `/api/share/*`), `security_headers.py`, `observability.py` (JSON request logs + Prometheus metrics — see docs/observability.md), `sanitize.py`/`dsn_redaction.py`, `settings.py` (pydantic-settings; env vars are documented in `.env.example`). @@ -98,7 +100,8 @@ Three deployable pieces in one repo: 1. A user registers a target-DB connection; the DSN is encrypted with `APP_SECRET` before being stored in the app DB. 2. Requesting a snapshot enqueues a job; the background worker connects to the target DB, introspects it, and stores a JSON snapshot (`SchemaSnapshot` + `SchemaSnapshotData`). 3. The frontend fetches snapshots via `/api/*` and renders the ERD; all exports (DDL, diff/migration SQL, reversing spec, DBML/Mermaid) are derived from the stored snapshot, not from live DB access. -4. Share links expose read-only snapshot/export routes under `/api/share/{share_uuid}/...` with a tighter rate limit, and sensitive fields (schema comments, example values) are redacted from publicly shared payloads. +4. The partial safe-live control plane stores canonical `SchemaModelRevision` rows, compiles an exact revision/connection/succeeded snapshot into an immutable `MigrationPlan`, persists dry-run intent/evidence, and persists exact confirmed apply intents without dispatch. It does not start a migration consumer or execute target apply DDL. Never describe this partial control plane as production apply readiness. +5. Share links expose read-only snapshot/export routes under `/api/share/{share_uuid}/...` with a tighter rate limit, and sensitive fields (schema comments, example values) are redacted from publicly shared payloads. ### Dev vs prod compose diff --git a/README.md b/README.md index 29924af51..b6e44c47a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ PostgreSQL 중심 클라우드 ERD 협업·공유 서비스입니다. 대상 DB - Snowflake reverse snapshot JSON(`source_dialect: "snowflake"`)도 PostgreSQL DDL export에서 주요 type을 매핑합니다. - DBML / Mermaid / Prisma 등 프론트엔드 export 경로 포함 + - 안전한 live workflow는 단계적으로 구현 중입니다. 현재 서버 권위의 버전형 schema model과 + 불변 structured migration plan(위험·lock·권한·precondition 포함)을 제공하며, 지원하지 + 않는 FK/index/default/identity/generated 등은 누락하지 않고 fail-closed 처리합니다. + 격리 dry-run, durable apply, 재역설계 convergence와 실제 프론트엔드 flow가 완료되기 + 전에는 production-ready live apply로 간주하지 않습니다. - **DB Reversing 명세서 생성**: - Markdown draft: `GET /api/snapshots/{snapshot_uuid}/reversing-spec.md` - LLM prompt: `GET /api/snapshots/{snapshot_uuid}/reversing-spec.md?mode=llm-prompt` @@ -76,6 +81,25 @@ PostgreSQL은 `CREATE INDEX ... USING `의 ``가 - pg_get_indexdef / pg_get_expr: +## 정본 설계 문서 + +- [Architecture](ARCHITECTURE.md): 현재/목표 구성요소와 신뢰 경계 +- [PRD](docs/PRD.md): 사용자 여정, 요구사항, 성공 기준, 릴리스 게이트 +- [TRD](docs/TRD.md): 구현 경계, API/지원 매트릭스, 기술 추적성 +- [ADR index](docs/adr/README.md): 서버 권위 계획, 격리 dry run, 실행 분할, + durable recovery, 권한·수렴 결정 +- [Forward Engineering v1 contract](docs/contracts/forward-engineering-v1.md): + 모델/계획/상태/error의 규범 계약 +- [UML](docs/UML.md) · [Metadata ERD](docs/DATA_MODEL.md) · + [위협 모델](docs/security/forward-engineering-threat-model.md) · + [운영 런북](docs/runbooks/forward-engineering.md) +- [문서 충분성 감사](docs/DOCUMENTATION_AUDIT.md): 코드↔요구사항↔테스트↔문서 + 연결과 남은 공백 + +`docs/superpowers/specs/2026-08-09-forward-engineering-design.md`는 승인된 목표 +설계와 구현 순서를 보존하는 상세 설계 기록입니다. 현재 동작은 위 정본 문서와 코드가 +우선하며, Figma/FigJam은 보조 시각자료입니다. + ## 실행(로컬, Docker) ```bash @@ -164,9 +188,14 @@ npm run dev - 대상 DB 연결정보(DSN)는 **APP_SECRET** 기반으로 암호화하여 앱 DB에 저장합니다. - 역공학(리버스) 작업은 요청 경로에서 동기 대기하지 않고 job queue로 비동기 처리합니다. - API 보안 체크리스트(프로젝트 기준): [docs/api-security-checklist.md](docs/api-security-checklist.md) +- Forward Engineering 위협·복구 기준: + [threat model](docs/security/forward-engineering-threat-model.md), + [runbook](docs/runbooks/forward-engineering.md) ## 로드맵(요약) - Casdoor OIDC 로그인 UI/리다이렉트 플로우(현재는 토큰 검증/DEV 모드만) - 실시간 협업(커서/코멘트/CRDT 기반 동시 편집) -- 포워드 엔지니어링(diff 기반 변경 SQL 생성/검증) +- 포워드 엔지니어링 2–4단계: 현재 Partial인 signed-plan 격리 실행 코어와 + live read-only preflight primitive를 배포 가능한 sandbox/worker로 완성하고, + durable apply/recovery, post-apply convergence, 접근 가능한 frontend workflow 구현 diff --git a/SECURITY.md b/SECURITY.md index a3ef6dc01..c251133d5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,21 @@ # Security Policy +## Forward Engineering safety boundary + +The live Forward Engineering workflow is not production-complete. Current code +implements a narrow server-authoritative model/plan control plane; isolated dry +run, durable apply, and convergence verification remain release blockers. See +the [Forward Engineering threat model](docs/security/forward-engineering-threat-model.md), +[v1 contract](docs/contracts/forward-engineering-v1.md), and +[operator runbook](docs/runbooks/forward-engineering.md). The legacy +`apply-sql` endpoint is a transitional compatibility surface and must not be +presented as the target graphical workflow. + +The authenticated DBML conversion/export path has a separate +[identifier-to-DDL boundary](docs/doctoring/dbml-identifier-ddl-boundary.md). +It validates and delimits identifier data but grants no target connection or +execution authority. + ## Reporting a Vulnerability If you believe you have found a security vulnerability in this project, please **do not** open a public issue. diff --git a/backend/alembic/versions/0008_schema_model_revision.py b/backend/alembic/versions/0008_schema_model_revision.py new file mode 100644 index 000000000..39bf98bd1 --- /dev/null +++ b/backend/alembic/versions/0008_schema_model_revision.py @@ -0,0 +1,96 @@ +"""versioned editable schema models + +Revision ID: 0008_schema_model_revision +Revises: 0007_api_key +Create Date: 2026-08-09 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0008_schema_model_revision" +down_revision = "0007_api_key" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "schema_model", + sa.Column("schema_model_uuid", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "project_space_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("model_name", sa.Text(), nullable=False), + sa.Column("current_revision_number", sa.Integer(), nullable=False), + sa.Column( + "created_by_user_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("user_account.user_account_uuid"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "project_space_uuid", "model_name", name="uq_schema_model__project_name" + ), + ) + op.create_index( + "ix_schema_model__project_space_uuid", "schema_model", ["project_space_uuid"] + ) + op.create_table( + "schema_model_revision", + sa.Column( + "schema_model_revision_uuid", + postgresql.UUID(as_uuid=True), + primary_key=True, + ), + sa.Column( + "schema_model_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("schema_model.schema_model_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("revision_number", sa.Integer(), nullable=False), + sa.Column("revision_digest", sa.Text(), nullable=False), + sa.Column("model_json", postgresql.JSONB(), nullable=False), + sa.Column( + "base_schema_snapshot_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("schema_snapshot.schema_snapshot_uuid", ondelete="RESTRICT"), + nullable=True, + ), + sa.Column( + "created_by_user_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("user_account.user_account_uuid"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "schema_model_uuid", + "revision_number", + name="uq_schema_model_revision__model_number", + ), + ) + op.create_index( + "ix_schema_model_revision__schema_model_uuid", + "schema_model_revision", + ["schema_model_uuid"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_schema_model_revision__schema_model_uuid", + table_name="schema_model_revision", + ) + op.drop_table("schema_model_revision") + op.drop_index("ix_schema_model__project_space_uuid", table_name="schema_model") + op.drop_table("schema_model") diff --git a/backend/alembic/versions/0009_migration_plan.py b/backend/alembic/versions/0009_migration_plan.py new file mode 100644 index 000000000..50a5a0b12 --- /dev/null +++ b/backend/alembic/versions/0009_migration_plan.py @@ -0,0 +1,97 @@ +"""immutable migration plans + +Revision ID: 0009_migration_plan +Revises: 0008_schema_model_revision +Create Date: 2026-08-09 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0009_migration_plan" +down_revision = "0008_schema_model_revision" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "migration_plan", + sa.Column("migration_plan_uuid", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "project_space_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "schema_model_revision_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey( + "schema_model_revision.schema_model_revision_uuid", + ondelete="RESTRICT", + ), + nullable=False, + ), + sa.Column( + "db_connection_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("db_connection.db_connection_uuid", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "base_schema_snapshot_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("schema_snapshot.schema_snapshot_uuid", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("compiler_version", sa.Text(), nullable=False), + sa.Column("base_digest", sa.Text(), nullable=False), + sa.Column("target_digest", sa.Text(), nullable=False), + sa.Column("statement_digest", sa.Text(), nullable=False), + sa.Column("plan_json", postgresql.JSONB(), nullable=False), + sa.Column( + "created_by_user_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("user_account.user_account_uuid"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "schema_model_revision_uuid", + "db_connection_uuid", + "base_schema_snapshot_uuid", + "statement_digest", + name="uq_migration_plan__immutable_identity", + ), + ) + op.create_index( + "ix_migration_plan__project_space_uuid", + "migration_plan", + ["project_space_uuid"], + ) + op.create_index( + "ix_migration_plan__schema_model_revision_uuid", + "migration_plan", + ["schema_model_revision_uuid"], + ) + op.create_index( + "ix_migration_plan__expires_at", + "migration_plan", + ["expires_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_migration_plan__expires_at", table_name="migration_plan") + op.drop_index( + "ix_migration_plan__schema_model_revision_uuid", table_name="migration_plan" + ) + op.drop_index( + "ix_migration_plan__project_space_uuid", table_name="migration_plan" + ) + op.drop_table("migration_plan") diff --git a/backend/alembic/versions/0010_migration_run.py b/backend/alembic/versions/0010_migration_run.py new file mode 100644 index 000000000..335bf54ea --- /dev/null +++ b/backend/alembic/versions/0010_migration_run.py @@ -0,0 +1,249 @@ +"""durable migration runs and append-only events + +Revision ID: 0010_migration_run +Revises: 0009_migration_plan +Create Date: 2026-08-10 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0010_migration_run" +down_revision = "0009_migration_plan" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create durable run identity and append-only transition evidence.""" + + op.create_table( + "migration_run", + sa.Column( + "migration_run_uuid", postgresql.UUID(as_uuid=True), primary_key=True + ), + sa.Column( + "project_space_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "migration_plan_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("migration_plan.migration_plan_uuid", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("run_kind", sa.Text(), nullable=False), + sa.Column("state", sa.Text(), nullable=False), + sa.Column("state_version", sa.Integer(), nullable=False), + sa.Column("idempotency_key_hash", sa.Text(), nullable=False), + sa.Column("plan_digest", sa.Text(), nullable=False), + sa.Column("request_digest", sa.Text(), nullable=False), + sa.Column("latest_event_digest", sa.Text(), nullable=False), + sa.Column( + "requested_by_user_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("user_account.user_account_uuid"), + nullable=False, + ), + sa.Column("cancellation_requested", sa.Boolean(), nullable=False), + sa.Column("observed_base_digest", sa.Text(), nullable=True), + sa.Column("evidence_json", postgresql.JSONB(), nullable=False), + sa.Column("error_code", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint( + "project_space_uuid", + "run_kind", + "idempotency_key_hash", + name="uq_migration_run__idempotent_action", + ), + sa.CheckConstraint( + "run_kind IN ('dry_run', 'apply')", + name="ck_migration_run__run_kind", + ), + sa.CheckConstraint( + "state IN ('queued', 'sandbox_running', 'live_preflight_running', " + "'passed', 'drifted', 'failed', 'applying', 'reconciling', " + "'verifying', 'verified', 'drifted_no_apply', 'not_applied', " + "'verification_failed', 'failed_rolled_back', " + "'applied_with_drift', 'outcome_unknown')", + name="ck_migration_run__state", + ), + sa.CheckConstraint( + "(run_kind = 'dry_run' AND state IN ('queued', 'sandbox_running', " + "'live_preflight_running', 'passed', 'drifted', 'failed')) OR " + "(run_kind = 'apply' AND state IN ('queued', 'applying', " + "'reconciling', 'verifying', 'verified', 'drifted_no_apply', " + "'not_applied', 'verification_failed', 'failed_rolled_back', " + "'applied_with_drift', 'outcome_unknown'))", + name="ck_migration_run__kind_state", + ), + sa.CheckConstraint( + "state_version >= 1", name="ck_migration_run__state_version" + ), + sa.CheckConstraint( + "latest_event_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__latest_event_digest", + ), + sa.CheckConstraint( + "idempotency_key_hash ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__idempotency_key_hash", + ), + sa.CheckConstraint( + "plan_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__plan_digest", + ), + sa.CheckConstraint( + "request_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__request_digest", + ), + sa.CheckConstraint( + "observed_base_digest IS NULL OR " + "observed_base_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__observed_base_digest", + ), + ) + op.create_index( + "ix_migration_run__migration_plan_uuid", + "migration_run", + ["migration_plan_uuid"], + ) + op.create_index( + "ix_migration_run__project_state", + "migration_run", + ["project_space_uuid", "state"], + ) + + op.create_table( + "migration_run_dispatch", + sa.Column( + "migration_run_dispatch_uuid", + postgresql.UUID(as_uuid=True), + primary_key=True, + ), + sa.Column( + "migration_run_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("migration_run.migration_run_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("dispatch_kind", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("attempt_count", sa.Integer(), nullable=False), + sa.Column("not_before", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint( + "migration_run_uuid", + name="uq_migration_run_dispatch__migration_run_uuid", + ), + sa.CheckConstraint( + "dispatch_kind = 'isolated_dry_run'", + name="ck_migration_run_dispatch__dispatch_kind", + ), + sa.CheckConstraint( + "status IN ('pending', 'published')", + name="ck_migration_run_dispatch__status", + ), + sa.CheckConstraint( + "attempt_count >= 0", + name="ck_migration_run_dispatch__attempt_count", + ), + sa.CheckConstraint( + "(status = 'pending' AND published_at IS NULL) OR " + "(status = 'published' AND published_at IS NOT NULL)", + name="ck_migration_run_dispatch__published_at", + ), + ) + op.create_index( + "ix_migration_run_dispatch__status_not_before", + "migration_run_dispatch", + ["status", "not_before"], + ) + + op.create_table( + "migration_run_event", + sa.Column( + "migration_run_event_uuid", + postgresql.UUID(as_uuid=True), + primary_key=True, + ), + sa.Column( + "migration_run_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("migration_run.migration_run_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("sequence_number", sa.Integer(), nullable=False), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column("state_before", sa.Text(), nullable=True), + sa.Column("state_after", sa.Text(), nullable=False), + sa.Column("evidence_json", postgresql.JSONB(), nullable=False), + sa.Column("previous_event_digest", sa.Text(), nullable=True), + sa.Column("event_digest", sa.Text(), nullable=False), + sa.Column( + "actor_user_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("user_account.user_account_uuid"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint( + "migration_run_uuid", + "sequence_number", + name="uq_migration_run_event__run_sequence", + ), + sa.CheckConstraint( + "sequence_number >= 1", + name="ck_migration_run_event__sequence_number", + ), + sa.CheckConstraint( + "(sequence_number = 1 AND previous_event_digest IS NULL) OR " + "(sequence_number > 1 AND previous_event_digest IS NOT NULL)", + name="ck_migration_run_event__previous_digest", + ), + sa.CheckConstraint( + "previous_event_digest IS NULL OR " + "previous_event_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_event__previous_digest_format", + ), + sa.CheckConstraint( + "event_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_event__event_digest", + ), + sa.CheckConstraint( + "event_type ~ '^[a-z][a-z0-9_]{0,63}$'", + name="ck_migration_run_event__event_type", + ), + sa.CheckConstraint( + "state_before IS NULL OR state_before IN ('queued', 'sandbox_running', 'live_preflight_running', 'passed', 'drifted', 'failed', 'applying', 'reconciling', 'verifying', 'verified', 'drifted_no_apply', 'not_applied', 'verification_failed', 'failed_rolled_back', 'applied_with_drift', 'outcome_unknown')", + name="ck_migration_run_event__state_before", + ), + sa.CheckConstraint( + "state_after IN ('queued', 'sandbox_running', 'live_preflight_running', 'passed', 'drifted', 'failed', 'applying', 'reconciling', 'verifying', 'verified', 'drifted_no_apply', 'not_applied', 'verification_failed', 'failed_rolled_back', 'applied_with_drift', 'outcome_unknown')", + name="ck_migration_run_event__state_after", + ), + ) + + +def downgrade() -> None: + """Remove run evidence before its parent run identity.""" + + op.drop_table("migration_run_event") + op.drop_index( + "ix_migration_run_dispatch__status_not_before", + table_name="migration_run_dispatch", + ) + op.drop_table("migration_run_dispatch") + op.drop_index("ix_migration_run__project_state", table_name="migration_run") + op.drop_index( + "ix_migration_run__migration_plan_uuid", table_name="migration_run" + ) + op.drop_table("migration_run") diff --git a/backend/alembic/versions/0011_migration_run_attempt.py b/backend/alembic/versions/0011_migration_run_attempt.py new file mode 100644 index 000000000..34b31343b --- /dev/null +++ b/backend/alembic/versions/0011_migration_run_attempt.py @@ -0,0 +1,104 @@ +"""lease-bound migration worker attempts + +Revision ID: 0011_migration_run_attempt +Revises: 0010_migration_run +Create Date: 2026-08-12 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0011_migration_run_attempt" +down_revision = "0010_migration_run" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create durable hashed worker-attempt ownership history.""" + + op.create_table( + "migration_run_attempt", + sa.Column( + "migration_run_attempt_uuid", + postgresql.UUID(as_uuid=True), + primary_key=True, + ), + sa.Column( + "migration_run_uuid", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("migration_run.migration_run_uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("attempt_number", sa.Integer(), nullable=False), + sa.Column("acquired_state_version", sa.Integer(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("worker_identity_hash", sa.Text(), nullable=False), + sa.Column("signal_lease_token_hash", sa.Text(), nullable=False), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint( + "migration_run_uuid", + "attempt_number", + name="uq_migration_run_attempt__run_number", + ), + sa.CheckConstraint( + "attempt_number >= 1", + name="ck_migration_run_attempt__attempt_number", + ), + sa.CheckConstraint( + "acquired_state_version >= 1", + name="ck_migration_run_attempt__acquired_state_version", + ), + sa.CheckConstraint( + "status IN ('active', 'completed', 'abandoned')", + name="ck_migration_run_attempt__status", + ), + sa.CheckConstraint( + "worker_identity_hash ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_attempt__worker_identity_hash", + ), + sa.CheckConstraint( + "signal_lease_token_hash ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_attempt__signal_lease_token_hash", + ), + sa.CheckConstraint( + "last_heartbeat_at >= acquired_at AND " + "lease_expires_at > acquired_at AND " + "((status = 'active' AND finished_at IS NULL) OR " + "(status IN ('completed', 'abandoned') AND finished_at IS NOT NULL " + "AND finished_at >= last_heartbeat_at))", + name="ck_migration_run_attempt__timestamps", + ), + ) + op.create_index( + "ix_migration_run_attempt__active_run", + "migration_run_attempt", + ["migration_run_uuid"], + unique=True, + postgresql_where=sa.text("status = 'active'"), + ) + op.create_index( + "ix_migration_run_attempt__lease_expiry", + "migration_run_attempt", + ["status", "lease_expires_at"], + ) + + +def downgrade() -> None: + """Remove worker-attempt history.""" + + op.drop_index( + "ix_migration_run_attempt__lease_expiry", + table_name="migration_run_attempt", + ) + op.drop_index( + "ix_migration_run_attempt__active_run", + table_name="migration_run_attempt", + ) + op.drop_table("migration_run_attempt") diff --git a/backend/alembic/versions/0012_apply_intent_confirmation.py b/backend/alembic/versions/0012_apply_intent_confirmation.py new file mode 100644 index 000000000..a68a055fa --- /dev/null +++ b/backend/alembic/versions/0012_apply_intent_confirmation.py @@ -0,0 +1,77 @@ +"""bind apply intents to exact reviewed dry-run confirmation + +Revision ID: 0012_apply_intent_confirmation +Revises: 0011_migration_run_attempt +Create Date: 2026-08-12 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0012_apply_intent_confirmation" +down_revision = "0011_migration_run_attempt" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add immutable reviewed-input bindings without adding execution authority.""" + + op.add_column( + "migration_run", + sa.Column("passed_dry_run_uuid", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "migration_run", sa.Column("confirmation_digest", sa.Text(), nullable=True) + ) + op.add_column( + "migration_run", + sa.Column("destructive_confirmation", sa.Boolean(), nullable=True), + ) + op.create_foreign_key( + "fk_migration_run__passed_dry_run_uuid", + "migration_run", + "migration_run", + ["passed_dry_run_uuid"], + ["migration_run_uuid"], + ondelete="RESTRICT", + ) + op.create_check_constraint( + "ck_migration_run__confirmation_digest", + "migration_run", + "confirmation_digest IS NULL OR confirmation_digest ~ '^[0-9a-f]{64}$'", + ) + op.create_check_constraint( + "ck_migration_run__apply_confirmation", + "migration_run", + "(run_kind = 'dry_run' AND passed_dry_run_uuid IS NULL AND " + "confirmation_digest IS NULL AND destructive_confirmation IS NULL) OR " + "(run_kind = 'apply' AND passed_dry_run_uuid IS NOT NULL AND " + "confirmation_digest IS NOT NULL AND destructive_confirmation IS NOT NULL)", + ) + op.create_index( + "ix_migration_run__passed_dry_run_uuid", + "migration_run", + ["passed_dry_run_uuid"], + ) + + +def downgrade() -> None: + """Remove apply-intent confirmation bindings.""" + + op.drop_index("ix_migration_run__passed_dry_run_uuid", table_name="migration_run") + op.drop_constraint( + "ck_migration_run__apply_confirmation", "migration_run", type_="check" + ) + op.drop_constraint( + "ck_migration_run__confirmation_digest", "migration_run", type_="check" + ) + op.drop_constraint( + "fk_migration_run__passed_dry_run_uuid", "migration_run", type_="foreignkey" + ) + op.drop_column("migration_run", "destructive_confirmation") + op.drop_column("migration_run", "confirmation_digest") + op.drop_column("migration_run", "passed_dry_run_uuid") diff --git a/backend/alembic/versions/0013_migration_run_cancellation.py b/backend/alembic/versions/0013_migration_run_cancellation.py new file mode 100644 index 000000000..fc2bf172d --- /dev/null +++ b/backend/alembic/versions/0013_migration_run_cancellation.py @@ -0,0 +1,91 @@ +"""persist terminal worker acknowledgement of cancellation intent + +Revision ID: 0013_migration_run_cancellation +Revises: 0012_apply_intent_confirmation +Create Date: 2026-08-14 +""" + +from __future__ import annotations + +from alembic import op + +revision = "0013_migration_run_cancellation" +down_revision = "0012_apply_intent_confirmation" +branch_labels = None +depends_on = None + +_RUN_STATES = ( + "'queued', 'sandbox_running', 'live_preflight_running', 'passed', " + "'drifted', 'failed', 'applying', 'reconciling', 'verifying', " + "'verified', 'drifted_no_apply', 'not_applied', 'verification_failed', " + "'failed_rolled_back', 'applied_with_drift', 'outcome_unknown'" +) +_RUN_STATES_WITH_CANCELLED = f"{_RUN_STATES}, 'cancelled'" +_DRY_RUN_STATES = ( + "'queued', 'sandbox_running', 'live_preflight_running', 'passed', " + "'drifted', 'failed'" +) +_APPLY_RUN_STATES = ( + "'queued', 'applying', 'reconciling', 'verifying', 'verified', " + "'drifted_no_apply', 'not_applied', 'verification_failed', " + "'failed_rolled_back', 'applied_with_drift', 'outcome_unknown'" +) + + +def _replace_state_constraints(*, include_cancelled: bool) -> None: + """Replace run/event checks while preserving every predecessor state.""" + + for table_name, constraint_name in ( + ("migration_run_event", "ck_migration_run_event__state_after"), + ("migration_run_event", "ck_migration_run_event__state_before"), + ("migration_run", "ck_migration_run__kind_state"), + ("migration_run", "ck_migration_run__state"), + ): + op.drop_constraint(constraint_name, table_name, type_="check") + + states = _RUN_STATES_WITH_CANCELLED if include_cancelled else _RUN_STATES + dry_run_states = ( + f"{_DRY_RUN_STATES}, 'cancelled'" + if include_cancelled + else _DRY_RUN_STATES + ) + apply_run_states = ( + f"{_APPLY_RUN_STATES}, 'cancelled'" + if include_cancelled + else _APPLY_RUN_STATES + ) + op.create_check_constraint( + "ck_migration_run__state", + "migration_run", + f"state IN ({states})", + ) + op.create_check_constraint( + "ck_migration_run__kind_state", + "migration_run", + "(run_kind = 'dry_run' AND " + f"state IN ({dry_run_states})) OR " + "(run_kind = 'apply' AND " + f"state IN ({apply_run_states}))", + ) + op.create_check_constraint( + "ck_migration_run_event__state_before", + "migration_run_event", + f"state_before IS NULL OR state_before IN ({states})", + ) + op.create_check_constraint( + "ck_migration_run_event__state_after", + "migration_run_event", + f"state_after IN ({states})", + ) + + +def upgrade() -> None: + """Admit one terminal state that acknowledges persisted cancellation.""" + + _replace_state_constraints(include_cancelled=True) + + +def downgrade() -> None: + """Restore predecessor checks, failing if cancelled evidence still exists.""" + + _replace_state_constraints(include_cancelled=False) diff --git a/backend/app/api/connections.py b/backend/app/api/connections.py index 80dcc820a..8bbc1118a 100644 --- a/backend/app/api/connections.py +++ b/backend/app/api/connections.py @@ -9,9 +9,14 @@ from app.auth import CurrentUser, get_current_user from app.db import get_read_session, get_session -from app.db_introspect import apply_database_sql, probe_database +from app.db_introspect import ( + apply_database_sql, + probe_database, + validate_database_dsn_target, +) from app.models import DbConnection from app.permissions import require_project_member +from app.request_validation import SecretSafeLegacyApplyRoute from app.schemas import ( ApplySqlIn, ApplySqlOut, @@ -21,8 +26,13 @@ ) from app.security import decrypt_text, encrypt_text from app.sanitize import sanitize_for_storage +from app.settings import settings -router = APIRouter(prefix="/api/connections", tags=["connections"]) +router = APIRouter( + prefix="/api/connections", + tags=["connections"], + route_class=SecretSafeLegacyApplyRoute, +) @router.get("/by-project/{project_space_uuid}", response_model=list[ConnectionOut]) @@ -56,6 +66,12 @@ async def create_connection( await require_project_member( session, project_space_uuid, user.user_account_uuid, minimum_role="editor" ) + try: + await validate_database_dsn_target(body.dsn) + except Exception: # noqa: BLE001 - expose no host, DNS, or credential detail + raise HTTPException( + status_code=422, detail="database DSN target is not allowed" + ) from None encrypted = encrypt_text(str(sanitize_for_storage(body.dsn))) c = DbConnection( db_connection_uuid=uuid.uuid4(), @@ -76,12 +92,16 @@ async def apply_sql( db_connection_uuid: uuid.UUID, body: ApplySqlIn, user: CurrentUser = Depends(get_current_user), - session: AsyncSession = Depends(get_read_session), + session: AsyncSession = Depends(get_session), ) -> ApplySqlOut: """Forward engineering: apply allow-listed DDL to a stored connection. SECURITY-SENSITIVE (writes to a live database): - * Requires the **editor** role on the connection's project. + * Requires **editor** for rollback-only compatibility validation and the + stronger **deployer** role for a persistent live apply. Persistent apply + is disabled by default and requires an explicit operator opt-in. + * Authorization and connection lookup use the primary session so replica + lag cannot revive a revoked deployer role. * IDOR-safe: non-members get a uniform 404 (no enumeration); members lacking editor get 403. * Rejects arbitrary SQL and requires unquoted snake_case database object @@ -109,9 +129,14 @@ async def apply_sql( if exc.status_code == 403: raise HTTPException(status_code=404, detail="connection not found") raise + required_role = "editor" if body.dry_run else "deployer" await require_project_member( - session, project_space_uuid, user.user_account_uuid, minimum_role="editor" + session, project_space_uuid, user.user_account_uuid, minimum_role=required_role ) + if not body.dry_run and not settings.legacy_persistent_apply_enabled: + raise HTTPException( + status_code=403, detail="persistent legacy apply is disabled" + ) conn = await session.get(DbConnection, db_connection_uuid) if conn is None: diff --git a/backend/app/api/dbml.py b/backend/app/api/dbml.py index c35db752a..474d5e542 100644 --- a/backend/app/api/dbml.py +++ b/backend/app/api/dbml.py @@ -1,11 +1,11 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from app.auth import CurrentUser, get_current_user from app.ddl.export import snapshot_json_to_sql from app.schemas import DbmlConvertIn, DbmlConvertOut -from app.spec.dbml_import import parse_dbml +from app.spec.dbml_import import DbmlIdentifierError, parse_dbml router = APIRouter(prefix="/api/dbml", tags=["dbml"]) @@ -22,7 +22,10 @@ async def convert_dbml( works on a design that never touched a database. Pure computation — no project resources involved, so authentication alone suffices. """ - snapshot = parse_dbml(body.dbml) + try: + snapshot = parse_dbml(body.dbml) + except DbmlIdentifierError as error: + raise HTTPException(status_code=422, detail="invalid DBML identifier") from error ddl = ( snapshot_json_to_sql(snapshot, target_dialect=body.dialect) if body.include_ddl diff --git a/backend/app/api/migration_plans.py b/backend/app/api/migration_plans.py new file mode 100644 index 000000000..3a273974f --- /dev/null +++ b/backend/app/api/migration_plans.py @@ -0,0 +1,648 @@ +"""Create immutable migration plans from stored model revisions.""" + +from __future__ import annotations + +import datetime as dt +import json +import uuid +from collections.abc import Mapping +from typing import Annotated, Any, cast + +import anyio +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status +from sqlalchemy import delete, exists, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import CurrentUser, get_current_user +from app.db import get_session +from app.forward.migration_plan import ( + compile_migration_plan, + verify_migration_plan_digest, +) +from app.forward.migration_run import MigrationRunContractError, create_migration_run +from app.forward.schema_model import SchemaModelValidationError +from app.forward.snapshot_adapter import snapshot_to_schema_model +from app.models import ( + DbConnection, + MigrationPlan, + MigrationRun, + SchemaModel, + SchemaModelRevision, + SchemaSnapshot, + SchemaSnapshotData, +) +from app.permissions import require_project_member +from app.schemas import ( + MigrationApplyRunCreateIn, + MigrationPlanCreateIn, + MigrationPlanOut, + MigrationRunActionOut, + MigrationRunCreateIn, + MigrationRunState, +) + +router = APIRouter(prefix="/api", tags=["migration-plans"]) +PLAN_LIFETIME = dt.timedelta(hours=24) +EXPIRED_PLAN_RETENTION = dt.timedelta(days=30) +MAX_PLAN_STATEMENTS = 1_000 +MAX_PLAN_BYTES = 4 * 1024 * 1024 + + +def _request_id(request: Request) -> str: + """Return the middleware-selected request ID or a safe local fallback.""" + + value = getattr(request.state, "request_id", None) + if isinstance(value, str) and 1 <= len(value) <= 64: + return value + return str(uuid.uuid4()) + + +def _creation_error( + request: Request, *, status_code: int, code: str, detail: str +) -> HTTPException: + """Return the stable sanitized error envelope for run creation.""" + + return HTTPException( + status_code=status_code, + detail={ + "code": code, + "detail": detail, + "correlation_id": _request_id(request), + }, + ) + + +def _creation_contract_error( + request: Request, error: MigrationRunContractError +) -> HTTPException: + """Map internal creation failures onto bounded public error codes.""" + + status_code, code = { + "migration plan integrity verification failed": ( + status.HTTP_409_CONFLICT, + "plan_integrity_invalid", + ), + "migration plan expired": (status.HTTP_409_CONFLICT, "plan_expired"), + "migration plan cannot be dry-run": ( + status.HTTP_409_CONFLICT, + "plan_not_dry_runnable", + ), + "idempotency key conflict": ( + status.HTTP_409_CONFLICT, + "idempotency_key_conflict", + ), + "idempotency winner is unavailable": ( + status.HTTP_503_SERVICE_UNAVAILABLE, + "run_creation_unavailable", + ), + "idempotency key length is invalid": ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "idempotency_key_invalid", + ), + "idempotency key contains a control character": ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "idempotency_key_invalid", + ), + }.get(str(error), (status.HTTP_409_CONFLICT, "run_action_rejected")) + return _creation_error( + request, + status_code=status_code, + code=code, + detail="dry-run creation was rejected", + ) + + +def _apply_creation_contract_error( + request: Request, error: MigrationRunContractError +) -> HTTPException: + """Map apply-intent rejection onto stable non-executing API errors.""" + + public_code = error.code + status_code, code = { + "plan_integrity_invalid": (status.HTTP_409_CONFLICT, public_code), + "plan_expired": (status.HTTP_409_CONFLICT, public_code), + "plan_not_executable": (status.HTTP_409_CONFLICT, public_code), + "stale_revision": (status.HTTP_409_CONFLICT, public_code), + "passed_dry_run_invalid": (status.HTTP_409_CONFLICT, public_code), + "target_confirmation_mismatch": (status.HTTP_409_CONFLICT, public_code), + "destructive_confirmation_mismatch": (status.HTTP_409_CONFLICT, public_code), + "apply_confirmation_invalid": ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + public_code, + ), + "idempotency_key_conflict": (status.HTTP_409_CONFLICT, public_code), + "run_creation_unavailable": ( + status.HTTP_503_SERVICE_UNAVAILABLE, + public_code, + ), + "idempotency_key_invalid": ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + public_code, + ), + }.get(public_code, (status.HTTP_409_CONFLICT, "run_action_rejected")) + return _creation_error( + request, + status_code=status_code, + code=code, + detail="apply intent creation was rejected", + ) + + +def _compile_and_serialize_plan( + snapshot_json: Mapping[str, Any], model_json: Mapping[str, Any] +) -> tuple[dict[str, Any], bytes]: + """Compile and serialize a plan outside the request event loop.""" + + base_model = snapshot_to_schema_model(snapshot_json) + plan_json = compile_migration_plan(base_model, model_json) + serialized_plan = json.dumps( + plan_json, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return plan_json, serialized_plan + + +def _plan_out(plan: MigrationPlan) -> MigrationPlanOut: + """Return the public representation of one persisted immutable plan.""" + + plan_json = plan.plan_json + if ( + not verify_migration_plan_digest(plan_json, plan.statement_digest) + or plan_json.get("compiler_version") != plan.compiler_version + or plan_json.get("base_digest") != plan.base_digest + or plan_json.get("target_digest") != plan.target_digest + ): + raise HTTPException( + status_code=409, + detail="migration plan integrity verification failed", + ) + return MigrationPlanOut( + migration_plan_uuid=plan.migration_plan_uuid, + project_space_uuid=plan.project_space_uuid, + schema_model_revision_uuid=plan.schema_model_revision_uuid, + db_connection_uuid=plan.db_connection_uuid, + base_schema_snapshot_uuid=plan.base_schema_snapshot_uuid, + plan_digest=plan.statement_digest, + base_digest=plan.base_digest, + target_digest=plan.target_digest, + compiler_version=plan.compiler_version, + snapshot_contract_version=plan_json["snapshot_contract_version"], + postgresql_major=plan_json["postgresql_major"], + created_by_user_uuid=plan.created_by_user_uuid, + created_at=plan.created_at, + can_dry_run=bool(plan_json["can_dry_run"]), + requires_destructive_confirmation=bool( + plan_json["requires_destructive_confirmation"] + ), + statements=plan_json["statements"], + proposed_statements=plan_json.get("proposed_statements", []), + blockers=plan_json["blockers"], + risk_summary=plan_json["risk_summary"], + expires_at=plan.expires_at, + ) + + +async def _existing_plan( + session: AsyncSession, + *, + revision_uuid: uuid.UUID, + connection_uuid: uuid.UUID, + snapshot_uuid: uuid.UUID, + statement_digest: str, +) -> MigrationPlan | None: + """Load the one plan allowed for an immutable compiler input identity.""" + + return cast( + MigrationPlan | None, + await session.scalar( + select(MigrationPlan).where( + MigrationPlan.schema_model_revision_uuid == revision_uuid, + MigrationPlan.db_connection_uuid == connection_uuid, + MigrationPlan.base_schema_snapshot_uuid == snapshot_uuid, + MigrationPlan.statement_digest == statement_digest, + ) + ), + ) + + +async def _cleanup_expired_unreferenced_plans( + session: AsyncSession, + *, + project_space_uuid: uuid.UUID, + now: dt.datetime, +) -> int: + """Delete old derived plans only when no durable run references them. + + Cleanup is tenant-scoped and retains every plan for 30 days after expiry. + Run evidence uses a restrictive foreign key, and the correlated exclusion + makes that retention boundary explicit before the database enforces it. + """ + + run_exists = exists( + select(MigrationRun.migration_run_uuid).where( + MigrationRun.migration_plan_uuid + == MigrationPlan.migration_plan_uuid + ) + ) + result = cast( + Any, + await session.execute( + delete(MigrationPlan).where( + MigrationPlan.project_space_uuid == project_space_uuid, + MigrationPlan.expires_at <= now - EXPIRED_PLAN_RETENTION, + ~run_exists, + ) + ), + ) + deleted = int(result.rowcount or 0) + if deleted: + await session.commit() + return deleted + + +async def _load_plan_inputs( + session: AsyncSession, + schema_model_revision_uuid: uuid.UUID, + body: MigrationPlanCreateIn, +) -> tuple[ + SchemaModel, + SchemaModelRevision, + DbConnection, + SchemaSnapshot, + SchemaSnapshotData, +] | None: + revision = await session.get(SchemaModelRevision, schema_model_revision_uuid) + if revision is None: + return None + model = await session.get(SchemaModel, revision.schema_model_uuid) + connection = await session.get(DbConnection, body.db_connection_uuid) + snapshot = await session.get(SchemaSnapshot, body.base_schema_snapshot_uuid) + snapshot_data = await session.get( + SchemaSnapshotData, body.base_schema_snapshot_uuid + ) + if any(value is None for value in (model, connection, snapshot, snapshot_data)): + return None + return model, revision, connection, snapshot, snapshot_data # type: ignore[return-value] + + +@router.post( + "/migration-plans/{migration_plan_uuid}/dry-runs", + response_model=MigrationRunActionOut, + status_code=status.HTTP_202_ACCEPTED, +) +async def create_dry_run( + migration_plan_uuid: uuid.UUID, + body: MigrationRunCreateIn, + request: Request, + idempotency_key: Annotated[ + str, + Header( + alias="Idempotency-Key", + min_length=1, + max_length=255, + pattern=r"^[^\x00-\x1F\x7F]+$", + ), + ], + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> MigrationRunActionOut: + """Persist an editor-authorized dry-run intent without executing SQL.""" + + plan = await session.get(MigrationPlan, migration_plan_uuid) + if plan is None: + raise _creation_error( + request, + status_code=status.HTTP_404_NOT_FOUND, + code="migration_plan_not_found", + detail="migration plan not found", + ) + try: + await require_project_member( + session, + plan.project_space_uuid, + user.user_account_uuid, + minimum_role="editor", + ) + except HTTPException as exc: + if exc.status_code == status.HTTP_403_FORBIDDEN: + if exc.detail == "insufficient project role": + raise _creation_error( + request, + status_code=status.HTTP_403_FORBIDDEN, + code="run_role_required", + detail="editor role required", + ) from exc + raise _creation_error( + request, + status_code=status.HTTP_404_NOT_FOUND, + code="migration_plan_not_found", + detail="migration plan not found", + ) from exc + raise + + if body.plan_digest != plan.statement_digest: + raise _creation_error( + request, + status_code=status.HTTP_409_CONFLICT, + code="stale_plan", + detail="migration plan digest does not match", + ) + + correlation_id = _request_id(request) + try: + creation = await create_migration_run( + session, + plan=plan, + run_kind="dry_run", + idempotency_key=idempotency_key, + requested_by_user_uuid=user.user_account_uuid, + evidence={"request_id": correlation_id, "request_source": "api"}, + ) + except MigrationRunContractError as exc: + raise _creation_contract_error(request, exc) from exc + await session.commit() + return MigrationRunActionOut( + migration_run_uuid=creation.migration_run_uuid, + state=cast(MigrationRunState, creation.state), + state_version=creation.state_version, + cancellation_requested=creation.cancellation_requested, + reused=creation.reused, + ) + + +@router.post( + "/migration-plans/{migration_plan_uuid}/apply-runs", + response_model=MigrationRunActionOut, + status_code=status.HTTP_202_ACCEPTED, +) +async def create_apply_run( + migration_plan_uuid: uuid.UUID, + body: MigrationApplyRunCreateIn, + request: Request, + idempotency_key: Annotated[ + str, + Header( + alias="Idempotency-Key", + min_length=1, + max_length=255, + pattern=r"^[^\x00-\x1F\x7F]+$", + ), + ], + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> MigrationRunActionOut: + """Persist deployer-reviewed apply intent without dispatch or execution.""" + + plan = await session.get(MigrationPlan, migration_plan_uuid) + if plan is None: + raise _creation_error( + request, + status_code=404, + code="migration_plan_not_found", + detail="migration plan not found", + ) + try: + await require_project_member( + session, + plan.project_space_uuid, + user.user_account_uuid, + minimum_role="deployer", + ) + except HTTPException as exc: + if exc.status_code == 403: + if exc.detail == "insufficient project role": + raise _creation_error( + request, + status_code=403, + code="run_role_required", + detail="deployer role required", + ) from exc + raise _creation_error( + request, + status_code=404, + code="migration_plan_not_found", + detail="migration plan not found", + ) from exc + raise + if body.plan_digest != plan.statement_digest: + raise _creation_error( + request, + status_code=409, + code="stale_plan", + detail="migration plan digest does not match", + ) + + model_revision = await session.get( + SchemaModelRevision, plan.schema_model_revision_uuid + ) + if model_revision is None: + raise _creation_error( + request, + status_code=409, + code="stale_revision", + detail="migration model revision is stale", + ) + schema_model = await session.get( + SchemaModel, model_revision.schema_model_uuid, with_for_update=True + ) + if schema_model is None: + raise _creation_error( + request, + status_code=409, + code="stale_revision", + detail="migration model revision is stale", + ) + passed_dry_run = await session.get(MigrationRun, body.passed_dry_run_uuid) + connection = await session.get(DbConnection, plan.db_connection_uuid) + if passed_dry_run is None: + raise _creation_error( + request, + status_code=409, + code="passed_dry_run_invalid", + detail="passed dry run is invalid", + ) + if connection is None: + raise _creation_error( + request, + status_code=409, + code="target_confirmation_mismatch", + detail="target connection confirmation does not match", + ) + correlation_id = _request_id(request) + try: + creation = await create_migration_run( + session, + plan=plan, + run_kind="apply", + idempotency_key=idempotency_key, + requested_by_user_uuid=user.user_account_uuid, + evidence={"request_id": correlation_id, "request_source": "api"}, + passed_dry_run=passed_dry_run, + connection=connection, + typed_connection_name=body.target_connection_name, + destructive_acknowledged=body.destructive_acknowledged, + model_revision=model_revision, + schema_model=schema_model, + ) + except MigrationRunContractError as exc: + raise _apply_creation_contract_error(request, exc) from exc + await session.commit() + return MigrationRunActionOut( + migration_run_uuid=creation.migration_run_uuid, + state=cast(MigrationRunState, creation.state), + state_version=creation.state_version, + cancellation_requested=creation.cancellation_requested, + reused=creation.reused, + ) + + +@router.get( + "/migration-plans/{migration_plan_uuid}", + response_model=MigrationPlanOut, +) +async def get_migration_plan( + migration_plan_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> MigrationPlanOut: + """Return one immutable plan preview to an authorized project member.""" + + plan = await session.get(MigrationPlan, migration_plan_uuid) + if plan is None: + raise HTTPException(status_code=404, detail="migration plan not found") + try: + await require_project_member( + session, + plan.project_space_uuid, + user.user_account_uuid, + ) + except HTTPException as exc: + if exc.status_code == 403: + raise HTTPException( + status_code=404, detail="migration plan not found" + ) from exc + raise + return _plan_out(plan) + + +@router.post( + "/schema-model-revisions/{schema_model_revision_uuid}/migration-plans", + response_model=MigrationPlanOut, +) +async def create_migration_plan( + schema_model_revision_uuid: uuid.UUID, + body: MigrationPlanCreateIn, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> MigrationPlanOut: + """Compile and persist an exact, expiring, reviewable migration plan.""" + + loaded = await _load_plan_inputs(session, schema_model_revision_uuid, body) + if loaded is None: + raise HTTPException(status_code=404, detail="migration plan input not found") + model, revision, connection, snapshot, snapshot_data = loaded + try: + await require_project_member( + session, + model.project_space_uuid, + user.user_account_uuid, + minimum_role="editor", + ) + except HTTPException as exc: + if exc.status_code == 403: + raise HTTPException( + status_code=404, detail="migration plan input not found" + ) from exc + raise + if not ( + connection.project_space_uuid + == snapshot.project_space_uuid + == model.project_space_uuid + ): + raise HTTPException( + status_code=404, + detail="migration plan input not found", + ) + if snapshot.db_connection_uuid != connection.db_connection_uuid: + raise HTTPException( + status_code=422, + detail="base snapshot was not captured from the target connection", + ) + if snapshot.status != "succeeded": + raise HTTPException(status_code=422, detail="base snapshot is not usable") + if revision.schema_model_uuid != model.schema_model_uuid: + raise HTTPException(status_code=422, detail="model revision binding is invalid") + try: + plan_json, serialized_plan = await anyio.to_thread.run_sync( + _compile_and_serialize_plan, + snapshot_data.snapshot_json, + revision.model_json, + ) + except SchemaModelValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + proposed_statements = plan_json.get("proposed_statements", []) + if ( + len(plan_json["statements"]) + len(proposed_statements) + > MAX_PLAN_STATEMENTS + or len(serialized_plan) > MAX_PLAN_BYTES + ): + raise HTTPException(status_code=413, detail="migration plan is too large") + + now = dt.datetime.now(dt.timezone.utc) + expires_at = now + PLAN_LIFETIME + await _cleanup_expired_unreferenced_plans( + session, + project_space_uuid=model.project_space_uuid, + now=now, + ) + existing = await _existing_plan( + session, + revision_uuid=revision.schema_model_revision_uuid, + connection_uuid=connection.db_connection_uuid, + snapshot_uuid=snapshot.schema_snapshot_uuid, + statement_digest=plan_json["plan_digest"], + ) + if existing is not None: + if existing.expires_at <= now: + raise HTTPException( + status_code=409, + detail="matching migration plan expired; capture a fresh target snapshot", + ) + return _plan_out(existing) + plan = MigrationPlan( + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=model.project_space_uuid, + schema_model_revision_uuid=revision.schema_model_revision_uuid, + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + compiler_version=plan_json["compiler_version"], + base_digest=plan_json["base_digest"], + target_digest=plan_json["target_digest"], + statement_digest=plan_json["plan_digest"], + plan_json=plan_json, + created_by_user_uuid=user.user_account_uuid, + expires_at=expires_at, + created_at=now, + ) + session.add(plan) + try: + await session.flush() + await session.commit() + except IntegrityError as exc: + await session.rollback() + winner = await _existing_plan( + session, + revision_uuid=revision.schema_model_revision_uuid, + connection_uuid=connection.db_connection_uuid, + snapshot_uuid=snapshot.schema_snapshot_uuid, + statement_digest=plan_json["plan_digest"], + ) + if winner is None: + raise + if winner.expires_at <= now: + raise HTTPException( + status_code=409, + detail="matching migration plan expired; capture a fresh target snapshot", + ) from exc + return _plan_out(winner) + return _plan_out(plan) diff --git a/backend/app/api/migration_runs.py b/backend/app/api/migration_runs.py new file mode 100644 index 000000000..aa0775bfb --- /dev/null +++ b/backend/app/api/migration_runs.py @@ -0,0 +1,302 @@ +"""Read and cancel authorized, integrity-checked durable migration runs.""" + +from __future__ import annotations + +import uuid +from typing import Literal, cast + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import CurrentUser, get_current_user +from app.db import get_read_session, get_session +from app.forward.migration_run import ( + APPLY_RUN_STATES, + DRY_RUN_STATES, + MigrationRunContractError, + canonicalize_run_evidence, + digest_run_event, + request_migration_run_cancellation, + validate_run_transition, +) +from app.models import MigrationRun, MigrationRunEvent +from app.permissions import require_project_member +from app.schemas import ( + MigrationRunActionOut, + MigrationRunCancelIn, + MigrationRunEventOut, + MigrationRunOut, + MigrationRunState, +) + +router = APIRouter(prefix="/api/migration-runs", tags=["migration-runs"]) +MAX_RETURNED_RUN_EVENTS = 1_000 + + +def _request_id(request: Request) -> str: + """Return the middleware-selected request ID or a safe local fallback.""" + + value = getattr(request.state, "request_id", None) + if isinstance(value, str) and 1 <= len(value) <= 64: + return value + return str(uuid.uuid4()) + + +def _action_error( + request: Request, + *, + status_code: int, + code: str, + detail: str, +) -> HTTPException: + """Return the stable sanitized error envelope for mutating run APIs.""" + + return HTTPException( + status_code=status_code, + detail={ + "code": code, + "detail": detail, + "correlation_id": _request_id(request), + }, + ) + + +def _cancellation_contract_error( + request: Request, error: MigrationRunContractError +) -> HTTPException: + """Map internal cancellation failures onto bounded public error codes.""" + + message = str(error) + code = { + "migration run state version conflict": "stale_run", + "terminal migration run cannot be cancelled": "run_not_cancellable", + "migration run state is invalid": "run_integrity_invalid", + }.get(message, "run_action_rejected") + return _action_error( + request, + status_code=status.HTTP_409_CONFLICT, + code=code, + detail="migration run cancellation was rejected", + ) + + +def _integrity_error() -> HTTPException: + """Return one sanitized response for every corrupt durable-history shape.""" + + return HTTPException( + status_code=409, + detail="migration run integrity verification failed", + ) + + +def _run_out( + run: MigrationRun, events: list[MigrationRunEvent] +) -> MigrationRunOut: + """Verify sequence/state/evidence integrity before constructing output.""" + + if len(events) > MAX_RETURNED_RUN_EVENTS or len(events) != run.state_version: + raise _integrity_error() + if run.run_kind not in {"dry_run", "apply"} or run.state not in ( + DRY_RUN_STATES if run.run_kind == "dry_run" else APPLY_RUN_STATES + ): + raise _integrity_error() + expected_state: str | None = None + previous_created_at = None + previous_event_digest = None + cancellation_event_seen = False + event_output: list[MigrationRunEventOut] = [] + try: + run_evidence = canonicalize_run_evidence(run.evidence_json) + for expected_sequence, event in enumerate(events, start=1): + canonical_evidence = canonicalize_run_evidence(event.evidence_json) + if expected_sequence == 1: + if ( + event.event_type != "run_queued" + or event.state_before is not None + or event.state_after != "queued" + ): + raise _integrity_error() + elif event.event_type == "cancellation_requested": + if cancellation_event_seen or event.state_before != event.state_after: + raise _integrity_error() + cancellation_event_seen = True + else: + if event.state_before is None: + raise _integrity_error() + validate_run_transition( + run.run_kind, event.state_before, event.state_after + ) + if ( + event.sequence_number != expected_sequence + or event.state_before != expected_state + or event.previous_event_digest != previous_event_digest + or event.event_digest + != digest_run_event( + migration_run_uuid=event.migration_run_uuid, + sequence_number=event.sequence_number, + event_type=event.event_type, + state_before=event.state_before, + state_after=event.state_after, + evidence=canonical_evidence, + actor_user_uuid=event.actor_user_uuid, + created_at=event.created_at, + previous_event_digest=event.previous_event_digest, + ) + or ( + previous_created_at is not None + and event.created_at < previous_created_at + ) + ): + raise _integrity_error() + event_output.append( + MigrationRunEventOut( + sequence_number=event.sequence_number, + event_type=event.event_type, + state_before=event.state_before, + state_after=event.state_after, + evidence=canonical_evidence, + previous_event_digest=event.previous_event_digest, + event_digest=event.event_digest, + actor_user_uuid=event.actor_user_uuid, + created_at=event.created_at, + ) + ) + expected_state = event.state_after + previous_created_at = event.created_at + previous_event_digest = event.event_digest + except MigrationRunContractError as exc: + raise _integrity_error() from exc + if ( + expected_state != run.state + or previous_event_digest != run.latest_event_digest + or cancellation_event_seen != run.cancellation_requested + ): + raise _integrity_error() + + return MigrationRunOut( + migration_run_uuid=run.migration_run_uuid, + project_space_uuid=run.project_space_uuid, + migration_plan_uuid=run.migration_plan_uuid, + run_kind=cast(Literal["dry_run", "apply"], run.run_kind), + state=cast(MigrationRunState, run.state), + state_version=run.state_version, + plan_digest=run.plan_digest, + requested_by_user_uuid=run.requested_by_user_uuid, + cancellation_requested=run.cancellation_requested, + observed_base_digest=run.observed_base_digest, + evidence=run_evidence, + error_code=run.error_code, + created_at=run.created_at, + updated_at=run.updated_at, + started_at=run.started_at, + finished_at=run.finished_at, + events=event_output, + ) + + +@router.post( + "/{migration_run_uuid}/cancel", + response_model=MigrationRunActionOut, + status_code=status.HTTP_202_ACCEPTED, +) +async def cancel_migration_run( + migration_run_uuid: uuid.UUID, + body: MigrationRunCancelIn, + request: Request, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> MigrationRunActionOut: + """Persist one editor-authorized cancellation intent and audit event.""" + + run = await session.get(MigrationRun, migration_run_uuid) + if run is None: + raise _action_error( + request, + status_code=status.HTTP_404_NOT_FOUND, + code="migration_run_not_found", + detail="migration run not found", + ) + try: + await require_project_member( + session, + run.project_space_uuid, + user.user_account_uuid, + minimum_role="editor", + ) + except HTTPException as exc: + if exc.status_code == status.HTTP_403_FORBIDDEN: + if exc.detail == "insufficient project role": + raise _action_error( + request, + status_code=status.HTTP_403_FORBIDDEN, + code="run_role_required", + detail="editor role required", + ) from exc + raise _action_error( + request, + status_code=status.HTTP_404_NOT_FOUND, + code="migration_run_not_found", + detail="migration run not found", + ) from exc + raise + + correlation_id = _request_id(request) + try: + cancellation = await request_migration_run_cancellation( + session, + migration_run_uuid=migration_run_uuid, + expected_state_version=body.expected_state_version, + actor_user_uuid=user.user_account_uuid, + evidence={ + "request_id": correlation_id, + "request_source": "api", + }, + ) + except MigrationRunContractError as exc: + raise _cancellation_contract_error(request, exc) from exc + await session.commit() + return MigrationRunActionOut( + migration_run_uuid=migration_run_uuid, + state=cast(MigrationRunState, cancellation.state), + state_version=cancellation.state_version, + cancellation_requested=True, + reused=cancellation.reused, + ) + + +@router.get("/{migration_run_uuid}", response_model=MigrationRunOut) +async def get_migration_run( + migration_run_uuid: uuid.UUID, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> MigrationRunOut: + """Return one project-authorized run with bounded verified evidence.""" + + run = await session.get(MigrationRun, migration_run_uuid) + if run is None: + raise HTTPException(status_code=404, detail="migration run not found") + try: + await require_project_member( + session, + run.project_space_uuid, + user.user_account_uuid, + ) + except HTTPException as exc: + if exc.status_code == 403: + raise HTTPException( + status_code=404, detail="migration run not found" + ) from exc + raise + + events = list( + ( + await session.scalars( + select(MigrationRunEvent) + .where(MigrationRunEvent.migration_run_uuid == migration_run_uuid) + .order_by(MigrationRunEvent.sequence_number) + .limit(MAX_RETURNED_RUN_EVENTS + 1) + ) + ).all() + ) + return _run_out(run, events) diff --git a/backend/app/api/schema_models.py b/backend/app/api/schema_models.py new file mode 100644 index 000000000..20214bf59 --- /dev/null +++ b/backend/app/api/schema_models.py @@ -0,0 +1,234 @@ +"""Versioned editable-schema API for server-authoritative forward engineering.""" + +from __future__ import annotations + +import datetime as dt +import json +import uuid + +from fastapi import APIRouter, Depends, Header, HTTPException, Response +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import CurrentUser, get_current_user +from app.db import get_read_session, get_session +from app.forward.schema_model import ( + SchemaModelValidationError, + canonicalize_schema_model, + schema_model_digest, +) +from app.models import SchemaModel, SchemaModelRevision, SchemaSnapshot +from app.permissions import require_project_member +from app.sanitize import sanitize_for_storage +from app.schemas import ( + SchemaModelCreateIn, + SchemaModelDetailOut, + SchemaModelReviseIn, +) + +router = APIRouter(prefix="/api/schema-models", tags=["schema-models"]) +MAX_MODEL_BYTES = 2 * 1024 * 1024 + + +def _canonical_model(model_json: dict) -> dict: + """Validate size and return canonical JSON, translating errors to 422.""" + + if len(json.dumps(model_json, ensure_ascii=False).encode("utf-8")) > MAX_MODEL_BYTES: + raise HTTPException(status_code=413, detail="schema model payload too large") + try: + return canonicalize_schema_model(model_json) + except SchemaModelValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +async def _validate_base_snapshot( + session: AsyncSession, + project_space_uuid: uuid.UUID, + schema_snapshot_uuid: uuid.UUID | None, +) -> None: + if schema_snapshot_uuid is None: + return + snapshot = await session.get(SchemaSnapshot, schema_snapshot_uuid) + if ( + snapshot is None + or snapshot.project_space_uuid != project_space_uuid + or snapshot.status != "succeeded" + ): + raise HTTPException(status_code=422, detail="base snapshot is not usable") + + +def _detail(model: SchemaModel, revision: SchemaModelRevision) -> SchemaModelDetailOut: + return SchemaModelDetailOut( + schema_model_uuid=model.schema_model_uuid, + model_name=model.model_name, + schema_model_revision_uuid=revision.schema_model_revision_uuid, + revision_number=revision.revision_number, + revision_digest=revision.revision_digest, + model_json=revision.model_json, + base_schema_snapshot_uuid=revision.base_schema_snapshot_uuid, + ) + + +async def _get_model_for_update( + session: AsyncSession, schema_model_uuid: uuid.UUID +) -> tuple[SchemaModel, SchemaModelRevision] | None: + model = await session.get(SchemaModel, schema_model_uuid, with_for_update=True) + if model is None: + return None + revision = ( + await session.execute( + select(SchemaModelRevision).where( + SchemaModelRevision.schema_model_uuid == schema_model_uuid, + SchemaModelRevision.revision_number == model.current_revision_number, + ) + ) + ).scalar_one() + return model, revision + + +def _revision_etag(revision: SchemaModelRevision) -> str: + """Return a strong ETag containing the immutable revision UUID.""" + + return f'"{revision.schema_model_revision_uuid}"' + + +@router.post("/by-project/{project_space_uuid}", response_model=SchemaModelDetailOut) +async def create_schema_model( + project_space_uuid: uuid.UUID, + body: SchemaModelCreateIn, + response: Response, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> SchemaModelDetailOut: + """Create a model identity and revision one in one database transaction.""" + + await require_project_member( + session, project_space_uuid, user.user_account_uuid, minimum_role="editor" + ) + canonical = _canonical_model(body.model_json) + await _validate_base_snapshot( + session, project_space_uuid, body.base_schema_snapshot_uuid + ) + now = dt.datetime.now(dt.timezone.utc) + model = SchemaModel( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=project_space_uuid, + model_name=str(sanitize_for_storage(body.model_name)), + current_revision_number=1, + created_by_user_uuid=user.user_account_uuid, + created_at=now, + updated_at=now, + ) + revision = SchemaModelRevision( + schema_model_revision_uuid=uuid.uuid4(), + schema_model_uuid=model.schema_model_uuid, + revision_number=1, + revision_digest=schema_model_digest(canonical), + model_json=canonical, + base_schema_snapshot_uuid=body.base_schema_snapshot_uuid, + created_by_user_uuid=user.user_account_uuid, + created_at=now, + ) + session.add(model) + try: + await session.flush() + except IntegrityError as exc: + await session.rollback() + raise HTTPException( + status_code=409, detail="schema model name already exists" + ) from exc + session.add(revision) + await session.commit() + response.headers["ETag"] = _revision_etag(revision) + return _detail(model, revision) + + +@router.put("/{schema_model_uuid}", response_model=SchemaModelDetailOut) +async def revise_schema_model( + schema_model_uuid: uuid.UUID, + body: SchemaModelReviseIn, + response: Response, + if_match: str = Header(alias="If-Match"), + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> SchemaModelDetailOut: + """Append a revision iff ``If-Match`` names the locked revision UUID.""" + + found = await _get_model_for_update(session, schema_model_uuid) + if found is None: + raise HTTPException(status_code=404, detail="schema model not found") + model, current = found + try: + await require_project_member( + session, + model.project_space_uuid, + user.user_account_uuid, + minimum_role="editor", + ) + except HTTPException as exc: + if exc.status_code == 403: + raise HTTPException(status_code=404, detail="schema model not found") from exc + raise + if if_match.strip() != _revision_etag(current): + raise HTTPException(status_code=409, detail="schema model revision is stale") + canonical = _canonical_model(body.model_json) + await _validate_base_snapshot( + session, model.project_space_uuid, body.base_schema_snapshot_uuid + ) + revision_digest = schema_model_digest(canonical) + if ( + revision_digest == current.revision_digest + and body.base_schema_snapshot_uuid == current.base_schema_snapshot_uuid + ): + response.headers["ETag"] = _revision_etag(current) + return _detail(model, current) + now = dt.datetime.now(dt.timezone.utc) + revision = SchemaModelRevision( + schema_model_revision_uuid=uuid.uuid4(), + schema_model_uuid=model.schema_model_uuid, + revision_number=model.current_revision_number + 1, + revision_digest=revision_digest, + model_json=canonical, + base_schema_snapshot_uuid=body.base_schema_snapshot_uuid, + created_by_user_uuid=user.user_account_uuid, + created_at=now, + ) + model.current_revision_number = revision.revision_number + model.updated_at = now + session.add(revision) + await session.commit() + response.headers["ETag"] = _revision_etag(revision) + return _detail(model, revision) + + +@router.get("/{schema_model_uuid}", response_model=SchemaModelDetailOut) +async def get_schema_model( + schema_model_uuid: uuid.UUID, + response: Response, + user: CurrentUser = Depends(get_current_user), + session: AsyncSession = Depends(get_read_session), +) -> SchemaModelDetailOut: + """Return the current immutable revision, masking unauthorized identities.""" + + model = await session.get(SchemaModel, schema_model_uuid) + if model is None: + raise HTTPException(status_code=404, detail="schema model not found") + try: + await require_project_member( + session, model.project_space_uuid, user.user_account_uuid + ) + except HTTPException as exc: + if exc.status_code == 403: + raise HTTPException(status_code=404, detail="schema model not found") from exc + raise + revision = ( + await session.execute( + select(SchemaModelRevision).where( + SchemaModelRevision.schema_model_uuid == schema_model_uuid, + SchemaModelRevision.revision_number == model.current_revision_number, + ) + ) + ).scalar_one() + response.headers["ETag"] = _revision_etag(revision) + return _detail(model, revision) diff --git a/backend/app/db_introspect.py b/backend/app/db_introspect.py index daef76d83..e1f14eb1c 100644 --- a/backend/app/db_introspect.py +++ b/backend/app/db_introspect.py @@ -5,14 +5,19 @@ from app.dsn_redaction import redact_dsn_error_message from app.mysql_introspect import introspect_mysql, probe_mysql +from app.mysql_introspect.introspect import _parse_mysql_dsn from app.pg_introspect.forward_ddl import validate_forward_ddl +from app.pg_introspect.dsn_guard import validate_postgres_dsn_target from app.pg_introspect.introspect import ( apply_postgres_ddl, introspect_postgres, probe_postgres, ) from app.snowflake_introspect import introspect_snowflake -from app.snowflake_introspect.introspect import probe_snowflake +from app.snowflake_introspect.introspect import ( + _parse_snowflake_dsn, + probe_snowflake, +) DatabaseDialect = Literal["postgresql", "snowflake", "mysql"] @@ -30,6 +35,19 @@ def detect_dsn_dialect(dsn: str) -> DatabaseDialect: raise ValueError(f"unsupported database DSN scheme: {scheme or ''}") +async def validate_database_dsn_target(dsn: str) -> None: + """Reject unsafe supported-database targets without opening a connection.""" + + dialect = detect_dsn_dialect(dsn) + if dialect == "snowflake": + await _parse_snowflake_dsn(dsn) + return + if dialect == "mysql": + await _parse_mysql_dsn(dsn) + return + await validate_postgres_dsn_target(dsn) + + async def introspect_database(dsn: str, schema_filter: str | None) -> dict: """Introspect a supported database and return the common snapshot JSON.""" diff --git a/backend/app/ddl/export.py b/backend/app/ddl/export.py index fc13b146c..7a4957c61 100644 --- a/backend/app/ddl/export.py +++ b/backend/app/ddl/export.py @@ -41,13 +41,18 @@ def _snapshot_source_dialect(snapshot: dict) -> DdlDialect: return "postgresql" -def _q(ident: str) -> str: - """Quote a SQL identifier.""" +def quote_identifier(ident: str) -> str: + """Return one SQL-delimited identifier with embedded quotes escaped.""" # Quote identifier with double-quotes, escaping internal quotes. return '"' + ident.replace('"', '""') + '"' +def _q(ident: str) -> str: + """Quote an identifier through the dialect-owned rendering boundary.""" + return quote_identifier(ident) + + def _qname(schema: str, name: str) -> str: """Quote a schema-qualified name.""" return f"{_q(schema)}.{_q(name)}" diff --git a/backend/app/forward/__init__.py b/backend/app/forward/__init__.py new file mode 100644 index 000000000..59ff19007 --- /dev/null +++ b/backend/app/forward/__init__.py @@ -0,0 +1,13 @@ +"""Server-authoritative PostgreSQL forward-engineering contracts.""" + +from app.forward.schema_model import ( + SchemaModelValidationError, + canonicalize_schema_model, + schema_model_digest, +) + +__all__ = [ + "SchemaModelValidationError", + "canonicalize_schema_model", + "schema_model_digest", +] diff --git a/backend/app/forward/apply_lock_plan.py b/backend/app/forward/apply_lock_plan.py new file mode 100644 index 000000000..b57fe5a79 --- /dev/null +++ b/backend/app/forward/apply_lock_plan.py @@ -0,0 +1,157 @@ +"""Compile immutable migration plans into deterministic table-lock targets. + +This module is an execution-neutral input boundary for the planned apply +executor. It consumes structured statement metadata, never parses rendered SQL, +and does not connect to PostgreSQL, acquire locks, dispatch work, or execute DDL. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + +from app.forward.migration_plan import COMPILER_VERSION + +MAX_APPLY_LOCK_STATEMENTS = 1000 +MAX_APPLY_LOCK_TARGETS = 1000 + +_EXISTING_TABLE_STATEMENT_KINDS = frozenset( + { + "add_column", + "alter_column_type", + "drop_column", + "drop_not_null", + "drop_table", + "set_not_null", + } +) +_NEW_OBJECT_STATEMENT_KINDS = frozenset({"create_schema", "create_table"}) +_SUPPORTED_STATEMENT_KINDS = ( + _EXISTING_TABLE_STATEMENT_KINDS | _NEW_OBJECT_STATEMENT_KINDS +) + + +class ApplyLockPlanContractError(ValueError): + """Reject a plan that cannot safely produce deterministic lock targets.""" + + +@dataclass(frozen=True) +class ApplyLockTarget: + """One existing PostgreSQL table that a future executor must lock.""" + + schema_name: str + table_name: str + sql: str + + +def _quote_identifier(identifier: object) -> str: + """Return one bounded PostgreSQL delimited identifier.""" + + if not isinstance(identifier, str): + raise ApplyLockPlanContractError("apply lock identifier must be text") + if not identifier or "\x00" in identifier: + raise ApplyLockPlanContractError("apply lock identifier is invalid") + if len(identifier.encode("utf-8")) > 63: + raise ApplyLockPlanContractError("apply lock identifier is too large") + return '"' + identifier.replace('"', '""') + '"' + + +def _object_table_ref(statement: Mapping[str, object]) -> tuple[str, str]: + """Extract and validate one structured schema/table reference.""" + + object_ref = statement.get("object_ref") + if not isinstance(object_ref, Mapping): + raise ApplyLockPlanContractError("apply lock object reference is invalid") + schema_name = object_ref.get("schema_name") + table_name = object_ref.get("table_name") + _quote_identifier(schema_name) + _quote_identifier(table_name) + return cast(str, schema_name), cast(str, table_name) + + +def _lock_mode(statement: Mapping[str, object]) -> object: + """Read the reviewed risk lock mode without consulting rendered SQL.""" + + risk = statement.get("risk") + if not isinstance(risk, Mapping): + raise ApplyLockPlanContractError("apply lock risk metadata is invalid") + return risk.get("lock_mode") + + +def compile_apply_lock_targets( + plan: Mapping[str, object], +) -> tuple[ApplyLockTarget, ...]: + """Return sorted unique existing-table locks for compiler-v1 statements. + + New schemas and tables have no pre-existing relation to lock. Every + existing-table operation must remain transactional and declare the + compiler-v1 ``ACCESS EXCLUSIVE`` risk mode. Unknown statement kinds fail + closed so a future compiler capability cannot silently bypass lock planning. + """ + + if plan.get("compiler_version") != COMPILER_VERSION: + raise ApplyLockPlanContractError( + "apply lock plan compiler is unsupported" + ) + if plan.get("can_dry_run") is not True or plan.get("blockers") != []: + raise ApplyLockPlanContractError( + "migration plan cannot enter apply lock planning" + ) + statements = plan.get("statements") + if not isinstance(statements, list): + raise ApplyLockPlanContractError("migration plan statements must be a list") + if len(statements) > MAX_APPLY_LOCK_STATEMENTS: + raise ApplyLockPlanContractError( + "apply lock plan contains too many statements" + ) + + targets: set[tuple[str, str]] = set() + for statement in statements: + if not isinstance(statement, Mapping): + raise ApplyLockPlanContractError( + "migration plan statement must be an object" + ) + kind = statement.get("kind") + if not isinstance(kind, str) or kind not in _SUPPORTED_STATEMENT_KINDS: + raise ApplyLockPlanContractError( + "unsupported apply statement kind" + ) + if statement.get("transactional") is not True: + raise ApplyLockPlanContractError( + "apply plan statement must be transactional" + ) + + if kind == "create_schema": + if _lock_mode(statement) != "none": + raise ApplyLockPlanContractError("apply lock mode is invalid") + object_ref = statement.get("object_ref") + if not isinstance(object_ref, Mapping): + raise ApplyLockPlanContractError( + "apply lock object reference is invalid" + ) + _quote_identifier(object_ref.get("schema_name")) + continue + + schema_name, table_name = _object_table_ref(statement) + if _lock_mode(statement) != "ACCESS EXCLUSIVE": + raise ApplyLockPlanContractError("apply lock mode is invalid") + if kind == "create_table": + continue + targets.add((schema_name, table_name)) + if len(targets) > MAX_APPLY_LOCK_TARGETS: + raise ApplyLockPlanContractError( + "apply lock plan contains too many targets" + ) + + return tuple( + ApplyLockTarget( + schema_name=schema_name, + table_name=table_name, + sql=( + f"LOCK TABLE {_quote_identifier(schema_name)}." + f"{_quote_identifier(table_name)} IN ACCESS EXCLUSIVE MODE" + ), + ) + for schema_name, table_name in sorted(targets) + ) diff --git a/backend/app/forward/isolated_dry_run.py b/backend/app/forward/isolated_dry_run.py new file mode 100644 index 000000000..c44d38c3c --- /dev/null +++ b/backend/app/forward/isolated_dry_run.py @@ -0,0 +1,367 @@ +"""Execute one immutable v1 plan inside an already isolated PostgreSQL sandbox. + +Provisioning, dependency materialization, network isolation, and sandbox cleanup +remain worker responsibilities. This module accepts neither a DSN nor browser +SQL. It verifies the persisted plan digest, checks the disposable server and +materialized base, executes the compiler-owned transactional statements, and +requires a fresh strict snapshot to converge on the planned target digest. +""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Protocol + +from app.forward.migration_plan import COMPILER_VERSION, verify_migration_plan_digest +from app.forward.schema_model import SchemaModelValidationError, schema_model_digest +from app.forward.snapshot_adapter import snapshot_to_schema_model + +MAX_DRY_RUN_STATEMENTS = 1_000 +MAX_DRY_RUN_SQL_BYTES = 262_144 +MIN_TIMEOUT_MS = 1 +MAX_LOCK_TIMEOUT_MS = 60_000 +MAX_STATEMENT_TIMEOUT_MS = 300_000 + +_DIGEST = re.compile(r"[0-9a-f]{64}\Z") +_SUPPORTED_KINDS = frozenset( + { + "create_schema", + "create_table", + "drop_table", + "add_column", + "drop_column", + "alter_column_type", + "set_not_null", + "drop_not_null", + } +) +_PLAN_FIELDS = frozenset( + { + "compiler_version", + "snapshot_contract_version", + "postgresql_major", + "base_digest", + "target_digest", + "statements", + "proposed_statements", + "blockers", + "risk_summary", + "requires_destructive_confirmation", + "can_dry_run", + "plan_digest", + } +) +_STATEMENT_FIELDS = frozenset( + { + "kind", + "target", + "object_ref", + "sql", + "transactional", + "dependencies", + "dependency_refs", + "reversible", + "risk", + "required_privileges", + "preconditions", + } +) + + +class _PreparedStatement(Protocol): + async def fetch(self, *, timeout: float) -> Sequence[object]: + """Execute the prepared statement with a client-side timeout.""" + + +class _Transaction(Protocol): + async def start(self) -> None: + """Start the owned transaction.""" + + async def commit(self) -> None: + """Commit the owned transaction.""" + + async def rollback(self) -> None: + """Roll back the owned transaction.""" + + +class IsolatedPostgresConnection(Protocol): + """Minimum asyncpg-compatible surface used by the sandbox executor.""" + + async def fetchval(self, query: str) -> object: + """Fetch one scalar value from a compiler-owned query.""" + + def transaction(self) -> _Transaction: + """Create a transaction bound to this sandbox connection.""" + + async def execute(self, query: str, value: str) -> str: + """Execute a parameterized control query.""" + + async def prepare(self, query: str) -> _PreparedStatement: + """Prepare one compiler-owned statement.""" + + +SnapshotCapture = Callable[ + [IsolatedPostgresConnection], Awaitable[Mapping[str, Any]] +] + + +class IsolatedDryRunContractError(ValueError): + """Raised when isolated execution cannot produce trusted convergence.""" + + +@dataclass(frozen=True) +class _ExecutablePlan: + postgresql_major: int + base_digest: str + target_digest: str + statements: tuple[str, ...] + + +def _require_timeout(value: int, *, maximum: int, name: str) -> None: + if ( + not isinstance(value, int) + or isinstance(value, bool) + or value < MIN_TIMEOUT_MS + or value > maximum + ): + raise IsolatedDryRunContractError(f"{name} is outside the allowed range") + + +def _require_digest(value: object, *, name: str) -> str: + if not isinstance(value, str) or _DIGEST.fullmatch(value) is None: + raise IsolatedDryRunContractError(f"{name} is invalid") + return value + + +def _validated_plan( + plan: Mapping[str, Any], expected_plan_digest: str +) -> _ExecutablePlan: + expected_digest = _require_digest( + expected_plan_digest, name="expected plan digest" + ) + if not verify_migration_plan_digest(plan, expected_digest): + raise IsolatedDryRunContractError("migration plan digest is invalid") + if set(plan) != _PLAN_FIELDS: + raise IsolatedDryRunContractError("migration plan contract is invalid") + if plan.get("compiler_version") != COMPILER_VERSION: + raise IsolatedDryRunContractError("migration plan compiler is unsupported") + if plan.get("can_dry_run") is not True or plan.get("blockers") != []: + raise IsolatedDryRunContractError("migration plan is not dry-runnable") + + postgresql_major = plan.get("postgresql_major") + if ( + not isinstance(postgresql_major, int) + or isinstance(postgresql_major, bool) + or postgresql_major < 14 + or postgresql_major > 18 + ): + raise IsolatedDryRunContractError("planned PostgreSQL major is invalid") + base_digest = _require_digest(plan.get("base_digest"), name="base digest") + target_digest = _require_digest(plan.get("target_digest"), name="target digest") + if plan.get("proposed_statements") != []: + raise IsolatedDryRunContractError("migration plan contract is invalid") + + raw_statements = plan.get("statements") + if ( + not isinstance(raw_statements, list) + or len(raw_statements) > MAX_DRY_RUN_STATEMENTS + ): + raise IsolatedDryRunContractError("migration plan statements are invalid") + statements: list[str] = [] + for statement in raw_statements: + if not isinstance(statement, Mapping): + raise IsolatedDryRunContractError("migration plan statement is invalid") + if set(statement) != _STATEMENT_FIELDS: + raise IsolatedDryRunContractError( + "migration plan statement contract is invalid" + ) + if statement.get("kind") not in _SUPPORTED_KINDS: + raise IsolatedDryRunContractError( + "migration plan statement kind is unsupported" + ) + if statement.get("transactional") is not True: + raise IsolatedDryRunContractError( + "migration plan contains a non-transactional statement" + ) + sql = statement.get("sql") + if ( + not isinstance(sql, str) + or not sql + or len(sql.encode("utf-8")) > MAX_DRY_RUN_SQL_BYTES + ): + raise IsolatedDryRunContractError("migration plan statement SQL is invalid") + statements.append(sql) + if not statements and base_digest != target_digest: + raise IsolatedDryRunContractError("migration plan contract is invalid") + return _ExecutablePlan( + postgresql_major=postgresql_major, + base_digest=base_digest, + target_digest=target_digest, + statements=tuple(statements), + ) + + +def _captured_digest(snapshot: Mapping[str, Any]) -> str: + invalid_snapshot = False + try: + model = snapshot_to_schema_model(snapshot) + except (SchemaModelValidationError, TypeError, ValueError): + invalid_snapshot = True + if invalid_snapshot: + raise IsolatedDryRunContractError( + "isolated sandbox snapshot is invalid" + ) + return schema_model_digest(model) + + +async def _capture_digest( + connection: IsolatedPostgresConnection, + capture_snapshot: SnapshotCapture, + *, + timeout: float, +) -> str: + capture_failed = False + try: + snapshot = await asyncio.wait_for( + capture_snapshot(connection), timeout=timeout + ) + except Exception: + capture_failed = True + if capture_failed: + raise IsolatedDryRunContractError( + "isolated sandbox snapshot capture failed" + ) + if not isinstance(snapshot, Mapping): + raise IsolatedDryRunContractError("isolated sandbox snapshot is invalid") + return _captured_digest(snapshot) + + +async def execute_isolated_dry_run( + connection: IsolatedPostgresConnection, + plan: Mapping[str, Any], + *, + expected_plan_digest: str, + capture_snapshot: SnapshotCapture, + lock_timeout_ms: int = 1_000, + statement_timeout_ms: int = 30_000, +) -> dict[str, object]: + """Execute a verified plan on a disposable, pre-materialized sandbox. + + The caller must prove the connection is disposable and isolated and must + destroy or sanitize it after this function returns or raises. Snapshot + capture is worker-owned and must introspect this sandbox, never the live + target or application metadata database. + """ + + executable = _validated_plan(plan, expected_plan_digest) + _require_timeout( + lock_timeout_ms, maximum=MAX_LOCK_TIMEOUT_MS, name="lock timeout" + ) + _require_timeout( + statement_timeout_ms, + maximum=MAX_STATEMENT_TIMEOUT_MS, + name="statement timeout", + ) + client_timeout = statement_timeout_ms / 1_000 + 1 + + version_check_failed = False + try: + server_version_num = await asyncio.wait_for( + connection.fetchval( + "SELECT pg_catalog.current_setting('server_version_num')::integer" + ), + timeout=client_timeout, + ) + if ( + not isinstance(server_version_num, int) + or isinstance(server_version_num, bool) + or server_version_num // 10_000 != executable.postgresql_major + ): + raise IsolatedDryRunContractError( + "isolated PostgreSQL major version mismatch" + ) + except IsolatedDryRunContractError: + raise + except Exception: + version_check_failed = True + if version_check_failed: + raise IsolatedDryRunContractError( + "isolated PostgreSQL version check failed" + ) + + observed_base_digest = await _capture_digest( + connection, capture_snapshot, timeout=client_timeout + ) + if observed_base_digest != executable.base_digest: + raise IsolatedDryRunContractError( + "isolated sandbox does not match the planned base" + ) + + transaction_started = False + statement_failed = False + try: + transaction = connection.transaction() + await asyncio.wait_for(transaction.start(), timeout=client_timeout) + transaction_started = True + await asyncio.wait_for( + connection.execute( + "SELECT pg_catalog.set_config('lock_timeout', $1, true)", + str(lock_timeout_ms), + ), + timeout=client_timeout, + ) + await asyncio.wait_for( + connection.execute( + "SELECT pg_catalog.set_config('statement_timeout', $1, true)", + str(statement_timeout_ms), + ), + timeout=client_timeout, + ) + for sql in executable.statements: + prepared = await asyncio.wait_for( + connection.prepare(sql), timeout=client_timeout + ) + await prepared.fetch(timeout=client_timeout) + await asyncio.wait_for(transaction.commit(), timeout=client_timeout) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + if transaction_started: + try: + await asyncio.wait_for( + transaction.rollback(), timeout=client_timeout + ) + except Exception: + # Preserve cancellation/process-exit over cleanup detail. + pass + raise + except Exception: + if transaction_started: + try: + await asyncio.wait_for( + transaction.rollback(), timeout=client_timeout + ) + except Exception: + # Preserve the fixed primary failure and never driver detail. + pass + statement_failed = True + if statement_failed: + raise IsolatedDryRunContractError( + "isolated dry-run statement failed" + ) + + observed_target_digest = await _capture_digest( + connection, capture_snapshot, timeout=client_timeout + ) + if observed_target_digest != executable.target_digest: + raise IsolatedDryRunContractError( + "isolated dry run did not converge" + ) from None + return { + "postgresql_major": executable.postgresql_major, + "statement_count": len(executable.statements), + "base_digest": observed_base_digest, + "target_digest": observed_target_digest, + "converged": True, + } diff --git a/backend/app/forward/live_preflight.py b/backend/app/forward/live_preflight.py new file mode 100644 index 000000000..a68e7becd --- /dev/null +++ b/backend/app/forward/live_preflight.py @@ -0,0 +1,375 @@ +"""Compile and execute bounded, read-only live-target preconditions. + +This module is an execution-neutral primitive for the planned dry-run worker. +It consumes only the structured preconditions already bound into an immutable +migration plan. ``execute_bound_live_preflight`` coordinates a caller-owned +fresh snapshot callback and the structured checks in one transaction. The +module neither accepts arbitrary SQL nor owns target credentials, worker +identity, durable attempts, run transitions, or apply authority. +""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import Any + +import asyncpg +from asyncpg.transaction import Transaction + +from app.forward.schema_model import ( + SchemaModelValidationError, + canonicalize_data_type, + schema_model_digest, +) +from app.forward.snapshot_adapter import snapshot_to_schema_model + +MAX_LIVE_PREFLIGHT_QUERIES = 1000 +MAX_STATEMENT_TIMEOUT_MS = 60_000 +LIVE_PREFLIGHT_PRECONDITION_KINDS = frozenset( + {"table_is_empty", "no_null_values", "castable_values"} +) +_SHA256_HEX_RE = re.compile(r"[0-9a-f]{64}") +SnapshotCapture = Callable[ + [asyncpg.Connection], Awaitable[Mapping[str, Any]] +] + + +class LivePreflightContractError(ValueError): + """Reject malformed plans or incomplete live-preflight evidence.""" + + +class _LivePreflightCaptureFailure(Exception): + """Keep caller callback failures separate from public contract errors.""" + + +@dataclass(frozen=True) +class LivePreflightQuery: + """One server-compiled read query bound to a plan precondition position.""" + + statement_index: int + precondition_index: int + kind: str + sql: str + + +def compare_live_preflight_snapshot( + plan: Mapping[str, object], snapshot: Mapping[str, Any] +) -> dict[str, object]: + """Compare one strictly adapted target snapshot with the planned base. + + The caller owns fresh capture, connection authorization, and durable state + transitions. This pure boundary only rejects malformed inputs and returns + the canonical observed digest plus an explicit match result. + """ + + expected_digest = plan.get("base_digest") + if not isinstance(expected_digest, str) or _SHA256_HEX_RE.fullmatch( + expected_digest + ) is None: + raise LivePreflightContractError("live preflight base digest is invalid") + try: + observed_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + except SchemaModelValidationError as err: + raise LivePreflightContractError(str(err)) from None + return { + "observed_base_digest": observed_digest, + "matches_plan_base": observed_digest == expected_digest, + } + + +def _quote_identifier(identifier: object) -> str: + if not isinstance(identifier, str): + raise LivePreflightContractError("live preflight identifier must be text") + if not identifier or "\x00" in identifier: + raise LivePreflightContractError("live preflight identifier is invalid") + if len(identifier.encode("utf-8")) > 63: + raise LivePreflightContractError("live preflight identifier is too large") + return '"' + identifier.replace('"', '""') + '"' + + +def _precondition_fields( + value: Mapping[str, object], allowed: set[str] +) -> None: + unknown = set(value) - allowed + if unknown: + raise LivePreflightContractError( + f"live preflight precondition contains unrecognized field {sorted(unknown)[0]!r}" + ) + missing = allowed - set(value) + if missing: + raise LivePreflightContractError( + f"live preflight precondition is missing field {sorted(missing)[0]!r}" + ) + + +def _compile_precondition( + precondition: Mapping[str, object], + *, + statement_index: int, + precondition_index: int, +) -> LivePreflightQuery: + kind = precondition.get("kind") + if not isinstance(kind, str): + raise LivePreflightContractError("live preflight precondition kind is invalid") + + common = {"kind", "schema_name", "table_name"} + if kind == "table_is_empty": + _precondition_fields(precondition, common) + table = ( + f"{_quote_identifier(precondition['schema_name'])}." + f"{_quote_identifier(precondition['table_name'])}" + ) + sql = f"SELECT NOT EXISTS (SELECT 1 FROM {table} LIMIT 1)" + elif kind == "no_null_values": + _precondition_fields(precondition, common | {"column_name"}) + table = ( + f"{_quote_identifier(precondition['schema_name'])}." + f"{_quote_identifier(precondition['table_name'])}" + ) + column = _quote_identifier(precondition["column_name"]) + sql = ( + f"SELECT NOT EXISTS (SELECT 1 FROM {table} " + f"WHERE {column} IS NULL LIMIT 1)" + ) + elif kind == "castable_values": + _precondition_fields( + precondition, common | {"column_name", "target_data_type"} + ) + table = ( + f"{_quote_identifier(precondition['schema_name'])}." + f"{_quote_identifier(precondition['table_name'])}" + ) + column = _quote_identifier(precondition["column_name"]) + try: + target_data_type = canonicalize_data_type( + precondition["target_data_type"], + "live_preflight.target_data_type", + ) + except SchemaModelValidationError as err: + raise LivePreflightContractError(str(err)) from None + sql = ( + f"SELECT COALESCE(bool_and(({column})::{target_data_type} IS NOT NULL), TRUE) " + f"FROM {table} WHERE {column} IS NOT NULL" + ) + else: + raise LivePreflightContractError( + f"unsupported live preflight precondition {kind!r}" + ) + + return LivePreflightQuery( + statement_index=statement_index, + precondition_index=precondition_index, + kind=kind, + sql=sql, + ) + + +def compile_live_preflight_queries( + plan: Mapping[str, object], +) -> tuple[LivePreflightQuery, ...]: + """Compile only recognized structured preconditions into bounded reads.""" + + blockers = plan.get("blockers") + statements = plan.get("statements") + if plan.get("can_dry_run") is not True or blockers != []: + raise LivePreflightContractError("migration plan cannot enter live preflight") + if not isinstance(statements, list): + raise LivePreflightContractError("migration plan statements must be a list") + + queries: list[LivePreflightQuery] = [] + for statement_index, statement in enumerate(statements): + if not isinstance(statement, Mapping): + raise LivePreflightContractError("migration plan statement must be an object") + preconditions = statement.get("preconditions") + if not isinstance(preconditions, list): + raise LivePreflightContractError( + "migration plan preconditions must be a list" + ) + for precondition_index, precondition in enumerate(preconditions): + if len(queries) >= MAX_LIVE_PREFLIGHT_QUERIES: + raise LivePreflightContractError( + "live preflight contains too many queries" + ) + if not isinstance(precondition, Mapping): + raise LivePreflightContractError( + "live preflight precondition must be an object" + ) + queries.append( + _compile_precondition( + precondition, + statement_index=statement_index, + precondition_index=precondition_index, + ) + ) + return tuple(queries) + + +async def _execute_live_preflight_query( + connection: asyncpg.Connection, + query: LivePreflightQuery, + *, + client_timeout: float, + data_failures_are_evidence: bool, +) -> object: + """Execute one check and isolate bound cast-data failures.""" + + prepared = await asyncio.wait_for( + connection.prepare(query.sql), timeout=client_timeout + ) + if query.kind != "castable_values" or not data_failures_are_evidence: + return await prepared.fetchval(timeout=client_timeout) + + savepoint = connection.transaction() + await asyncio.wait_for(savepoint.start(), timeout=client_timeout) + try: + result = await prepared.fetchval(timeout=client_timeout) + except asyncpg.DataError: + await asyncio.wait_for(savepoint.rollback(), timeout=client_timeout) + return False + await asyncio.wait_for(savepoint.commit(), timeout=client_timeout) + return result + + +async def _execute_live_preflight( + connection: asyncpg.Connection, + plan: Mapping[str, object], + *, + capture_snapshot: SnapshotCapture | None, + statement_timeout_ms: int = 5000, +) -> dict[str, Any]: + """Execute optional capture and compiled checks in one target snapshot.""" + + if ( + not isinstance(statement_timeout_ms, int) + or isinstance(statement_timeout_ms, bool) + or not 1 <= statement_timeout_ms <= MAX_STATEMENT_TIMEOUT_MS + ): + raise LivePreflightContractError( + "live preflight statement timeout is invalid" + ) + queries = compile_live_preflight_queries(plan) + client_timeout = statement_timeout_ms / 1000 + 1 + transaction: Transaction | None = None + transaction_started = False + sanitized_failure = False + try: + transaction = connection.transaction( + isolation="repeatable_read", readonly=True + ) + await asyncio.wait_for(transaction.start(), timeout=client_timeout) + transaction_started = True + await asyncio.wait_for( + connection.execute( + "SELECT pg_catalog.set_config('statement_timeout', $1, true)", + str(statement_timeout_ms), + ), + timeout=client_timeout, + ) + snapshot_evidence: dict[str, object] | None = None + if capture_snapshot is not None: + try: + snapshot = await asyncio.wait_for( + capture_snapshot(connection), timeout=client_timeout + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: + raise _LivePreflightCaptureFailure from None + if not isinstance(snapshot, Mapping): + raise LivePreflightContractError( + "live preflight snapshot capture is invalid" + ) + snapshot_evidence = compare_live_preflight_snapshot(plan, snapshot) + checks: list[dict[str, object]] = [] + for query in queries: + result = await _execute_live_preflight_query( + connection, + query, + client_timeout=client_timeout, + data_failures_are_evidence=snapshot_evidence is not None, + ) + if not isinstance(result, bool): + raise LivePreflightContractError( + "live preflight database result is not boolean" + ) + checks.append( + { + "statement_index": query.statement_index, + "precondition_index": query.precondition_index, + "kind": query.kind, + "passed": result, + } + ) + await asyncio.wait_for(transaction.commit(), timeout=client_timeout) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + if transaction_started and transaction is not None: + try: + await asyncio.wait_for( + transaction.rollback(), timeout=client_timeout + ) + except Exception: + # Preserve cancellation/process-exit over cleanup detail. + pass + raise + except Exception as err: + if transaction_started and transaction is not None: + try: + await asyncio.wait_for( + transaction.rollback(), timeout=client_timeout + ) + except Exception: + # Preserve the fixed non-success diagnostic, never driver detail. + pass + if isinstance(err, LivePreflightContractError): + raise + sanitized_failure = True + if sanitized_failure: + raise LivePreflightContractError("live preflight query failed") from None + preconditions_passed = all(bool(item["passed"]) for item in checks) + if snapshot_evidence is None: + return {"passed": preconditions_passed, "checks": checks} + return { + "preconditions_passed": preconditions_passed, + "checks": checks, + **snapshot_evidence, + } + + +async def execute_live_preflight( + connection: asyncpg.Connection, + plan: Mapping[str, object], + *, + statement_timeout_ms: int = 5000, +) -> dict[str, Any]: + """Execute compiled checks in one bounded read-only target snapshot.""" + + return await _execute_live_preflight( + connection, + plan, + capture_snapshot=None, + statement_timeout_ms=statement_timeout_ms, + ) + + +async def execute_bound_live_preflight( + connection: asyncpg.Connection, + plan: Mapping[str, object], + *, + capture_snapshot: SnapshotCapture, + statement_timeout_ms: int = 5000, +) -> dict[str, Any]: + """Bind fresh capture and checks to one caller-owned target transaction.""" + + if not callable(capture_snapshot): + raise LivePreflightContractError( + "live preflight snapshot capture is invalid" + ) + return await _execute_live_preflight( + connection, + plan, + capture_snapshot=capture_snapshot, + statement_timeout_ms=statement_timeout_ms, + ) diff --git a/backend/app/forward/migration_plan.py b/backend/app/forward/migration_plan.py new file mode 100644 index 000000000..105d61ebd --- /dev/null +++ b/backend/app/forward/migration_plan.py @@ -0,0 +1,600 @@ +"""Compile canonical schema models into immutable structured migration plans. + +The compiler emits both SQL and the metadata the executor needs. Execution +must consume these statements directly; it must not re-parse browser-supplied +SQL to rediscover authority, ordering, risk, privileges, or preconditions. +Compiler v1 intentionally supports a transactional subset and turns every +unsupported semantic change into a blocking finding. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from collections.abc import Mapping +from typing import Any + +from app.forward.schema_model import canonicalize_schema_model, schema_model_digest +from app.pg_introspect.snapshot_contract import ( + CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, +) + +COMPILER_VERSION = "pg-erd-forward/v1" + + +def _quote_identifier(identifier: str) -> str: + """Return a PostgreSQL delimited identifier preserving exact spelling.""" + + return '"' + identifier.replace('"', '""') + '"' + + +def _qualified_name(schema_name: str, table_name: str) -> str: + return f"{_quote_identifier(schema_name)}.{_quote_identifier(table_name)}" + + +def _tables(model: Mapping[str, Any]) -> dict[tuple[str, str], dict[str, Any]]: + return { + (schema["schema_name"], table["table_name"]): table + for schema in model["schemas"] + for table in schema["tables"] + } + + +def _risk( + severity: str, + *, + lock_mode: str, + possible_rewrite: bool = False, + table_scan: bool = False, + data_loss: bool = False, + detail: str, +) -> dict[str, Any]: + return { + "severity": severity, + "lock_mode": lock_mode, + "possible_rewrite": possible_rewrite, + "table_scan": table_scan, + "data_loss": data_loss, + "detail": detail, + } + + +def _statement( + *, + kind: str, + target: str, + object_ref: dict[str, str], + sql: str, + dependencies: list[str], + dependency_refs: list[dict[str, str]], + reversible: bool, + risk: dict[str, Any], + required_privileges: list[str], + preconditions: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "kind": kind, + "target": target, + "object_ref": object_ref, + "sql": sql, + "transactional": True, + "dependencies": dependencies, + "dependency_refs": dependency_refs, + "reversible": reversible, + "risk": risk, + "required_privileges": required_privileges, + "preconditions": preconditions or [], + } + + +def _column_sql(column: Mapping[str, Any]) -> str: + sql = f"{_quote_identifier(column['column_name'])} {column['data_type']}" + if not column["nullable"]: + sql += " NOT NULL" + return sql + + +def _create_table_statement( + schema_name: str, table: Mapping[str, Any] +) -> dict[str, Any]: + clauses = [_column_sql(column) for column in table["columns"]] + primary_key = table.get("primary_key") + if primary_key: + columns = ", ".join( + _quote_identifier(column) for column in primary_key["columns"] + ) + deferrability = "" + if primary_key["deferrable"]: + deferrability = " DEFERRABLE" + if primary_key["initially_deferred"]: + deferrability += " INITIALLY DEFERRED" + clauses.append( + f"CONSTRAINT {_quote_identifier(primary_key['constraint_name'])} " + f"PRIMARY KEY ({columns}){deferrability}" + ) + table_name = table["table_name"] + target = f"{schema_name}.{table_name}" + sql = f"CREATE TABLE {_qualified_name(schema_name, table_name)} ({', '.join(clauses)});" + return _statement( + kind="create_table", + target=target, + object_ref={"schema_name": schema_name, "table_name": table_name}, + sql=sql, + dependencies=[f"schema:{schema_name}"], + dependency_refs=[{"schema_name": schema_name}], + reversible=True, + risk=_risk( + "safe", + lock_mode="ACCESS EXCLUSIVE", + detail="Creates a new table; no existing rows are modified.", + ), + required_privileges=["CREATE"], + ) + + +def _compile_table_changes( + schema_name: str, + table_name: str, + base: Mapping[str, Any], + target: Mapping[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + qualified = _qualified_name(schema_name, table_name) + object_name = f"{schema_name}.{table_name}" + statements: list[dict[str, Any]] = [] + blockers: list[dict[str, Any]] = [] + if base.get("primary_key") != target.get("primary_key"): + blockers.append( + { + "code": "primary_key_change_unsupported", + "object": object_name, + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + }, + "detail": "Changing an existing primary key is not supported by compiler v1.", + } + ) + base_columns = {column["column_name"]: column for column in base["columns"]} + target_columns = {column["column_name"]: column for column in target["columns"]} + if base.get("comment") != target.get("comment"): + blockers.append( + { + "code": "table_comment_change_unsupported", + "object": object_name, + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + }, + "detail": "Changing a table comment is not supported by compiler v1.", + } + ) + for column_name in sorted(set(base_columns) & set(target_columns)): + before = base_columns[column_name] + after = target_columns[column_name] + if before.get("comment") != after.get("comment"): + blockers.append( + { + "code": "column_comment_change_unsupported", + "object": f"{object_name}.{column_name}", + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + "detail": "Changing a column comment is not supported by compiler v1.", + } + ) + if before["ordinal_position"] != after["ordinal_position"]: + blockers.append( + { + "code": "column_order_change_unsupported", + "object": f"{object_name}.{column_name}", + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + "detail": "Reordering an existing column is not supported by compiler v1.", + } + ) + maximum_existing_ordinal = max( + (int(column["ordinal_position"]) for column in base_columns.values()), + default=0, + ) + added_column_names = sorted( + set(target_columns) - set(base_columns), + key=lambda name: (target_columns[name]["ordinal_position"], name), + ) + expected_added_ordinals = list( + range( + maximum_existing_ordinal + 1, + maximum_existing_ordinal + len(added_column_names) + 1, + ) + ) + for index, column_name in enumerate(added_column_names): + column = target_columns[column_name] + if column.get("comment") is not None: + blockers.append( + { + "code": "column_comment_change_unsupported", + "object": f"{object_name}.{column_name}", + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + "detail": "Creating a column with a comment is not supported by compiler v1.", + } + ) + if int(column["ordinal_position"]) != expected_added_ordinals[index]: + blockers.append( + { + "code": "column_order_change_unsupported", + "object": f"{object_name}.{column_name}", + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + "detail": "New columns must be appended contiguously after existing columns in compiler v1.", + } + ) + + for column_name in sorted(set(base_columns) - set(target_columns)): + statements.append( + _statement( + kind="drop_column", + target=f"{object_name}.{column_name}", + object_ref={ + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + sql=f"ALTER TABLE {qualified} DROP COLUMN {_quote_identifier(column_name)};", + dependencies=[f"table:{object_name}"], + dependency_refs=[ + {"schema_name": schema_name, "table_name": table_name} + ], + reversible=False, + risk=_risk( + "destructive", + lock_mode="ACCESS EXCLUSIVE", + data_loss=True, + detail="Drops the column and its stored values.", + ), + required_privileges=["OWNER"], + ) + ) + + for column_name in added_column_names: + column = target_columns[column_name] + preconditions: list[dict[str, Any]] = [] + severity = "safe" + detail = "Adds a nullable column without rewriting existing rows." + # Compiler v1 rejects every default expression at model validation, so + # every required added column needs target-side proof that the table is + # empty. Do not let an uncompiled default suppress this precondition. + if not column["nullable"]: + severity = "warning" + detail = "A required column without a default needs proof the table is empty." + preconditions.append( + { + "kind": "table_is_empty", + "schema_name": schema_name, + "table_name": table_name, + } + ) + statements.append( + _statement( + kind="add_column", + target=f"{object_name}.{column_name}", + object_ref={ + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + sql=f"ALTER TABLE {qualified} ADD COLUMN {_column_sql(column)};", + dependencies=[f"table:{object_name}"], + dependency_refs=[ + {"schema_name": schema_name, "table_name": table_name} + ], + reversible=True, + risk=_risk( + severity, + lock_mode="ACCESS EXCLUSIVE", + possible_rewrite=False, + detail=detail, + ), + required_privileges=["OWNER"], + preconditions=preconditions, + ) + ) + + for column_name in sorted(set(base_columns) & set(target_columns)): + before = base_columns[column_name] + after = target_columns[column_name] + target_name = f"{object_name}.{column_name}" + if before["data_type"] != after["data_type"]: + statements.append( + _statement( + kind="alter_column_type", + target=target_name, + object_ref={ + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + sql=( + f"ALTER TABLE {qualified} ALTER COLUMN " + f"{_quote_identifier(column_name)} TYPE {after['data_type']};" + ), + dependencies=[f"column:{target_name}"], + dependency_refs=[ + { + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + } + ], + reversible=False, + risk=_risk( + "destructive", + lock_mode="ACCESS EXCLUSIVE", + possible_rewrite=True, + table_scan=True, + data_loss=True, + detail="A type conversion may rewrite or change existing values and is conservatively destructive.", + ), + required_privileges=["OWNER"], + preconditions=[ + { + "kind": "castable_values", + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + "target_data_type": after["data_type"], + } + ], + ) + ) + if before["nullable"] and not after["nullable"]: + statements.append( + _statement( + kind="set_not_null", + target=target_name, + object_ref={ + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + sql=( + f"ALTER TABLE {qualified} ALTER COLUMN " + f"{_quote_identifier(column_name)} SET NOT NULL;" + ), + dependencies=[f"column:{target_name}"], + dependency_refs=[ + { + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + } + ], + reversible=True, + risk=_risk( + "warning", + lock_mode="ACCESS EXCLUSIVE", + table_scan=True, + detail="Validating NOT NULL scans existing rows and fails when NULL exists.", + ), + required_privileges=["OWNER"], + preconditions=[ + { + "kind": "no_null_values", + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + } + ], + ) + ) + elif not before["nullable"] and after["nullable"]: + statements.append( + _statement( + kind="drop_not_null", + target=target_name, + object_ref={ + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + }, + sql=( + f"ALTER TABLE {qualified} ALTER COLUMN " + f"{_quote_identifier(column_name)} DROP NOT NULL;" + ), + dependencies=[f"column:{target_name}"], + dependency_refs=[ + { + "schema_name": schema_name, + "table_name": table_name, + "column_name": column_name, + } + ], + reversible=True, + risk=_risk( + "safe", + lock_mode="ACCESS EXCLUSIVE", + detail="Relaxes an existing nullability constraint.", + ), + required_privileges=["OWNER"], + ) + ) + return statements, blockers + + +def _digest_plan(plan: Mapping[str, Any]) -> str: + encoded = json.dumps( + plan, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def verify_migration_plan_digest( + plan: Mapping[str, Any], expected_digest: str +) -> bool: + """Verify persisted plan content against its immutable stored digest.""" + + claimed_digest = plan.get("plan_digest") + if not isinstance(claimed_digest, str): + return False + unsigned_plan = dict(plan) + unsigned_plan.pop("plan_digest", None) + calculated_digest = _digest_plan(unsigned_plan) + return hmac.compare_digest(claimed_digest, expected_digest) and hmac.compare_digest( + calculated_digest, expected_digest + ) + + +def compile_migration_plan( + base_model: Mapping[str, Any], target_model: Mapping[str, Any] +) -> dict[str, Any]: + """Compile two validated models into a deterministic immutable plan. + + The returned plan is safe to persist and hash. A non-empty ``blockers`` + list makes ``can_dry_run`` false and suppresses every executable statement, + preventing a partial plan from being mistaken for semantic convergence. + """ + + base = canonicalize_schema_model(base_model) + target = canonicalize_schema_model(target_model) + if base["postgresql_major"] != target["postgresql_major"]: + blockers: list[dict[str, Any]] = [ + { + "code": "postgresql_version_mismatch", + "object": "database", + "object_ref": {"database": "current"}, + "detail": "Base and target PostgreSQL major versions must match.", + } + ] + else: + blockers = [] + + base_tables = _tables(base) + target_tables = _tables(target) + base_schemas = {schema["schema_name"] for schema in base["schemas"]} + target_schemas = {schema["schema_name"] for schema in target["schemas"]} + statements: list[dict[str, Any]] = [] + + for schema_name in sorted(base_schemas - target_schemas): + blockers.append( + { + "code": "schema_removal_unsupported", + "object": schema_name, + "object_ref": {"schema_name": schema_name}, + "detail": "Removing an existing schema is not supported by compiler v1.", + } + ) + + for schema_name in sorted(target_schemas - base_schemas): + statements.append( + _statement( + kind="create_schema", + target=schema_name, + object_ref={"schema_name": schema_name}, + sql=f"CREATE SCHEMA {_quote_identifier(schema_name)};", + dependencies=[], + dependency_refs=[], + reversible=True, + risk=_risk( + "safe", + lock_mode="none", + detail="Creates an empty schema namespace.", + ), + required_privileges=["CREATE"], + ) + ) + + for schema_name, table_name in sorted(set(base_tables) - set(target_tables)): + object_name = f"{schema_name}.{table_name}" + statements.append( + _statement( + kind="drop_table", + target=object_name, + object_ref={"schema_name": schema_name, "table_name": table_name}, + sql=f"DROP TABLE {_qualified_name(schema_name, table_name)};", + dependencies=[f"table:{object_name}"], + dependency_refs=[ + {"schema_name": schema_name, "table_name": table_name} + ], + reversible=False, + risk=_risk( + "destructive", + lock_mode="ACCESS EXCLUSIVE", + data_loss=True, + detail="Drops the table and all of its stored rows.", + ), + required_privileges=["OWNER"], + ) + ) + + for key in sorted(set(target_tables) - set(base_tables)): + table = target_tables[key] + object_name = f"{key[0]}.{key[1]}" + if table.get("comment") is not None: + blockers.append( + { + "code": "table_comment_change_unsupported", + "object": object_name, + "object_ref": { + "schema_name": key[0], + "table_name": key[1], + }, + "detail": "Creating a table with a comment is not supported by compiler v1.", + } + ) + for column in table["columns"]: + if column.get("comment") is not None: + blockers.append( + { + "code": "column_comment_change_unsupported", + "object": f"{object_name}.{column['column_name']}", + "object_ref": { + "schema_name": key[0], + "table_name": key[1], + "column_name": column["column_name"], + }, + "detail": "Creating a column with a comment is not supported by compiler v1.", + } + ) + statements.append(_create_table_statement(key[0], table)) + + for key in sorted(set(base_tables) & set(target_tables)): + changed, table_blockers = _compile_table_changes( + key[0], key[1], base_tables[key], target_tables[key] + ) + statements.extend(changed) + blockers.extend(table_blockers) + + proposed_statements = statements if blockers else [] + executable_statements = [] if blockers else statements + risk_summary = { + severity: sum( + statement["risk"]["severity"] == severity for statement in statements + ) + for severity in ("safe", "warning", "destructive") + } + plan: dict[str, Any] = { + "compiler_version": COMPILER_VERSION, + "snapshot_contract_version": CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, + "postgresql_major": target["postgresql_major"], + "base_digest": schema_model_digest(base), + "target_digest": schema_model_digest(target), + "statements": executable_statements, + "proposed_statements": proposed_statements, + "blockers": blockers, + "risk_summary": risk_summary, + "requires_destructive_confirmation": risk_summary["destructive"] > 0, + "can_dry_run": not blockers, + } + plan["plan_digest"] = _digest_plan(plan) + return plan diff --git a/backend/app/forward/migration_run.py b/backend/app/forward/migration_run.py new file mode 100644 index 000000000..cce50917f --- /dev/null +++ b/backend/app/forward/migration_run.py @@ -0,0 +1,1565 @@ +"""Durable migration-run state and evidence contracts. + +This module persists an execution-free dispatch outbox but remains independent +from queue delivery. It defines the states that may become durable product +evidence, hashes caller idempotency keys, and prevents raw SQL or +credential-bearing fields from entering run events. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import json +import math +import re +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, cast + +from sqlalchemy import func, select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.engine import CursorResult +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import set_committed_value + +from app.forward.isolated_dry_run import MAX_DRY_RUN_STATEMENTS +from app.forward.live_preflight import ( + LIVE_PREFLIGHT_PRECONDITION_KINDS, + MAX_LIVE_PREFLIGHT_QUERIES, +) +from app.forward.migration_plan import verify_migration_plan_digest +from app.models import ( + DbConnection, + MigrationPlan, + MigrationRun, + MigrationRunAttempt, + MigrationRunDispatch, + MigrationRunEvent, + SchemaModel, + SchemaModelRevision, +) + +MAX_IDEMPOTENCY_KEY_BYTES = 255 +MAX_RUN_EVIDENCE_BYTES = 16_384 +MAX_RUN_EVIDENCE_DEPTH = 8 +MAX_RUN_EVIDENCE_ITEMS = 256 +MAX_RUN_EVIDENCE_STRING_BYTES = 2_048 +MAX_MIGRATION_ATTEMPT_LEASE_SECONDS = 300 +MAX_WORKER_IDENTITY_BYTES = 255 + +DRY_RUN_STATES = frozenset( + { + "queued", + "sandbox_running", + "live_preflight_running", + "passed", + "drifted", + "failed", + "cancelled", + } +) +APPLY_RUN_STATES = frozenset( + { + "queued", + "applying", + "reconciling", + "verifying", + "verified", + "drifted_no_apply", + "not_applied", + "verification_failed", + "failed_rolled_back", + "applied_with_drift", + "outcome_unknown", + "cancelled", + } +) + +_TRANSITIONS = { + "dry_run": { + "queued": frozenset({"sandbox_running", "failed", "cancelled"}), + "sandbox_running": frozenset( + {"live_preflight_running", "failed", "cancelled"} + ), + "live_preflight_running": frozenset( + {"passed", "drifted", "failed", "cancelled"} + ), + }, + "apply": { + "queued": frozenset({"applying", "drifted_no_apply", "cancelled"}), + "applying": frozenset( + {"reconciling", "verifying", "failed_rolled_back", "outcome_unknown"} + ), + "reconciling": frozenset( + {"verifying", "verified", "not_applied", "outcome_unknown"} + ), + "verifying": frozenset( + {"verified", "verification_failed", "applied_with_drift"} + ), + }, +} + +_FORBIDDEN_EVIDENCE_TOKENS = frozenset( + {"credential", "dsn", "password", "secret", "sql", "token"} +) +_POSTGRES_CONNECTION_STRING = re.compile( + r"postgres(?:ql)?(?:\+[a-z0-9_.-]+)?://", re.IGNORECASE +) +_HEX_DIGEST = re.compile(r"[0-9a-f]{64}") +_EVENT_TYPE = re.compile(r"[a-z][a-z0-9_]{0,63}") +_LIVE_PREFLIGHT_RESULT_FIELDS = frozenset( + { + "preconditions_passed", + "checks", + "observed_base_digest", + "matches_plan_base", + } +) +_LIVE_PREFLIGHT_CHECK_FIELDS = frozenset( + {"statement_index", "precondition_index", "kind", "passed"} +) +_ISOLATED_DRY_RUN_RESULT_FIELDS = frozenset( + { + "postgresql_major", + "statement_count", + "base_digest", + "target_digest", + "converged", + } +) + + +class MigrationRunContractError(ValueError): + """Raised when run state or durable evidence violates the v1 contract. + + ``code`` is a stable machine-readable identity for API and audit mapping; + the human-readable message may evolve without changing that contract. + """ + + def __init__(self, message: str, *, code: str | None = None) -> None: + """Store one bounded contract code independently from diagnostic text.""" + + super().__init__(message) + self.code = code or message + + +@dataclass(frozen=True) +class MigrationRunTransition: + """The durable state identity produced by one successful CAS transition.""" + + state: str + state_version: int + started_at: dt.datetime | None + finished_at: dt.datetime | None + + +@dataclass(frozen=True) +class MigrationRunCreation: + """The durable identity selected by one idempotent creation request.""" + + migration_run_uuid: uuid.UUID + state: str + state_version: int + cancellation_requested: bool + reused: bool + + +@dataclass(frozen=True) +class MigrationDispatchClaim: + """Identifier-only relay claim held by the caller's open transaction.""" + + migration_run_dispatch_uuid: uuid.UUID + migration_run_uuid: uuid.UUID + dispatch_kind: str + attempt_count: int + + +@dataclass(frozen=True) +class MigrationRunAttemptClaim: + """Exact durable worker-attempt identity owned by one signal claimant.""" + + migration_run_attempt_uuid: uuid.UUID + migration_run_uuid: uuid.UUID + attempt_number: int + acquired_state_version: int + lease_expires_at: dt.datetime + + +@dataclass(frozen=True) +class MigrationRunCancellation: + """The durable cancellation-intent identity selected by one CAS request.""" + + state: str + state_version: int + reused: bool + + +def _require_aware_dispatch_time(value: dt.datetime) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise MigrationRunContractError("dispatch time must include a timezone") + + +def _validate_attempt_inputs( + *, worker_identity: str, lease_seconds: int, now: dt.datetime +) -> tuple[str, dt.datetime]: + """Validate bounded ownership input and return its hash and expiry.""" + + _require_aware_dispatch_time(now) + if not isinstance(worker_identity, str): + raise MigrationRunContractError("worker identity is invalid") + encoded_identity = worker_identity.encode("utf-8") + if ( + not encoded_identity + or len(encoded_identity) > MAX_WORKER_IDENTITY_BYTES + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,254}", worker_identity) + is None + ): + raise MigrationRunContractError("worker identity is invalid") + if ( + isinstance(lease_seconds, bool) + or not isinstance(lease_seconds, int) + or not 1 <= lease_seconds <= MAX_MIGRATION_ATTEMPT_LEASE_SECONDS + ): + raise MigrationRunContractError("migration attempt lease is invalid") + return ( + hashlib.sha256(encoded_identity).hexdigest(), + now + dt.timedelta(seconds=lease_seconds), + ) + + +def _hash_signal_lease_token(signal_lease_token: uuid.UUID) -> str: + """Hash an opaque signal lease token before durable comparison/storage.""" + + if not isinstance(signal_lease_token, uuid.UUID): + raise MigrationRunContractError("signal lease token is invalid") + return hashlib.sha256(signal_lease_token.bytes).hexdigest() + + +async def acquire_migration_run_attempt( + session: AsyncSession, + *, + migration_run_uuid: uuid.UUID, + worker_identity: str, + signal_lease_token: uuid.UUID, + lease_seconds: int, + now: dt.datetime | None = None, +) -> MigrationRunAttemptClaim: + """Acquire one DB-durable attempt after serializing on an executable run.""" + + acquired_at = now or dt.datetime.now(dt.timezone.utc) + worker_identity_hash, lease_expires_at = _validate_attempt_inputs( + worker_identity=worker_identity, + lease_seconds=lease_seconds, + now=acquired_at, + ) + signal_lease_token_hash = _hash_signal_lease_token(signal_lease_token) + run = await session.scalar( + select(MigrationRun) + .where(MigrationRun.migration_run_uuid == migration_run_uuid) + .with_for_update() + ) + if ( + run is None + or run.run_kind != "dry_run" + or run.state + not in {"queued", "sandbox_running", "live_preflight_running"} + or run.cancellation_requested + ): + raise MigrationRunContractError("migration run is not executable") + + active_attempt = await session.scalar( + select(MigrationRunAttempt) + .where( + MigrationRunAttempt.migration_run_uuid == migration_run_uuid, + MigrationRunAttempt.status == "active", + ) + .with_for_update() + .limit(1) + ) + if active_attempt is not None: + if active_attempt.lease_expires_at > acquired_at: + raise MigrationRunContractError("migration run attempt is already active") + active_attempt.status = "abandoned" + active_attempt.finished_at = acquired_at + + latest_attempt_number = await session.scalar( + select(func.max(MigrationRunAttempt.attempt_number)).where( + MigrationRunAttempt.migration_run_uuid == migration_run_uuid + ) + ) + attempt_number = int(latest_attempt_number or 0) + 1 + attempt = MigrationRunAttempt( + migration_run_attempt_uuid=uuid.uuid4(), + migration_run_uuid=migration_run_uuid, + attempt_number=attempt_number, + acquired_state_version=run.state_version, + status="active", + worker_identity_hash=worker_identity_hash, + signal_lease_token_hash=signal_lease_token_hash, + lease_expires_at=lease_expires_at, + acquired_at=acquired_at, + last_heartbeat_at=acquired_at, + finished_at=None, + ) + session.add(attempt) + return MigrationRunAttemptClaim( + migration_run_attempt_uuid=attempt.migration_run_attempt_uuid, + migration_run_uuid=attempt.migration_run_uuid, + attempt_number=attempt.attempt_number, + acquired_state_version=attempt.acquired_state_version, + lease_expires_at=attempt.lease_expires_at, + ) + + +async def renew_migration_run_attempt( + session: AsyncSession, + *, + claim: MigrationRunAttemptClaim, + worker_identity: str, + signal_lease_token: uuid.UUID, + lease_seconds: int, + now: dt.datetime | None = None, +) -> bool: + """Monotonically renew one exact, unexpired attempt while its run is active.""" + + heartbeat_at = now or dt.datetime.now(dt.timezone.utc) + worker_identity_hash, requested_expiry = _validate_attempt_inputs( + worker_identity=worker_identity, + lease_seconds=lease_seconds, + now=heartbeat_at, + ) + signal_lease_token_hash = _hash_signal_lease_token(signal_lease_token) + executable_run = ( + select(MigrationRun.migration_run_uuid) + .where( + MigrationRun.migration_run_uuid == claim.migration_run_uuid, + MigrationRun.run_kind == "dry_run", + MigrationRun.state.in_( + {"queued", "sandbox_running", "live_preflight_running"} + ), + MigrationRun.cancellation_requested.is_(False), + ) + .exists() + ) + result = cast( + CursorResult[Any], + await session.execute( + update(MigrationRunAttempt) + .where( + MigrationRunAttempt.migration_run_attempt_uuid + == claim.migration_run_attempt_uuid, + MigrationRunAttempt.migration_run_uuid + == claim.migration_run_uuid, + MigrationRunAttempt.attempt_number == claim.attempt_number, + MigrationRunAttempt.status == "active", + MigrationRunAttempt.worker_identity_hash == worker_identity_hash, + MigrationRunAttempt.signal_lease_token_hash + == signal_lease_token_hash, + MigrationRunAttempt.lease_expires_at > heartbeat_at, + executable_run, + ) + .values( + lease_expires_at=func.greatest( + MigrationRunAttempt.lease_expires_at, requested_expiry + ), + last_heartbeat_at=heartbeat_at, + ) + .execution_options(synchronize_session=False) + ), + ) + return result.rowcount == 1 + + +async def finish_migration_run_attempt( + session: AsyncSession, + *, + claim: MigrationRunAttemptClaim, + worker_identity: str, + signal_lease_token: uuid.UUID, + succeeded: bool, + now: dt.datetime | None = None, +) -> bool: + """Finish one exact active attempt as completed or abandoned.""" + + finished_at = now or dt.datetime.now(dt.timezone.utc) + worker_identity_hash, _ = _validate_attempt_inputs( + worker_identity=worker_identity, + lease_seconds=1, + now=finished_at, + ) + signal_lease_token_hash = _hash_signal_lease_token(signal_lease_token) + if not isinstance(succeeded, bool): + raise MigrationRunContractError("migration attempt outcome is invalid") + result = cast( + CursorResult[Any], + await session.execute( + update(MigrationRunAttempt) + .where( + MigrationRunAttempt.migration_run_attempt_uuid + == claim.migration_run_attempt_uuid, + MigrationRunAttempt.migration_run_uuid + == claim.migration_run_uuid, + MigrationRunAttempt.attempt_number == claim.attempt_number, + MigrationRunAttempt.status == "active", + MigrationRunAttempt.worker_identity_hash == worker_identity_hash, + MigrationRunAttempt.signal_lease_token_hash + == signal_lease_token_hash, + MigrationRunAttempt.lease_expires_at > finished_at, + ) + .values( + status="completed" if succeeded else "abandoned", + finished_at=finished_at, + ) + .execution_options(synchronize_session=False) + ), + ) + return result.rowcount == 1 + + +async def claim_one_migration_dispatch( + session: AsyncSession, + *, + now: dt.datetime | None = None, +) -> MigrationDispatchClaim | None: + """Lock one due outbox row for an identifier-only publish attempt. + + The caller must keep this transaction open while publishing only the + migration-run UUID, then mark the claim published before committing. A + publish failure must roll back the transaction, restoring the pending row + and its attempt counter. SKIP LOCKED lets independent relays make progress + without publishing the same row concurrently. + """ + + transition_time = now or dt.datetime.now(dt.timezone.utc) + _require_aware_dispatch_time(transition_time) + dispatch = await session.scalar( + select(MigrationRunDispatch) + .where( + MigrationRunDispatch.status == "pending", + MigrationRunDispatch.not_before <= transition_time, + ) + .order_by( + MigrationRunDispatch.not_before, + MigrationRunDispatch.migration_run_dispatch_uuid, + ) + .with_for_update(skip_locked=True) + .limit(1) + ) + if dispatch is None: + return None + dispatch.attempt_count = int(dispatch.attempt_count) + 1 + return MigrationDispatchClaim( + migration_run_dispatch_uuid=dispatch.migration_run_dispatch_uuid, + migration_run_uuid=dispatch.migration_run_uuid, + dispatch_kind=dispatch.dispatch_kind, + attempt_count=dispatch.attempt_count, + ) + + +async def mark_migration_dispatch_published( + session: AsyncSession, + *, + claim: MigrationDispatchClaim, + now: dt.datetime | None = None, +) -> None: + """CAS one locked claim to published without committing its transaction.""" + + transition_time = now or dt.datetime.now(dt.timezone.utc) + _require_aware_dispatch_time(transition_time) + if claim.attempt_count < 1: + raise MigrationRunContractError("migration dispatch attempt is invalid") + result = cast( + CursorResult[Any], + await session.execute( + update(MigrationRunDispatch) + .where( + MigrationRunDispatch.migration_run_dispatch_uuid + == claim.migration_run_dispatch_uuid, + MigrationRunDispatch.migration_run_uuid + == claim.migration_run_uuid, + MigrationRunDispatch.dispatch_kind == claim.dispatch_kind, + MigrationRunDispatch.status == "pending", + MigrationRunDispatch.attempt_count == claim.attempt_count, + MigrationRunDispatch.published_at.is_(None), + ) + .values(status="published", published_at=transition_time) + .execution_options(synchronize_session=False) + ), + ) + if result.rowcount != 1: + raise MigrationRunContractError("migration dispatch claim is stale") + + +def validate_run_transition(run_kind: str, current_state: str, next_state: str) -> None: + """Reject a state transition outside the exact dry-run/apply graph.""" + + transitions = _TRANSITIONS.get(run_kind) + if transitions is None: + raise MigrationRunContractError(f"unknown run kind {run_kind!r}") + if next_state not in transitions.get(current_state, frozenset()): + raise MigrationRunContractError( + f"invalid transition for {run_kind}: {current_state} -> {next_state}" + ) + + +def hash_idempotency_key(value: str) -> str: + """Return a storage-safe digest for one bounded opaque request key.""" + + encoded = value.encode("utf-8") + if not encoded or len(encoded) > MAX_IDEMPOTENCY_KEY_BYTES: + raise MigrationRunContractError("idempotency key length is invalid", code="idempotency_key_invalid") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value): + raise MigrationRunContractError("idempotency key contains a control character", code="idempotency_key_invalid") + return hashlib.sha256(encoded).hexdigest() + + +def digest_run_request( + *, + project_space_uuid: uuid.UUID, + migration_plan_uuid: uuid.UUID, + run_kind: str, + plan_digest: str, + requested_by_user_uuid: uuid.UUID, + passed_dry_run_uuid: uuid.UUID | None = None, + confirmation_digest: str | None = None, +) -> str: + """Bind one versioned run intent for idempotency conflict detection.""" + + if run_kind not in {"dry_run", "apply"}: + raise MigrationRunContractError("run kind is invalid") + if re.fullmatch(r"[0-9a-f]{64}", plan_digest) is None: + raise MigrationRunContractError("plan digest is invalid") + request = { + "contract_version": "migration-run-request/v1", + "migration_plan_uuid": str(migration_plan_uuid), + "plan_digest": plan_digest, + "project_space_uuid": str(project_space_uuid), + "requested_by_user_uuid": str(requested_by_user_uuid), + "run_kind": run_kind, + } + if run_kind == "apply": + if not isinstance(passed_dry_run_uuid, uuid.UUID): + raise MigrationRunContractError("passed dry run is invalid", code="passed_dry_run_invalid") + if confirmation_digest is None or _HEX_DIGEST.fullmatch( + confirmation_digest + ) is None: + raise MigrationRunContractError("apply confirmation is invalid", code="apply_confirmation_invalid") + request["passed_dry_run_uuid"] = str(passed_dry_run_uuid) + request["confirmation_digest"] = confirmation_digest + encoded = json.dumps( + request, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def digest_run_event( + *, + migration_run_uuid: uuid.UUID, + sequence_number: int, + event_type: str, + state_before: str | None, + state_after: str, + evidence: Mapping[str, object], + actor_user_uuid: uuid.UUID | None, + created_at: dt.datetime, + previous_event_digest: str | None, +) -> str: + """Return the versioned digest for one canonical event-chain link.""" + + if not isinstance(migration_run_uuid, uuid.UUID): + raise MigrationRunContractError("migration run UUID is invalid") + if ( + isinstance(sequence_number, bool) + or not isinstance(sequence_number, int) + or sequence_number < 1 + ): + raise MigrationRunContractError("event sequence is invalid") + if _EVENT_TYPE.fullmatch(event_type) is None: + raise MigrationRunContractError("event type is invalid") + if not isinstance(state_after, str) or not state_after: + raise MigrationRunContractError("event state is invalid") + if state_before is not None and ( + not isinstance(state_before, str) or not state_before + ): + raise MigrationRunContractError("event state is invalid") + if actor_user_uuid is not None and not isinstance(actor_user_uuid, uuid.UUID): + raise MigrationRunContractError("event actor UUID is invalid") + if created_at.tzinfo is None or created_at.utcoffset() is None: + raise MigrationRunContractError("event time must include a timezone") + if sequence_number == 1: + if previous_event_digest is not None: + raise MigrationRunContractError( + "genesis event must not have a previous digest" + ) + elif ( + previous_event_digest is None + or _HEX_DIGEST.fullmatch(previous_event_digest) is None + ): + raise MigrationRunContractError("previous event digest is invalid") + + event = { + "actor_user_uuid": ( + str(actor_user_uuid) if actor_user_uuid is not None else None + ), + "contract_version": "migration-run-event/v1", + "created_at": created_at.astimezone(dt.timezone.utc) + .isoformat(timespec="microseconds") + .replace("+00:00", "Z"), + "event_type": event_type, + "evidence": canonicalize_run_evidence(evidence), + "migration_run_uuid": str(migration_run_uuid), + "previous_event_digest": previous_event_digest, + "sequence_number": sequence_number, + "state_after": state_after, + "state_before": state_before, + } + encoded = json.dumps( + event, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _evidence_field_tokens(key: str) -> tuple[str, ...]: + separated_key = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key) + return tuple( + token + for token in re.split(r"[^a-z0-9]+", separated_key.casefold()) + if token + ) + + +def _contains_evidence_field(value: object, identity: str) -> bool: + if isinstance(value, Mapping): + return any( + "".join(_evidence_field_tokens(key)) == identity + or _contains_evidence_field(nested, identity) + for key, nested in value.items() + if isinstance(key, str) + ) + if isinstance(value, Sequence) and not isinstance( + value, (str, bytes, bytearray) + ): + return any(_contains_evidence_field(item, identity) for item in value) + return False + + +def _validate_evidence(value: object, *, path: str, depth: int) -> Any: + if depth > MAX_RUN_EVIDENCE_DEPTH: + raise MigrationRunContractError("run evidence nesting is too deep") + if value is None or isinstance(value, (bool, int, str)): + if isinstance(value, str): + if len(value.encode("utf-8")) > MAX_RUN_EVIDENCE_STRING_BYTES: + raise MigrationRunContractError("run evidence string is too large") + if _POSTGRES_CONNECTION_STRING.search(value): + raise MigrationRunContractError( + "run evidence must not contain a PostgreSQL connection string" + ) + return value + if isinstance(value, float): + if not math.isfinite(value): + raise MigrationRunContractError("run evidence number must be finite") + return value + if isinstance(value, Mapping): + if len(value) > MAX_RUN_EVIDENCE_ITEMS: + raise MigrationRunContractError("run evidence object has too many fields") + result: dict[str, Any] = {} + for key, nested in value.items(): + if not isinstance(key, str): + raise MigrationRunContractError("run evidence field name must be text") + tokens = set(_evidence_field_tokens(key)) + if tokens & _FORBIDDEN_EVIDENCE_TOKENS: + raise MigrationRunContractError( + f"forbidden evidence field at {path}.{key}" + ) + result[key] = _validate_evidence( + nested, path=f"{path}.{key}", depth=depth + 1 + ) + return result + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + if len(value) > MAX_RUN_EVIDENCE_ITEMS: + raise MigrationRunContractError("run evidence list has too many items") + return [ + _validate_evidence(item, path=f"{path}[{index}]", depth=depth + 1) + for index, item in enumerate(value) + ] + raise MigrationRunContractError(f"unsupported run evidence value at {path}") + + +def canonicalize_run_evidence(value: Mapping[str, object]) -> dict[str, Any]: + """Return bounded canonical JSON evidence without SQL or credential fields.""" + + normalized = _validate_evidence(value, path="evidence", depth=0) + encoded = json.dumps( + normalized, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + if len(encoded) > MAX_RUN_EVIDENCE_BYTES: + raise MigrationRunContractError("run evidence is too large") + return cast(dict[str, Any], json.loads(encoded)) + + +async def create_migration_run( + session: AsyncSession, + *, + plan: MigrationPlan, + run_kind: str, + idempotency_key: str, + requested_by_user_uuid: uuid.UUID, + evidence: Mapping[str, object], + passed_dry_run: MigrationRun | None = None, + connection: DbConnection | None = None, + typed_connection_name: str | None = None, + destructive_acknowledged: bool | None = None, + model_revision: SchemaModelRevision | None = None, + schema_model: SchemaModel | None = None, + now: dt.datetime | None = None, +) -> MigrationRunCreation: + """Select one durable run intent without exposing execution authority. + + The PostgreSQL uniqueness constraint is the concurrency winner. A new run, + its genesis event, and one identifier-only dispatch outbox row share the + caller's transaction. This function never commits, publishes, or signals a + worker. Apply intents persist exact confirmation evidence but deliberately + create no dispatch row, worker signal, credential access, or DDL authority. + """ + + if run_kind not in {"dry_run", "apply"}: + raise MigrationRunContractError("run kind is invalid") + transition_time = now or dt.datetime.now(dt.timezone.utc) + if transition_time.tzinfo is None or transition_time.utcoffset() is None: + raise MigrationRunContractError("creation time must include a timezone") + key_hash = hash_idempotency_key(idempotency_key) + canonical_evidence = canonicalize_run_evidence(evidence) + plan_json = plan.plan_json + if ( + not verify_migration_plan_digest(plan_json, plan.statement_digest) + or plan_json.get("compiler_version") != plan.compiler_version + or plan_json.get("base_digest") != plan.base_digest + or plan_json.get("target_digest") != plan.target_digest + ): + raise MigrationRunContractError("migration plan integrity verification failed", code="plan_integrity_invalid") + if plan.expires_at <= transition_time: + raise MigrationRunContractError("migration plan expired", code="plan_expired") + if plan_json.get("can_dry_run") is not True or plan_json.get("blockers"): + raise MigrationRunContractError("migration plan cannot be dry-run", code="plan_not_executable") + + passed_dry_run_uuid: uuid.UUID | None = None + confirmation_digest: str | None = None + destructive_confirmation: bool | None = None + if run_kind == "dry_run": + if any( + value is not None + for value in ( + passed_dry_run, + connection, + typed_connection_name, + destructive_acknowledged, + model_revision, + schema_model, + ) + ): + raise MigrationRunContractError("dry-run confirmation is invalid") + else: + required_destructive = plan_json.get( + "requires_destructive_confirmation" + ) + if not isinstance(required_destructive, bool): + raise MigrationRunContractError("apply confirmation is invalid", code="apply_confirmation_invalid") + if ( + passed_dry_run is None + or connection is None + or not isinstance(typed_connection_name, str) + or not isinstance(destructive_acknowledged, bool) + or model_revision is None + or schema_model is None + ): + raise MigrationRunContractError("apply confirmation is invalid", code="apply_confirmation_invalid") + encoded_connection_name = typed_connection_name.encode("utf-8") + if ( + not encoded_connection_name + or len(typed_connection_name) > 128 + or any( + ord(character) < 0x20 or ord(character) == 0x7F + for character in typed_connection_name + ) + ): + raise MigrationRunContractError("apply confirmation is invalid", code="apply_confirmation_invalid") + if ( + model_revision.schema_model_revision_uuid + != plan.schema_model_revision_uuid + or model_revision.schema_model_uuid != schema_model.schema_model_uuid + or model_revision.revision_number != schema_model.current_revision_number + or model_revision.revision_digest != plan.target_digest + or schema_model.project_space_uuid != plan.project_space_uuid + ): + raise MigrationRunContractError("migration model revision is stale", code="stale_revision") + if ( + connection.db_connection_uuid != plan.db_connection_uuid + or connection.project_space_uuid != plan.project_space_uuid + or typed_connection_name != connection.conn_name + ): + raise MigrationRunContractError("target connection confirmation mismatch", code="target_confirmation_mismatch") + if ( + passed_dry_run.run_kind != "dry_run" + or passed_dry_run.state != "passed" + or passed_dry_run.cancellation_requested + or passed_dry_run.project_space_uuid != plan.project_space_uuid + or passed_dry_run.migration_plan_uuid != plan.migration_plan_uuid + or passed_dry_run.plan_digest != plan.statement_digest + or passed_dry_run.observed_base_digest != plan.base_digest + ): + raise MigrationRunContractError("passed dry run is invalid", code="passed_dry_run_invalid") + if destructive_acknowledged is not required_destructive: + raise MigrationRunContractError("destructive confirmation mismatch", code="destructive_confirmation_mismatch") + passed_dry_run_uuid = passed_dry_run.migration_run_uuid + destructive_confirmation = destructive_acknowledged + confirmation_payload = { + "actor_user_uuid": str(requested_by_user_uuid), + "connection_name": typed_connection_name, + "connection_uuid": str(connection.db_connection_uuid), + "contract_version": "migration-apply-confirmation/v1", + "destructive_acknowledged": destructive_acknowledged, + "passed_dry_run_uuid": str(passed_dry_run_uuid), + "plan_digest": plan.statement_digest, + } + confirmation_digest = hashlib.sha256( + json.dumps( + confirmation_payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + for reserved in ( + "passed_dry_run_uuid", + "target_connection_confirmed", + "destructive_acknowledged", + ): + if _contains_evidence_field(canonical_evidence, reserved.replace("_", "")): + raise MigrationRunContractError("apply evidence is invalid", code="apply_confirmation_invalid") + canonical_evidence = { + **canonical_evidence, + "destructive_acknowledged": destructive_acknowledged, + "passed_dry_run_uuid": str(passed_dry_run_uuid), + "target_connection_confirmed": True, + } + + request_digest = digest_run_request( + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind=run_kind, + plan_digest=plan.statement_digest, + requested_by_user_uuid=requested_by_user_uuid, + passed_dry_run_uuid=passed_dry_run_uuid, + confirmation_digest=confirmation_digest, + ) + run_uuid = uuid.uuid4() + event_digest = digest_run_event( + migration_run_uuid=run_uuid, + sequence_number=1, + event_type="run_queued", + state_before=None, + state_after="queued", + evidence=canonical_evidence, + actor_user_uuid=requested_by_user_uuid, + created_at=transition_time, + previous_event_digest=None, + ) + result = await session.execute( + insert(MigrationRun) + .values( + migration_run_uuid=run_uuid, + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + passed_dry_run_uuid=passed_dry_run_uuid, + run_kind=run_kind, + state="queued", + state_version=1, + idempotency_key_hash=key_hash, + plan_digest=plan.statement_digest, + request_digest=request_digest, + confirmation_digest=confirmation_digest, + destructive_confirmation=destructive_confirmation, + latest_event_digest=event_digest, + requested_by_user_uuid=requested_by_user_uuid, + cancellation_requested=False, + observed_base_digest=None, + evidence_json=canonical_evidence, + error_code=None, + created_at=transition_time, + updated_at=transition_time, + started_at=None, + finished_at=None, + ) + .on_conflict_do_nothing(constraint="uq_migration_run__idempotent_action") + .returning(MigrationRun.migration_run_uuid) + ) + inserted_uuid = result.scalar_one_or_none() + if inserted_uuid is None: + existing = await session.scalar( + select(MigrationRun).where( + MigrationRun.project_space_uuid == plan.project_space_uuid, + MigrationRun.run_kind == run_kind, + MigrationRun.idempotency_key_hash == key_hash, + ) + ) + if existing is None: + raise MigrationRunContractError("idempotency winner is unavailable", code="run_creation_unavailable") + if existing.request_digest != request_digest: + raise MigrationRunContractError("idempotency key conflict", code="idempotency_key_conflict") + return MigrationRunCreation( + migration_run_uuid=existing.migration_run_uuid, + state=existing.state, + state_version=existing.state_version, + cancellation_requested=existing.cancellation_requested, + reused=True, + ) + + session.add( + MigrationRunEvent( + migration_run_event_uuid=uuid.uuid4(), + migration_run_uuid=inserted_uuid, + sequence_number=1, + event_type="run_queued", + state_before=None, + state_after="queued", + evidence_json=canonical_evidence, + previous_event_digest=None, + event_digest=event_digest, + actor_user_uuid=requested_by_user_uuid, + created_at=transition_time, + ) + ) + if run_kind == "dry_run": + session.add( + MigrationRunDispatch( + migration_run_dispatch_uuid=uuid.uuid4(), + migration_run_uuid=inserted_uuid, + dispatch_kind="isolated_dry_run", + status="pending", + attempt_count=0, + not_before=transition_time, + created_at=transition_time, + published_at=None, + ) + ) + return MigrationRunCreation( + migration_run_uuid=inserted_uuid, + state="queued", + state_version=1, + cancellation_requested=False, + reused=False, + ) + + +async def transition_migration_run( + session: AsyncSession, + *, + migration_run_uuid: uuid.UUID, + expected_state_version: int, + next_state: str, + event_type: str, + evidence: Mapping[str, object], + observed_base_digest: str | None = None, + actor_user_uuid: uuid.UUID | None, + now: dt.datetime | None = None, +) -> MigrationRunTransition: + """Atomically advance one run and append the same-version durable event. + + The caller owns the surrounding transaction. The compare-and-swap update + prevents stale workers from publishing evidence after another worker has + advanced the run. Any later event insert failure therefore rolls the state + update back with the caller's transaction. + """ + + if ( + isinstance(expected_state_version, bool) + or not isinstance(expected_state_version, int) + or expected_state_version < 1 + ): + raise MigrationRunContractError("expected state version is invalid") + if _EVENT_TYPE.fullmatch(event_type) is None: + raise MigrationRunContractError("event type is invalid") + if observed_base_digest is not None and _HEX_DIGEST.fullmatch( + observed_base_digest + ) is None: + raise MigrationRunContractError("observed base digest is invalid") + canonical_evidence = canonicalize_run_evidence(evidence) + transition_time = now or dt.datetime.now(dt.timezone.utc) + if transition_time.tzinfo is None or transition_time.utcoffset() is None: + raise MigrationRunContractError("transition time must include a timezone") + + run = await session.scalar( + select(MigrationRun) + .where(MigrationRun.migration_run_uuid == migration_run_uuid) + .execution_options(populate_existing=True) + ) + if run is None or run.state_version != expected_state_version: + raise MigrationRunContractError("migration run state version conflict") + + current_state = run.state + validate_run_transition(run.run_kind, current_state, next_state) + acknowledges_cancellation = next_state == "cancelled" + if acknowledges_cancellation: + if not run.cancellation_requested: + raise MigrationRunContractError( + "migration run cancellation intent is required" + ) + if ( + event_type != "cancellation_acknowledged" + or canonical_evidence + or actor_user_uuid is not None + ): + raise MigrationRunContractError( + "migration run cancellation acknowledgement is invalid" + ) + binds_observed_base = ( + current_state == "live_preflight_running" + and next_state in {"passed", "drifted"} + ) + if binds_observed_base: + if observed_base_digest is None: + raise MigrationRunContractError("observed base digest is required") + if _contains_evidence_field(canonical_evidence, "observedbasedigest"): + raise MigrationRunContractError( + "observed base digest evidence is server-authoritative" + ) + plan = await session.scalar( + select(MigrationPlan).where( + MigrationPlan.migration_plan_uuid == run.migration_plan_uuid + ) + ) + if ( + plan is None + or plan.project_space_uuid != run.project_space_uuid + or run.plan_digest != plan.statement_digest + or not verify_migration_plan_digest( + plan.plan_json, plan.statement_digest + ) + or plan.plan_json.get("compiler_version") != plan.compiler_version + or plan.plan_json.get("base_digest") != plan.base_digest + or plan.plan_json.get("target_digest") != plan.target_digest + ): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + matches_planned_base = observed_base_digest == plan.base_digest + if (next_state == "passed") != matches_planned_base: + raise MigrationRunContractError( + "observed base digest conflicts with preflight outcome" + ) + canonical_evidence = canonicalize_run_evidence( + { + **canonical_evidence, + "observed_base_digest": observed_base_digest, + } + ) + elif observed_base_digest is not None: + raise MigrationRunContractError( + "observed base digest is not allowed for this transition" + ) + next_version = expected_state_version + 1 + previous_event_digest = run.latest_event_digest + event_digest = digest_run_event( + migration_run_uuid=migration_run_uuid, + sequence_number=next_version, + event_type=event_type, + state_before=current_state, + state_after=next_state, + evidence=canonical_evidence, + actor_user_uuid=actor_user_uuid, + created_at=transition_time, + previous_event_digest=previous_event_digest, + ) + started_at = run.started_at + finished_at = run.finished_at + values: dict[str, object] = { + "state": next_state, + "state_version": next_version, + "evidence_json": canonical_evidence, + "latest_event_digest": event_digest, + "updated_at": transition_time, + } + if ( + current_state == "queued" + and next_state != "cancelled" + and started_at is None + ): + started_at = transition_time + values["started_at"] = started_at + if not _TRANSITIONS[run.run_kind].get(next_state): + finished_at = transition_time + values["finished_at"] = finished_at + if binds_observed_base: + values["observed_base_digest"] = observed_base_digest + + result = cast( + CursorResult[Any], + await session.execute( + update(MigrationRun) + .where( + MigrationRun.migration_run_uuid == migration_run_uuid, + MigrationRun.run_kind == run.run_kind, + MigrationRun.state == current_state, + MigrationRun.state_version == expected_state_version, + MigrationRun.latest_event_digest == previous_event_digest, + *( + (MigrationRun.cancellation_requested.is_(True),) + if acknowledges_cancellation + else () + ), + ) + .values(**values) + .execution_options(synchronize_session=False) + ), + ) + if result.rowcount != 1: + raise MigrationRunContractError("migration run state version conflict") + + session.add( + MigrationRunEvent( + migration_run_event_uuid=uuid.uuid4(), + migration_run_uuid=migration_run_uuid, + sequence_number=next_version, + event_type=event_type, + state_before=current_state, + state_after=next_state, + evidence_json=canonical_evidence, + previous_event_digest=previous_event_digest, + event_digest=event_digest, + actor_user_uuid=actor_user_uuid, + created_at=transition_time, + ) + ) + for attribute, value in values.items(): + set_committed_value(run, attribute, value) + return MigrationRunTransition( + state=next_state, + state_version=next_version, + started_at=started_at, + finished_at=finished_at, + ) + + +def _canonicalize_isolated_dry_run_result( + result: Mapping[str, object], +) -> tuple[int, int, str, str]: + """Validate the exact bounded success shape returned by the executor.""" + + if ( + not isinstance(result, Mapping) + or set(result) != _ISOLATED_DRY_RUN_RESULT_FIELDS + ): + raise MigrationRunContractError("isolated dry-run result is invalid") + postgresql_major = result["postgresql_major"] + statement_count = result["statement_count"] + base_digest = result["base_digest"] + target_digest = result["target_digest"] + if ( + isinstance(postgresql_major, bool) + or not isinstance(postgresql_major, int) + or postgresql_major < 14 + or postgresql_major > 18 + or isinstance(statement_count, bool) + or not isinstance(statement_count, int) + or statement_count < 0 + or statement_count > MAX_DRY_RUN_STATEMENTS + or not isinstance(base_digest, str) + or _HEX_DIGEST.fullmatch(base_digest) is None + or not isinstance(target_digest, str) + or _HEX_DIGEST.fullmatch(target_digest) is None + or result["converged"] is not True + ): + raise MigrationRunContractError("isolated dry-run result is invalid") + return postgresql_major, statement_count, base_digest, target_digest + + +async def complete_isolated_dry_run( + session: AsyncSession, + *, + migration_run_uuid: uuid.UUID, + expected_state_version: int, + result: Mapping[str, object], + actor_user_uuid: uuid.UUID | None, + now: dt.datetime | None = None, +) -> MigrationRunTransition: + """Verify one executor success against its stored plan and advance CAS. + + The caller cannot select the next state, event type, evidence shape, plan, + or digests. This boundary owns no sandbox, connection, credential, queue + lease, durable worker attempt, or DDL execution authority. + """ + + postgresql_major, statement_count, base_digest, target_digest = ( + _canonicalize_isolated_dry_run_result(result) + ) + transition_time = now or dt.datetime.now(dt.timezone.utc) + if transition_time.tzinfo is None or transition_time.utcoffset() is None: + raise MigrationRunContractError("transition time must include a timezone") + run = await session.scalar( + select(MigrationRun).where( + MigrationRun.migration_run_uuid == migration_run_uuid + ) + ) + if ( + run is None + or run.run_kind != "dry_run" + or run.state != "sandbox_running" + or run.state_version != expected_state_version + or run.cancellation_requested + ): + raise MigrationRunContractError("migration run state version conflict") + plan = await session.scalar( + select(MigrationPlan).where( + MigrationPlan.migration_plan_uuid == run.migration_plan_uuid + ) + ) + if ( + plan is None + or plan.project_space_uuid != run.project_space_uuid + or run.plan_digest != plan.statement_digest + or plan.expires_at <= transition_time + or not verify_migration_plan_digest(plan.plan_json, plan.statement_digest) + or plan.plan_json.get("compiler_version") != plan.compiler_version + or plan.plan_json.get("base_digest") != plan.base_digest + or plan.plan_json.get("target_digest") != plan.target_digest + ): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + statements = plan.plan_json.get("statements") + if not isinstance(statements, list) or ( + postgresql_major != plan.plan_json.get("postgresql_major") + or statement_count != len(statements) + or base_digest != plan.base_digest + or target_digest != plan.target_digest + ): + raise MigrationRunContractError( + "isolated dry-run result does not match migration plan" + ) + return await transition_migration_run( + session, + migration_run_uuid=migration_run_uuid, + expected_state_version=expected_state_version, + next_state="live_preflight_running", + event_type="isolated_dry_run_succeeded", + evidence={ + "postgresql_major": postgresql_major, + "statement_count": statement_count, + "converged": True, + }, + actor_user_uuid=actor_user_uuid, + now=now, + ) + + +def _canonicalize_live_preflight_result( + result: Mapping[str, object], +) -> tuple[bool, bool, str, int, int, frozenset[tuple[int, int, str]]]: + """Validate one exact execution result without retaining target metadata.""" + + if ( + not isinstance(result, Mapping) + or set(result) != _LIVE_PREFLIGHT_RESULT_FIELDS + ): + raise MigrationRunContractError("live preflight result is invalid") + preconditions_passed = result["preconditions_passed"] + matches_plan_base = result["matches_plan_base"] + observed_base_digest = result["observed_base_digest"] + checks = result["checks"] + if ( + not isinstance(preconditions_passed, bool) + or not isinstance(matches_plan_base, bool) + or not isinstance(observed_base_digest, str) + or _HEX_DIGEST.fullmatch(observed_base_digest) is None + or not isinstance(checks, list) + or len(checks) > MAX_LIVE_PREFLIGHT_QUERIES + ): + raise MigrationRunContractError("live preflight result is invalid") + failed_check_count = 0 + positions: set[tuple[int, int]] = set() + check_bindings: set[tuple[int, int, str]] = set() + for check in checks: + if not isinstance(check, Mapping) or set(check) != _LIVE_PREFLIGHT_CHECK_FIELDS: + raise MigrationRunContractError("live preflight result is invalid") + statement_index = check["statement_index"] + precondition_index = check["precondition_index"] + kind = check["kind"] + passed = check["passed"] + if ( + isinstance(statement_index, bool) + or not isinstance(statement_index, int) + or statement_index < 0 + or isinstance(precondition_index, bool) + or not isinstance(precondition_index, int) + or precondition_index < 0 + or not isinstance(kind, str) + or kind not in LIVE_PREFLIGHT_PRECONDITION_KINDS + or not isinstance(passed, bool) + ): + raise MigrationRunContractError("live preflight result is invalid") + position = (statement_index, precondition_index) + if position in positions: + raise MigrationRunContractError("live preflight result is invalid") + positions.add(position) + check_bindings.add((statement_index, precondition_index, kind)) + if not passed: + failed_check_count += 1 + if preconditions_passed != (failed_check_count == 0): + raise MigrationRunContractError("live preflight result is invalid") + return ( + preconditions_passed, + matches_plan_base, + observed_base_digest, + len(checks), + failed_check_count, + frozenset(check_bindings), + ) + + +def _expected_live_preflight_checks( + plan_json: Mapping[str, object], +) -> frozenset[tuple[int, int, str]]: + """Derive the exact check identities from one integrity-checked plan.""" + + statements = plan_json.get("statements") + if not isinstance(statements, list): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + expected: set[tuple[int, int, str]] = set() + for statement_index, statement in enumerate(statements): + if not isinstance(statement, Mapping): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + preconditions = statement.get("preconditions") + if not isinstance(preconditions, list): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + for precondition_index, precondition in enumerate(preconditions): + if not isinstance(precondition, Mapping): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + kind = precondition.get("kind") + if ( + not isinstance(kind, str) + or kind not in LIVE_PREFLIGHT_PRECONDITION_KINDS + ): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + expected.add((statement_index, precondition_index, kind)) + return frozenset(expected) + + +async def complete_live_preflight( + session: AsyncSession, + *, + migration_run_uuid: uuid.UUID, + expected_state_version: int, + result: Mapping[str, object], + actor_user_uuid: uuid.UUID | None, + now: dt.datetime | None = None, +) -> MigrationRunTransition: + """Derive and persist one terminal state from bounded preflight evidence. + + A caller cannot choose the terminal state, event type, observed digest, or + durable evidence shape. Base mismatch wins ``drifted`` classification; + otherwise any failed structured check becomes ``failed`` and only an exact + base match with every check passing becomes ``passed``. + """ + + ( + preconditions_passed, + matches_plan_base, + observed_base_digest, + check_count, + failed_check_count, + check_bindings, + ) = _canonicalize_live_preflight_result(result) + transition_time = now or dt.datetime.now(dt.timezone.utc) + if transition_time.tzinfo is None or transition_time.utcoffset() is None: + raise MigrationRunContractError("transition time must include a timezone") + run = await session.scalar( + select(MigrationRun).where( + MigrationRun.migration_run_uuid == migration_run_uuid + ) + ) + if ( + run is None + or run.run_kind != "dry_run" + or run.state != "live_preflight_running" + or run.state_version != expected_state_version + or run.cancellation_requested + ): + raise MigrationRunContractError("migration run state version conflict") + plan = await session.scalar( + select(MigrationPlan).where( + MigrationPlan.migration_plan_uuid == run.migration_plan_uuid + ) + ) + if ( + plan is None + or plan.project_space_uuid != run.project_space_uuid + or run.plan_digest != plan.statement_digest + or plan.expires_at <= transition_time + or not verify_migration_plan_digest(plan.plan_json, plan.statement_digest) + or plan.plan_json.get("compiler_version") != plan.compiler_version + or plan.plan_json.get("base_digest") != plan.base_digest + or plan.plan_json.get("target_digest") != plan.target_digest + ): + raise MigrationRunContractError( + "migration plan integrity verification failed" + ) + if check_bindings != _expected_live_preflight_checks(plan.plan_json): + raise MigrationRunContractError( + "live preflight result does not match migration plan" + ) + if not matches_plan_base: + next_state = "drifted" + elif preconditions_passed: + next_state = "passed" + else: + next_state = "failed" + return await transition_migration_run( + session, + migration_run_uuid=migration_run_uuid, + expected_state_version=expected_state_version, + next_state=next_state, + event_type=f"live_preflight_{next_state}", + evidence={ + "check_count": check_count, + "failed_check_count": failed_check_count, + }, + observed_base_digest=( + observed_base_digest if next_state in {"passed", "drifted"} else None + ), + actor_user_uuid=actor_user_uuid, + now=now, + ) + + +async def request_migration_run_cancellation( + session: AsyncSession, + *, + migration_run_uuid: uuid.UUID, + expected_state_version: int, + actor_user_uuid: uuid.UUID | None, + evidence: Mapping[str, object], + now: dt.datetime | None = None, +) -> MigrationRunCancellation: + """Persist cancellation intent without inventing a synthetic run state. + + Cancellation increments the same optimistic state version used by workers + and appends a same-state event. A worker must therefore observe the intent + before its next transition can win. The caller owns the transaction. + """ + + if ( + isinstance(expected_state_version, bool) + or not isinstance(expected_state_version, int) + or expected_state_version < 1 + ): + raise MigrationRunContractError("expected state version is invalid") + canonical_evidence = canonicalize_run_evidence(evidence) + request_time = now or dt.datetime.now(dt.timezone.utc) + if request_time.tzinfo is None or request_time.utcoffset() is None: + raise MigrationRunContractError("cancellation time must include a timezone") + + run = await session.scalar( + select(MigrationRun) + .where(MigrationRun.migration_run_uuid == migration_run_uuid) + .execution_options(populate_existing=True) + ) + if run is None or run.state_version != expected_state_version: + raise MigrationRunContractError("migration run state version conflict") + transitions = _TRANSITIONS.get(run.run_kind) + if transitions is None or run.state not in ( + DRY_RUN_STATES if run.run_kind == "dry_run" else APPLY_RUN_STATES + ): + raise MigrationRunContractError("migration run state is invalid") + if not transitions.get(run.state): + raise MigrationRunContractError("terminal migration run cannot be cancelled") + if run.cancellation_requested: + return MigrationRunCancellation( + state=run.state, + state_version=run.state_version, + reused=True, + ) + + next_version = expected_state_version + 1 + previous_event_digest = run.latest_event_digest + event_digest = digest_run_event( + migration_run_uuid=migration_run_uuid, + sequence_number=next_version, + event_type="cancellation_requested", + state_before=run.state, + state_after=run.state, + evidence=canonical_evidence, + actor_user_uuid=actor_user_uuid, + created_at=request_time, + previous_event_digest=previous_event_digest, + ) + result = cast( + CursorResult[Any], + await session.execute( + update(MigrationRun) + .where( + MigrationRun.migration_run_uuid == migration_run_uuid, + MigrationRun.run_kind == run.run_kind, + MigrationRun.state == run.state, + MigrationRun.state_version == expected_state_version, + MigrationRun.cancellation_requested.is_(False), + MigrationRun.latest_event_digest == previous_event_digest, + ) + .values( + cancellation_requested=True, + state_version=next_version, + updated_at=request_time, + latest_event_digest=event_digest, + ) + .execution_options(synchronize_session=False) + ), + ) + if result.rowcount != 1: + raise MigrationRunContractError("migration run state version conflict") + + session.add( + MigrationRunEvent( + migration_run_event_uuid=uuid.uuid4(), + migration_run_uuid=migration_run_uuid, + sequence_number=next_version, + event_type="cancellation_requested", + state_before=run.state, + state_after=run.state, + evidence_json=canonical_evidence, + previous_event_digest=previous_event_digest, + event_digest=event_digest, + actor_user_uuid=actor_user_uuid, + created_at=request_time, + ) + ) + for attribute, value in { + "cancellation_requested": True, + "state_version": next_version, + "updated_at": request_time, + "latest_event_digest": event_digest, + }.items(): + set_committed_value(run, attribute, value) + return MigrationRunCancellation( + state=run.state, + state_version=next_version, + reused=False, + ) diff --git a/backend/app/forward/pre_apply_revalidation.py b/backend/app/forward/pre_apply_revalidation.py new file mode 100644 index 000000000..b322cb727 --- /dev/null +++ b/backend/app/forward/pre_apply_revalidation.py @@ -0,0 +1,777 @@ +"""Compile and capture bounded pre-apply revalidation facts. + +This module binds the persisted plan digest and compatibility metadata to the +existing structured table-lock and live-precondition compilers. It also proves +that every data precondition names its statement's table and that the table is +present in the deterministic lock set. Its pure observation assessment rejects +missing, extra, or positionally mismatched caller evidence and derives only +non-authorizing booleans. The public compiler re-derives the manifest from the +exact signed plan rather than trusting a caller-built dataclass. The manifest, +probe compiler, and pure assessor open no target connection. The optional +capture primitive accepts a caller-owned connection and performs only fixed +reads in one read-only repeatable-read transaction, producing non-authorizing +facts. It acquires no advisory/object lock, owns no credential or durable +attempt binding, dispatches no work, and executes no DDL. A future executor must +repeat these checks after acquiring its locks on the bound execution connection. +""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + +import asyncpg +from asyncpg.transaction import Transaction + +from app.forward.apply_lock_plan import ( + ApplyLockPlanContractError, + ApplyLockTarget, + compile_apply_lock_targets, +) +from app.forward.live_preflight import ( + LivePreflightContractError, + LivePreflightQuery, + SnapshotCapture, + compare_live_preflight_snapshot, + compile_live_preflight_queries, +) +from app.forward.migration_plan import COMPILER_VERSION, verify_migration_plan_digest +from app.pg_introspect.snapshot_contract import ( + CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, +) + +_DIGEST_RE = re.compile(r"[0-9a-f]{64}") +_PLAN_FIELDS = frozenset( + { + "compiler_version", + "snapshot_contract_version", + "postgresql_major", + "base_digest", + "target_digest", + "statements", + "proposed_statements", + "blockers", + "risk_summary", + "requires_destructive_confirmation", + "can_dry_run", + "plan_digest", + } +) +_STATEMENT_FIELDS = frozenset( + { + "kind", + "target", + "object_ref", + "sql", + "transactional", + "dependencies", + "dependency_refs", + "reversible", + "risk", + "required_privileges", + "preconditions", + } +) +_OBSERVATION_FIELDS = frozenset( + {"plan_digest", "observed_base_digest", "privileges", "preconditions"} +) +_PRIVILEGE_OBSERVATION_FIELDS = frozenset( + { + "statement_index", + "privilege", + "scope", + "schema_name", + "table_name", + "allowed", + } +) +_PRECONDITION_OBSERVATION_FIELDS = frozenset( + {"statement_index", "precondition_index", "kind", "passed"} +) +MAX_PRE_APPLY_REVALIDATION_STATEMENT_TIMEOUT_MS = 60_000 + + +class PreApplyRevalidationContractError(ValueError): + """Reject input that cannot safely enter future in-lock revalidation.""" + + +class _PreApplyRevalidationCaptureFailure(Exception): + """Separate target/callback failures from fixed public diagnostics.""" + + +@dataclass(frozen=True) +class ApplyTransactionSegment: + """One ordered compiler-v1 all-transactional apply segment input.""" + + segment_index: int + statement_indexes: tuple[int, ...] + transactional: bool + + +@dataclass(frozen=True) +class ApplyPrivilegeRequirement: + """One compiler-v1 privilege requirement without target access.""" + + statement_index: int + privilege: str + scope: str + schema_name: str | None + table_name: str | None + + +@dataclass(frozen=True) +class ApplyPrivilegeQuery: + """One parameterized read-only PostgreSQL privilege probe.""" + + statement_index: int + privilege: str + scope: str + sql: str + parameters: tuple[str, ...] + + +@dataclass(frozen=True) +class PreApplyRevalidationManifest: + """Immutable inputs a future executor must revalidate after locking.""" + + plan_digest: str + compiler_version: str + snapshot_contract_version: int + postgresql_major: int + base_digest: str + target_digest: str + transaction_segments: tuple[ApplyTransactionSegment, ...] + privilege_requirements: tuple[ApplyPrivilegeRequirement, ...] + lock_targets: tuple[ApplyLockTarget, ...] + precondition_queries: tuple[LivePreflightQuery, ...] + + +@dataclass(frozen=True) +class PreApplyRevalidationAssessment: + """Execution-neutral assessment of complete, manifest-bound observations.""" + + observed_base_digest: str + base_matches: bool + privileges_satisfied: bool + preconditions_satisfied: bool + + +def _require_digest(value: object, *, name: str) -> str: + """Return one canonical lowercase SHA-256 digest.""" + + if not isinstance(value, str) or _DIGEST_RE.fullmatch(value) is None: + raise PreApplyRevalidationContractError(f"{name} is invalid") + return value + + +def _validate_plan_shape(plan: Mapping[str, object]) -> list[object]: + """Reject contract drift before delegating to bounded compilers.""" + + if set(plan) != _PLAN_FIELDS: + raise PreApplyRevalidationContractError( + "pre-apply revalidation plan contract is invalid" + ) + statements = plan.get("statements") + if not isinstance(statements, list): + raise PreApplyRevalidationContractError( + "pre-apply revalidation statements are invalid" + ) + for statement in statements: + if not isinstance(statement, Mapping) or set(statement) != _STATEMENT_FIELDS: + raise PreApplyRevalidationContractError( + "pre-apply revalidation statement contract is invalid" + ) + if plan.get("compiler_version") != COMPILER_VERSION: + raise PreApplyRevalidationContractError( + "pre-apply revalidation compiler is unsupported" + ) + if ( + plan.get("snapshot_contract_version") + != CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION + ): + raise PreApplyRevalidationContractError( + "pre-apply revalidation snapshot contract is unsupported" + ) + if plan.get("proposed_statements") != []: + raise PreApplyRevalidationContractError( + "pre-apply revalidation proposals are not executable" + ) + return cast(list[object], statements) + + +def _validate_locked_preconditions( + statements: list[object], + lock_targets: tuple[ApplyLockTarget, ...], +) -> None: + """Require each data precondition to be covered by its statement lock.""" + + locked_tables = { + (target.schema_name, target.table_name) for target in lock_targets + } + for statement in statements: + if not isinstance(statement, Mapping): # guarded by _validate_plan_shape + raise PreApplyRevalidationContractError( + "pre-apply revalidation statement contract is invalid" + ) + object_ref = statement.get("object_ref") + preconditions = statement.get("preconditions") + if not isinstance(object_ref, Mapping) or not isinstance(preconditions, list): + raise PreApplyRevalidationContractError( + "pre-apply revalidation statement contract is invalid" + ) + statement_table = ( + object_ref.get("schema_name"), + object_ref.get("table_name"), + ) + for precondition in preconditions: + if not isinstance(precondition, Mapping): + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition is invalid" + ) + precondition_table = ( + precondition.get("schema_name"), + precondition.get("table_name"), + ) + if precondition_table != statement_table: + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition target " + "does not match statement" + ) + if precondition_table not in locked_tables: + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition target is not locked" + ) + + +def _compile_transaction_segments( + statements: list[object], +) -> tuple[ApplyTransactionSegment, ...]: + """Represent compiler-v1 work as zero or one ordered transaction segment.""" + + if not statements: + return () + return ( + ApplyTransactionSegment( + segment_index=0, + statement_indexes=tuple(range(len(statements))), + transactional=True, + ), + ) + + +def _compile_privilege_requirements( + statements: list[object], +) -> tuple[ApplyPrivilegeRequirement, ...]: + """Bind compiler-v1 privilege labels to structured PostgreSQL scopes.""" + + requirements: list[ApplyPrivilegeRequirement] = [] + for statement_index, statement in enumerate(statements): + if not isinstance(statement, Mapping): # guarded by _validate_plan_shape + raise PreApplyRevalidationContractError( + "pre-apply revalidation statement contract is invalid" + ) + kind = statement.get("kind") + object_ref = statement.get("object_ref") + if not isinstance(object_ref, Mapping): + raise PreApplyRevalidationContractError( + "pre-apply revalidation statement contract is invalid" + ) + if kind == "create_schema": + expected_privileges = ["CREATE"] + requirement = ApplyPrivilegeRequirement( + statement_index=statement_index, + privilege="CREATE", + scope="database", + schema_name=None, + table_name=None, + ) + elif kind == "create_table": + expected_privileges = ["CREATE"] + requirement = ApplyPrivilegeRequirement( + statement_index=statement_index, + privilege="CREATE", + scope="schema", + schema_name=cast(str, object_ref.get("schema_name")), + table_name=None, + ) + else: + expected_privileges = ["OWNER"] + requirement = ApplyPrivilegeRequirement( + statement_index=statement_index, + privilege="OWNER", + scope="table", + schema_name=cast(str, object_ref.get("schema_name")), + table_name=cast(str, object_ref.get("table_name")), + ) + if statement.get("required_privileges") != expected_privileges: + raise PreApplyRevalidationContractError( + "pre-apply revalidation required privileges are invalid" + ) + requirements.append(requirement) + return tuple(requirements) + + +def compile_pre_apply_revalidation_manifest( + plan: Mapping[str, object], + *, + expected_plan_digest: object, +) -> PreApplyRevalidationManifest: + """Bind exact signed plan metadata to deterministic lock/check inputs. + + The returned value defines inputs only. It does not make holding the locks + or completing revalidation true and therefore grants no execution authority. + """ + + expected_digest = _require_digest( + expected_plan_digest, name="expected plan digest" + ) + try: + digest_is_valid = isinstance(plan, Mapping) and verify_migration_plan_digest( + plan, expected_digest + ) + except (TypeError, ValueError, OverflowError, RecursionError): + digest_is_valid = False + if not digest_is_valid: + raise PreApplyRevalidationContractError( + "pre-apply revalidation plan digest is invalid" + ) + statements = _validate_plan_shape(plan) + + postgresql_major = plan.get("postgresql_major") + if ( + not isinstance(postgresql_major, int) + or isinstance(postgresql_major, bool) + or postgresql_major < 14 + or postgresql_major > 18 + ): + raise PreApplyRevalidationContractError( + "pre-apply revalidation PostgreSQL major is invalid" + ) + base_digest = _require_digest(plan.get("base_digest"), name="base digest") + target_digest = _require_digest( + plan.get("target_digest"), name="target digest" + ) + + try: + lock_targets = compile_apply_lock_targets(plan) + precondition_queries = compile_live_preflight_queries(plan) + except (ApplyLockPlanContractError, LivePreflightContractError) as err: + raise PreApplyRevalidationContractError(str(err)) from None + _validate_locked_preconditions(statements, lock_targets) + transaction_segments = _compile_transaction_segments(statements) + privilege_requirements = _compile_privilege_requirements(statements) + + return PreApplyRevalidationManifest( + plan_digest=expected_digest, + compiler_version=COMPILER_VERSION, + snapshot_contract_version=CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, + postgresql_major=postgresql_major, + base_digest=base_digest, + target_digest=target_digest, + transaction_segments=transaction_segments, + privilege_requirements=privilege_requirements, + lock_targets=lock_targets, + precondition_queries=precondition_queries, + ) + + +def _require_privilege_identifier(value: object) -> str: + """Validate one identifier passed as query data rather than SQL text.""" + + if ( + not isinstance(value, str) + or not value + or "\x00" in value + or len(value.encode("utf-8")) > 63 + ): + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege identifier is invalid" + ) + return value + + +def _compile_apply_privilege_queries_from_manifest( + manifest: PreApplyRevalidationManifest, +) -> tuple[ApplyPrivilegeQuery, ...]: + """Compile already-validated manifest requirements into catalog reads.""" + + queries: list[ApplyPrivilegeQuery] = [] + for position, requirement in enumerate(manifest.privilege_requirements): + if ( + not isinstance(requirement, ApplyPrivilegeRequirement) + or requirement.statement_index != position + ): + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege requirement order is invalid" + ) + if ( + requirement.privilege == "CREATE" + and requirement.scope == "database" + and requirement.schema_name is None + and requirement.table_name is None + ): + sql = ( + "SELECT pg_catalog.has_database_privilege(" + "pg_catalog.current_database(), 'CREATE')" + ) + parameters: tuple[str, ...] = () + elif ( + requirement.privilege == "CREATE" + and requirement.scope == "schema" + and requirement.table_name is None + ): + schema_name = _require_privilege_identifier(requirement.schema_name) + sql = "SELECT pg_catalog.has_schema_privilege($1::text, 'CREATE')" + parameters = (schema_name,) + elif requirement.privilege == "OWNER" and requirement.scope == "table": + schema_name = _require_privilege_identifier(requirement.schema_name) + table_name = _require_privilege_identifier(requirement.table_name) + sql = ( + "SELECT COALESCE((SELECT pg_catalog.pg_has_role(" + "c.relowner, 'USAGE') FROM pg_catalog.pg_class AS c " + "JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace " + "WHERE n.nspname::text = $1::text " + "AND c.relname::text = $2::text " + "AND c.relkind = 'r'), FALSE)" + ) + parameters = (schema_name, table_name) + else: + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege requirement is invalid" + ) + queries.append( + ApplyPrivilegeQuery( + statement_index=requirement.statement_index, + privilege=requirement.privilege, + scope=requirement.scope, + sql=sql, + parameters=parameters, + ) + ) + return tuple(queries) + + +def compile_apply_privilege_queries( + plan: Mapping[str, object], + *, + expected_plan_digest: object, +) -> tuple[ApplyPrivilegeQuery, ...]: + """Compile exact signed-plan requirements into parameterized catalog reads. + + The manifest is re-derived from the exact signed plan at this public trust + boundary. Callers therefore cannot redirect a valid-looking requirement + to a different database object by replacing a manifest dataclass field. + + This function does not execute the probes or establish which role, + connection, target, transaction, or lock context produced a result. + """ + + manifest = compile_pre_apply_revalidation_manifest( + plan, + expected_plan_digest=expected_plan_digest, + ) + return _compile_apply_privilege_queries_from_manifest(manifest) + + +async def _fetch_pre_apply_precondition( + connection: asyncpg.Connection, + query: LivePreflightQuery, + *, + client_timeout: float, +) -> object: + """Fetch one boolean while containing expected cast-data failure.""" + + if query.kind != "castable_values": + return await connection.fetchval( + query.sql, + timeout=client_timeout, + ) + + savepoint = connection.transaction() + await asyncio.wait_for(savepoint.start(), timeout=client_timeout) + try: + result = await connection.fetchval( + query.sql, + timeout=client_timeout, + ) + except asyncpg.DataError: + await asyncio.wait_for(savepoint.rollback(), timeout=client_timeout) + return False + await asyncio.wait_for(savepoint.commit(), timeout=client_timeout) + return result + + +async def capture_pre_apply_revalidation_observation( + connection: asyncpg.Connection, + plan: Mapping[str, object], + *, + expected_plan_digest: object, + capture_snapshot: SnapshotCapture, + statement_timeout_ms: int = 5_000, +) -> PreApplyRevalidationAssessment: + """Capture and assess fresh read-only facts in one target snapshot. + + The caller owns the connection and target routing. This bounded primitive + re-derives the manifest from the signed plan, begins one read-only + repeatable-read transaction, captures the strict snapshot, and observes all + exact privilege and data-precondition positions on that same connection. + It acquires no advisory/object lock and returns only non-authorizing facts; + a future apply executor must repeat the checks after acquiring its locks. + """ + + if ( + not isinstance(statement_timeout_ms, int) + or isinstance(statement_timeout_ms, bool) + or not 1 + <= statement_timeout_ms + <= MAX_PRE_APPLY_REVALIDATION_STATEMENT_TIMEOUT_MS + ): + raise PreApplyRevalidationContractError( + "pre-apply revalidation statement timeout is invalid" + ) + if not callable(capture_snapshot): + raise PreApplyRevalidationContractError( + "pre-apply revalidation snapshot capture is invalid" + ) + + manifest = compile_pre_apply_revalidation_manifest( + plan, + expected_plan_digest=expected_plan_digest, + ) + privilege_queries = _compile_apply_privilege_queries_from_manifest(manifest) + client_timeout = statement_timeout_ms / 1000 + 1 + transaction: Transaction | None = None + transaction_started = False + try: + transaction = connection.transaction( + isolation="repeatable_read", + readonly=True, + ) + await asyncio.wait_for(transaction.start(), timeout=client_timeout) + transaction_started = True + await asyncio.wait_for( + connection.execute( + "SELECT pg_catalog.set_config('statement_timeout', $1, true)", + str(statement_timeout_ms), + ), + timeout=client_timeout, + ) + try: + snapshot = await asyncio.wait_for( + capture_snapshot(connection), + timeout=client_timeout, + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: + raise _PreApplyRevalidationCaptureFailure from None + if not isinstance(snapshot, Mapping): + raise PreApplyRevalidationContractError( + "pre-apply revalidation snapshot capture is invalid" + ) + try: + snapshot_evidence = compare_live_preflight_snapshot(plan, snapshot) + except LivePreflightContractError as err: + raise PreApplyRevalidationContractError(str(err)) from None + + privilege_rows: list[dict[str, object]] = [] + for requirement, query in zip( + manifest.privilege_requirements, + privilege_queries, + strict=True, + ): + allowed = await asyncio.wait_for( + connection.fetchval( + query.sql, + *query.parameters, + timeout=client_timeout, + ), + timeout=client_timeout, + ) + if not isinstance(allowed, bool): + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege result is invalid" + ) + privilege_rows.append( + { + "statement_index": requirement.statement_index, + "privilege": requirement.privilege, + "scope": requirement.scope, + "schema_name": requirement.schema_name, + "table_name": requirement.table_name, + "allowed": allowed, + } + ) + + precondition_rows: list[dict[str, object]] = [] + for precondition_query in manifest.precondition_queries: + passed = await asyncio.wait_for( + _fetch_pre_apply_precondition( + connection, + precondition_query, + client_timeout=client_timeout, + ), + timeout=client_timeout, + ) + if not isinstance(passed, bool): + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition result is invalid" + ) + precondition_rows.append( + { + "statement_index": precondition_query.statement_index, + "precondition_index": precondition_query.precondition_index, + "kind": precondition_query.kind, + "passed": passed, + } + ) + + observed_base_digest = snapshot_evidence.get("observed_base_digest") + observation: dict[str, object] = { + "plan_digest": manifest.plan_digest, + "observed_base_digest": observed_base_digest, + "privileges": privilege_rows, + "preconditions": precondition_rows, + } + assessment = assess_pre_apply_revalidation_observation( + manifest, + observation, + ) + await asyncio.wait_for(transaction.commit(), timeout=client_timeout) + return assessment + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + if transaction_started and transaction is not None: + try: + await asyncio.wait_for( + transaction.rollback(), + timeout=client_timeout, + ) + except Exception: + # Best-effort cleanup must not mask cancellation or shutdown. + pass + raise + except Exception as err: + if transaction_started and transaction is not None: + try: + await asyncio.wait_for( + transaction.rollback(), + timeout=client_timeout, + ) + except Exception: + # Best-effort cleanup must not mask the original target failure. + pass + if isinstance(err, PreApplyRevalidationContractError): + raise + raise PreApplyRevalidationContractError( + "pre-apply revalidation capture failed" + ) from None + + +def assess_pre_apply_revalidation_observation( + manifest: PreApplyRevalidationManifest, + observation: Mapping[str, object], +) -> PreApplyRevalidationAssessment: + """Validate complete positional evidence and derive non-authorizing facts. + + The caller remains responsible for proving that observations were captured + freshly while holding the manifest locks on the intended target connection. + This pure function cannot establish those facts or grant apply authority. + """ + + if not isinstance(manifest, PreApplyRevalidationManifest): + raise PreApplyRevalidationContractError( + "pre-apply revalidation manifest is invalid" + ) + if not isinstance(observation, Mapping) or set(observation) != _OBSERVATION_FIELDS: + raise PreApplyRevalidationContractError( + "pre-apply revalidation observation contract is invalid" + ) + plan_digest = _require_digest( + observation.get("plan_digest"), name="observation plan digest" + ) + if plan_digest != manifest.plan_digest: + raise PreApplyRevalidationContractError( + "pre-apply revalidation observation plan digest does not match" + ) + observed_base_digest = _require_digest( + observation.get("observed_base_digest"), name="observed base digest" + ) + + privilege_rows = observation.get("privileges") + if not isinstance(privilege_rows, list) or len(privilege_rows) != len( + manifest.privilege_requirements + ): + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege observations are incomplete" + ) + privilege_results: list[bool] = [] + for requirement, row in zip( + manifest.privilege_requirements, + privilege_rows, + strict=True, + ): + if not isinstance(row, Mapping) or set(row) != _PRIVILEGE_OBSERVATION_FIELDS: + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege observation is invalid" + ) + expected = { + "statement_index": requirement.statement_index, + "privilege": requirement.privilege, + "scope": requirement.scope, + "schema_name": requirement.schema_name, + "table_name": requirement.table_name, + } + if any(row.get(field) != value for field, value in expected.items()): + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege observation does not match manifest" + ) + allowed = row.get("allowed") + if not isinstance(allowed, bool): + raise PreApplyRevalidationContractError( + "pre-apply revalidation privilege result is invalid" + ) + privilege_results.append(allowed) + + precondition_rows = observation.get("preconditions") + if not isinstance(precondition_rows, list) or len(precondition_rows) != len( + manifest.precondition_queries + ): + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition observations are incomplete" + ) + precondition_results: list[bool] = [] + for query, row in zip( + manifest.precondition_queries, + precondition_rows, + strict=True, + ): + if not isinstance(row, Mapping) or set(row) != _PRECONDITION_OBSERVATION_FIELDS: + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition observation is invalid" + ) + expected = { + "statement_index": query.statement_index, + "precondition_index": query.precondition_index, + "kind": query.kind, + } + if any(row.get(field) != value for field, value in expected.items()): + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition observation " + "does not match manifest" + ) + passed = row.get("passed") + if not isinstance(passed, bool): + raise PreApplyRevalidationContractError( + "pre-apply revalidation precondition result is invalid" + ) + precondition_results.append(passed) + + return PreApplyRevalidationAssessment( + observed_base_digest=observed_base_digest, + base_matches=observed_base_digest == manifest.base_digest, + privileges_satisfied=all(privilege_results), + preconditions_satisfied=all(precondition_results), + ) diff --git a/backend/app/forward/schema_model.py b/backend/app/forward/schema_model.py new file mode 100644 index 000000000..4260be2d4 --- /dev/null +++ b/backend/app/forward/schema_model.py @@ -0,0 +1,364 @@ +"""Validate and canonicalize editable PostgreSQL schema models. + +The browser supplies an untrusted model, never executable SQL. This module is +the first server-owned authority boundary: it rejects ambiguous or lossy +objects, removes explicitly volatile capture metadata, and produces stable JSON +whose SHA-256 digest can bind revisions, plans, approvals, dry runs and applies. + +Only the deliberately small v1 contract is accepted. Unknown fields inside +authoritative objects fail closed so a newer client cannot silently lose a +schema feature when an older server compiles it. PostgreSQL identifiers are +preserved exactly (including Unicode, whitespace, reserved words and quotes); +the SQL renderer is responsible for dialect-correct quoting later. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from typing import Any + + +class SchemaModelValidationError(ValueError): + """Raised when an editable model cannot be represented without ambiguity.""" + + +_MODEL_FIELDS = {"format_version", "postgresql_major", "schemas"} +_SCHEMA_FIELDS = {"schema_name", "tables"} +_TABLE_FIELDS = { + "table_name", + "comment", + "columns", + "primary_key", + "unique_constraints", + "foreign_keys", + "indexes", + "unsupported_features", +} +_COLUMN_FIELDS = { + "column_name", + "data_type", + "nullable", + "ordinal_position", + "default", + "identity", + "generated", + "comment", +} +_PRIMARY_KEY_FIELDS = { + "constraint_name", + "columns", + "deferrable", + "initially_deferred", +} +_VOLATILE_MODEL_FIELDS = {"capture_id", "captured_at", "source_snapshot_uuid"} +_VOLATILE_TABLE_FIELDS = {"relation_oid", "captured_at"} +_CANONICAL_DATA_TYPE_RE = re.compile( + r"(?:" + r"smallint|integer|bigint|" + r"real|double\s+precision|money|boolean|" + r"text|bytea|uuid|json|jsonb|xml|inet|cidr|macaddr|macaddr8|" + r"tsvector|tsquery|date|" + r"character\s+varying(?:\(\d+\))?|character\(\d+\)|" + r"numeric(?:\(\d+(?:,\d+)?\))?|" + r"(?:timestamp|time)(?:\(\d+\))?\s+(?:with|without)\s+time\s+zone" + r")(?:\[\])?", +) +_SERIAL_PSEUDO_TYPES = {"smallserial", "serial", "bigserial"} + + +def _object(value: object, path: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise SchemaModelValidationError(f"{path} must be an object") + return value + + +def _list(value: object, path: str) -> list[Any]: + if not isinstance(value, list): + raise SchemaModelValidationError(f"{path} must be a list") + return value + + +def _check_fields( + value: Mapping[str, Any], allowed: set[str], path: str, volatile: set[str] | None = None +) -> None: + unknown = set(value) - allowed - (volatile or set()) + if unknown: + field = sorted(unknown)[0] + raise SchemaModelValidationError(f"{path} contains unrecognized field {field!r}") + + +def _text(value: object, path: str) -> str: + if not isinstance(value, str): + raise SchemaModelValidationError(f"{path} must be text") + if "\x00" in value: + raise SchemaModelValidationError(f"{path} must not contain NUL") + if not value: + raise SchemaModelValidationError(f"{path} must not be empty") + if len(value.encode("utf-8")) > 63: + raise SchemaModelValidationError( + f"{path} exceeds PostgreSQL's 63-byte identifier limit" + ) + return value + + +def _optional_text(value: object, path: str) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise SchemaModelValidationError(f"{path} must be text or null") + if "\x00" in value: + raise SchemaModelValidationError(f"{path} must not contain NUL") + return value + + +def _boolean(value: object, path: str) -> bool: + if not isinstance(value, bool): + raise SchemaModelValidationError(f"{path} must be boolean") + return value + + +def canonicalize_data_type(value: object, path: str) -> str: + """Normalize safe SQL type syntax to ``pg_catalog.format_type`` spelling.""" + + if not isinstance(value, str): + raise SchemaModelValidationError(f"{path} must be text") + if "\x00" in value: + raise SchemaModelValidationError(f"{path} must not contain NUL") + normalized = re.sub(r"\s+", " ", value.strip().lower()) + normalized = re.sub(r"\s*\(\s*", "(", normalized) + normalized = re.sub(r"\s*,\s*", ",", normalized) + normalized = re.sub(r"\s*\)", ")", normalized) + if not normalized: + raise SchemaModelValidationError(f"{path} must not be empty") + + array_suffix = "" + if normalized.endswith("[]"): + array_suffix = "[]" + normalized = normalized[:-2].rstrip() + if normalized in _SERIAL_PSEUDO_TYPES: + raise SchemaModelValidationError( + f"{path} contains unsupported serial pseudo-type" + ) + + aliases = {"int": "integer", "bool": "boolean"} + normalized = aliases.get(normalized, normalized) + for alias, canonical in ( + ("varchar", "character varying"), + ("char", "character"), + ("decimal", "numeric"), + ): + match = re.fullmatch(rf"{alias}(\(\d+(?:,\d+)?\))?", normalized) + if match: + normalized = canonical + (match.group(1) or "") + break + if normalized == "character": + normalized = "character(1)" + for temporal in ("timestamp", "time"): + if re.fullmatch(rf"{temporal}(?:\(\d+\))?", normalized): + normalized += " without time zone" + break + + canonical = normalized + array_suffix + if _CANONICAL_DATA_TYPE_RE.fullmatch(canonical) is None: + raise SchemaModelValidationError(f"{path} contains unsupported data type") + return canonical + + +def _string_list(value: object, path: str) -> list[str]: + return [_text(item, f"{path}[{index}]") for index, item in enumerate(_list(value, path))] + + +def _canonical_primary_key( + value: object, columns: set[str], path: str +) -> dict[str, Any] | None: + if value is None: + return None + primary_key = _object(value, path) + _check_fields(primary_key, _PRIMARY_KEY_FIELDS, path) + key_columns = _string_list(primary_key.get("columns"), f"{path}.columns") + if not key_columns: + raise SchemaModelValidationError(f"{path}.columns must not be empty") + if len(set(key_columns)) != len(key_columns): + raise SchemaModelValidationError(f"{path}.columns contains a duplicate column") + unknown = [column for column in key_columns if column not in columns] + if unknown: + raise SchemaModelValidationError( + f"{path}.columns references unknown column {unknown[0]!r}" + ) + deferrable = _boolean(primary_key.get("deferrable"), f"{path}.deferrable") + initially_deferred = _boolean( + primary_key.get("initially_deferred"), f"{path}.initially_deferred" + ) + if initially_deferred and not deferrable: + raise SchemaModelValidationError( + f"{path}.initially_deferred requires deferrable to be true" + ) + return { + "constraint_name": _text( + primary_key.get("constraint_name"), f"{path}.constraint_name" + ), + "columns": key_columns, + "deferrable": deferrable, + "initially_deferred": initially_deferred, + } + + +def _canonical_column(value: object, path: str) -> dict[str, Any]: + column = _object(value, path) + _check_fields(column, _COLUMN_FIELDS, path) + ordinal = column.get("ordinal_position") + if not isinstance(ordinal, int) or isinstance(ordinal, bool) or ordinal < 1: + raise SchemaModelValidationError(f"{path}.ordinal_position must be a positive integer") + data_type = canonicalize_data_type(column.get("data_type"), f"{path}.data_type") + for field, label in ( + ("default", "default expressions"), + ("identity", "identity columns"), + ("generated", "generated columns"), + ): + if column.get(field) is not None: + raise SchemaModelValidationError( + f"{path} contains unsupported feature {label}" + ) + result: dict[str, Any] = { + "column_name": _text(column.get("column_name"), f"{path}.column_name"), + "data_type": data_type, + "nullable": _boolean(column.get("nullable"), f"{path}.nullable"), + "ordinal_position": ordinal, + "comment": _optional_text(column.get("comment"), f"{path}.comment"), + } + return result + + +def _canonical_table(value: object, path: str) -> dict[str, Any]: + table = _object(value, path) + _check_fields(table, _TABLE_FIELDS, path, _VOLATILE_TABLE_FIELDS) + unsupported = _string_list( + table.get("unsupported_features", []), f"{path}.unsupported_features" + ) + if unsupported: + raise SchemaModelValidationError( + f"{path} contains unsupported feature {unsupported[0]!r}" + ) + columns = [ + _canonical_column(column, f"{path}.columns[{index}]") + for index, column in enumerate(_list(table.get("columns"), f"{path}.columns")) + ] + columns.sort(key=lambda column: (column["ordinal_position"], column["column_name"])) + column_names = [str(column["column_name"]) for column in columns] + if len(set(column_names)) != len(column_names): + raise SchemaModelValidationError(f"{path} contains a duplicate column") + ordinals = [int(column["ordinal_position"]) for column in columns] + if len(set(ordinals)) != len(ordinals): + raise SchemaModelValidationError(f"{path} contains a duplicate column ordinal") + + # These collections are retained in the v1 wire contract but must remain + # empty until their lossless validators and structured compilers land. + for field in ("unique_constraints", "foreign_keys", "indexes"): + entries = _list(table.get(field, []), f"{path}.{field}") + if entries: + raise SchemaModelValidationError( + f"{path}.{field} contains unsupported feature {field!r}" + ) + + primary_key = _canonical_primary_key( + table.get("primary_key"), set(column_names), f"{path}.primary_key" + ) + if primary_key is not None: + nullable_columns = { + str(column["column_name"]) + for column in columns + if bool(column["nullable"]) + } + nullable_key_columns = [ + column + for column in primary_key["columns"] + if column in nullable_columns + ] + if nullable_key_columns: + raise SchemaModelValidationError( + f"{path}.primary_key column {nullable_key_columns[0]!r} " + "must be explicitly not nullable" + ) + + result: dict[str, Any] = { + "table_name": _text(table.get("table_name"), f"{path}.table_name"), + "comment": _optional_text(table.get("comment"), f"{path}.comment"), + "columns": columns, + "primary_key": primary_key, + "unique_constraints": [], + "foreign_keys": [], + "indexes": [], + "unsupported_features": [], + } + return result + + +def canonicalize_schema_model(model: Mapping[str, Any]) -> dict[str, Any]: + """Return deterministic, validated JSON for a v1 editable schema model. + + Volatile reverse-engineering metadata is discarded. All compiler-relevant + fields are preserved and normalized in a deterministic order. Unsupported + or unrecognized content raises :class:`SchemaModelValidationError` instead + of being silently omitted. + """ + + root = _object(model, "model") + _check_fields(root, _MODEL_FIELDS, "model", _VOLATILE_MODEL_FIELDS) + if root.get("format_version") != 1: + raise SchemaModelValidationError("model.format_version must be 1") + postgresql_major = root.get("postgresql_major") + if ( + not isinstance(postgresql_major, int) + or isinstance(postgresql_major, bool) + or postgresql_major < 14 + or postgresql_major > 18 + ): + raise SchemaModelValidationError( + "model.postgresql_major must be a supported version from 14 through 18" + ) + + schemas: list[dict[str, Any]] = [] + schema_names: set[str] = set() + for schema_index, raw_schema in enumerate(_list(root.get("schemas"), "model.schemas")): + path = f"model.schemas[{schema_index}]" + schema = _object(raw_schema, path) + _check_fields(schema, _SCHEMA_FIELDS, path) + schema_name = _text(schema.get("schema_name"), f"{path}.schema_name") + if schema_name in schema_names: + raise SchemaModelValidationError(f"model contains duplicate schema {schema_name!r}") + schema_names.add(schema_name) + tables = [ + _canonical_table(table, f"{path}.tables[{table_index}]") + for table_index, table in enumerate( + _list(schema.get("tables"), f"{path}.tables") + ) + ] + table_names = [str(table["table_name"]) for table in tables] + if len(set(table_names)) != len(table_names): + raise SchemaModelValidationError( + f"{path} contains a duplicate table" + ) + tables.sort(key=lambda table: table["table_name"]) + schemas.append({"schema_name": schema_name, "tables": tables}) + schemas.sort(key=lambda schema: schema["schema_name"]) + return { + "format_version": 1, + "postgresql_major": postgresql_major, + "schemas": schemas, + } + + +def schema_model_digest(model: Mapping[str, Any]) -> str: + """Return a lowercase SHA-256 hex digest of canonical model JSON.""" + + canonical = canonicalize_schema_model(model) + encoded = json.dumps( + canonical, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() diff --git a/backend/app/forward/snapshot_adapter.py b/backend/app/forward/snapshot_adapter.py new file mode 100644 index 000000000..8a97f9cb1 --- /dev/null +++ b/backend/app/forward/snapshot_adapter.py @@ -0,0 +1,249 @@ +"""Convert reverse-engineering snapshots to the editable model contract. + +This adapter is deliberately loss-intolerant. It removes volatile PostgreSQL +OIDs only after resolving them within the same snapshot, and rejects any object +class compiler v1 cannot yet preserve and execute. That makes an unsupported +database an explicit planning blocker instead of a deceptively incomplete +target model. +""" + +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Mapping +from typing import Any + +from app.forward.schema_model import ( + SchemaModelValidationError, + canonicalize_schema_model, +) +from app.pg_introspect.snapshot_contract import ( + CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, +) + + +def _postgresql_major(snapshot: Mapping[str, Any]) -> int: + numeric = snapshot.get("server_version_num") + if isinstance(numeric, int) and not isinstance(numeric, bool): + major = numeric // 10_000 + else: + match = re.match(r"\s*(\d+)", str(snapshot.get("server_version") or "")) + major = int(match.group(1)) if match else 0 + if major < 14 or major > 18: + raise SchemaModelValidationError( + "snapshot PostgreSQL major version is missing or unsupported" + ) + return major + + +def snapshot_to_schema_model(snapshot: Mapping[str, Any]) -> dict[str, Any]: + """Return canonical v1 model JSON for a supported PostgreSQL snapshot.""" + + if ( + snapshot.get("snapshot_contract_version") + != CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION + ): + raise SchemaModelValidationError( + "snapshot capability contract is outdated; recapture is required" + ) + if snapshot.get("fk_edges"): + raise SchemaModelValidationError( + "snapshot foreign keys are not supported by compiler v1" + ) + if snapshot.get("citus_distributed_tables"): + raise SchemaModelValidationError( + "snapshot distributed tables are not supported by compiler v1" + ) + + relations = snapshot.get("relations") + columns = snapshot.get("columns") + raw_schemas = snapshot.get("schemas") + primary_keys = snapshot.get("pk_columns") or [] + constraints = snapshot.get("constraints") or [] + indexes = snapshot.get("indexes") or [] + if not isinstance(relations, list) or not isinstance(columns, list): + raise SchemaModelValidationError( + "snapshot relations and columns must be lists" + ) + if raw_schemas is not None and not isinstance(raw_schemas, list): + raise SchemaModelValidationError("snapshot schemas must be a list") + if not isinstance(constraints, list): + raise SchemaModelValidationError("snapshot constraints must be a list") + if not isinstance(indexes, list): + raise SchemaModelValidationError("snapshot indexes must be a list") + + oid_to_relation: dict[object, Mapping[str, Any]] = {} + for relation in relations: + if not isinstance(relation, Mapping): + raise SchemaModelValidationError("snapshot relation must be an object") + if relation.get("relation_kind") != "r": + raise SchemaModelValidationError( + "snapshot relation kind is not supported by compiler v1" + ) + if relation.get("has_dropped_columns") not in {None, False}: + raise SchemaModelValidationError( + "snapshot relations with dropped columns are not supported by compiler v1" + ) + if relation.get("is_partition") not in {None, False} or any( + relation.get(field) is not None + for field in ( + "partition_key", + "partition_bound", + "partition_parent_oid", + "partition_parent_schema", + "partition_parent_name", + ) + ): + raise SchemaModelValidationError( + "snapshot partition metadata is not supported by compiler v1" + ) + if relation.get("tablespace_name") is not None: + raise SchemaModelValidationError( + "snapshot tablespace metadata is not supported by compiler v1" + ) + relation_oid = relation.get("relation_oid") + if relation_oid is None: + raise SchemaModelValidationError("snapshot relation OID is missing") + if relation_oid in oid_to_relation: + raise SchemaModelValidationError( + "snapshot contains a duplicate relation OID" + ) + oid_to_relation[relation_oid] = relation + + columns_by_oid: dict[object, list[dict[str, Any]]] = defaultdict(list) + for column in columns: + if not isinstance(column, Mapping): + raise SchemaModelValidationError("snapshot column must be an object") + oid = column.get("relation_oid") + if oid not in oid_to_relation: + raise SchemaModelValidationError("snapshot column references unknown relation") + if ( + column.get("has_default") not in {None, False} + or column.get("default_expr") is not None + or any( + column.get(field) is not None + for field in ("column_default",) + ) + or any( + column.get(field) not in {None, ""} + for field in ("identity", "generated") + ) + ): + raise SchemaModelValidationError( + "snapshot default, identity, or generated columns are not supported by compiler v1" + ) + columns_by_oid[oid].append( + { + "column_name": column.get("column_name"), + "data_type": column.get("data_type"), + "nullable": not bool(column.get("is_not_null")), + "ordinal_position": column.get("column_position") + or column.get("ordinal_position"), + "comment": column.get("column_comment"), + } + ) + + pk_by_oid: dict[object, list[Mapping[str, Any]]] = defaultdict(list) + for primary_key_row in primary_keys: + if not isinstance(primary_key_row, Mapping): + raise SchemaModelValidationError("snapshot primary key must be an object") + oid = primary_key_row.get("relation_oid") + if oid not in oid_to_relation: + raise SchemaModelValidationError( + "snapshot primary key references unknown relation" + ) + pk_by_oid[oid].append(primary_key_row) + + for constraint_row in constraints: + if not isinstance(constraint_row, Mapping): + raise SchemaModelValidationError("snapshot constraint must be an object") + if constraint_row.get("constraint_type") != "p": + raise SchemaModelValidationError( + "snapshot constraints other than primary keys are not supported by compiler v1" + ) + relation_oid = constraint_row.get("relation_oid") + matching_key_rows = pk_by_oid.get(relation_oid, []) + constraint_name = constraint_row.get("constraint_name") + constraint_oid = constraint_row.get("constraint_oid") + represented = any( + key_row.get("constraint_name") == constraint_name + and ( + constraint_oid is None + or key_row.get("constraint_oid") is None + or key_row.get("constraint_oid") == constraint_oid + ) + for key_row in matching_key_rows + ) + if not represented: + raise SchemaModelValidationError( + "snapshot primary key constraint is not represented by pk_columns" + ) + + for index_row in indexes: + if not isinstance(index_row, Mapping): + raise SchemaModelValidationError("snapshot index must be an object") + relation_oid = index_row.get("relation_oid") or index_row.get("table_oid") + if index_row.get("is_primary") is not True or not pk_by_oid.get(relation_oid): + raise SchemaModelValidationError( + "snapshot indexes other than primary-key backing indexes are not supported by compiler v1" + ) + + schemas: dict[str, list[dict[str, Any]]] = defaultdict(list) + declared_schema_names: set[str] | None = None + if isinstance(raw_schemas, list): + declared_schema_names = set() + for schema_row in raw_schemas: + if not isinstance(schema_row, Mapping): + raise SchemaModelValidationError("snapshot schema must be an object") + schema_name = str(schema_row.get("schema_name") or "") + if schema_name in declared_schema_names: + raise SchemaModelValidationError("snapshot contains a duplicate schema") + declared_schema_names.add(schema_name) + schemas.setdefault(schema_name, []) + for oid, relation in oid_to_relation.items(): + key_parts = sorted( + pk_by_oid.get(oid, []), key=lambda item: int(item.get("column_ordinal") or 0) + ) + primary_key: dict[str, Any] | None = None + if key_parts: + names = {str(item.get("constraint_name") or "") for item in key_parts} + if len(names) != 1 or "" in names: + raise SchemaModelValidationError( + "snapshot primary key has ambiguous constraint names" + ) + primary_key = { + "constraint_name": next(iter(names)), + "columns": [str(item.get("column_name") or "") for item in key_parts], + "deferrable": bool(key_parts[0].get("is_deferrable")), + "initially_deferred": bool( + key_parts[0].get("is_initially_deferred") + ), + } + schema_name = str(relation.get("schema_name") or "") + if declared_schema_names is not None and schema_name not in declared_schema_names: + raise SchemaModelValidationError( + "snapshot relation references an undeclared schema" + ) + schemas[schema_name].append( + { + "table_name": relation.get("relation_name"), + "comment": relation.get("relation_comment"), + "columns": columns_by_oid.get(oid, []), + "primary_key": primary_key, + "unique_constraints": [], + "foreign_keys": [], + "indexes": [], + "unsupported_features": [], + } + ) + + model = { + "format_version": 1, + "postgresql_major": _postgresql_major(snapshot), + "schemas": [ + {"schema_name": schema_name, "tables": tables} + for schema_name, tables in schemas.items() + ], + } + return canonicalize_schema_model(model) diff --git a/backend/app/jobs/live_preflight_provider.py b/backend/app/jobs/live_preflight_provider.py new file mode 100644 index 000000000..af3a3da2b --- /dev/null +++ b/backend/app/jobs/live_preflight_provider.py @@ -0,0 +1,234 @@ +"""Guarded stored-PostgreSQL provider for bounded live preflight only.""" + +from __future__ import annotations + +import asyncio +import math +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import asyncpg + +from app.forward.migration_run import MigrationRunAttemptClaim +from app.jobs.migration_dry_run_worker import ( + LivePreflightExecution, + LivePreflightRequest, + MigrationDryRunWorkerError, + load_guarded_live_preflight_target, + make_durable_dry_run_attempt_handler, +) +from app.jobs.migration_dry_run_worker_contract import ( + IsolatedSandboxFactory, + LivePreflightFactory, + SessionFactory, +) +from app.jobs.migration_run_consumer import ( + MigrationRunAttemptHandler, + MigrationRunHandler, + make_attempt_bound_migration_run_handler, +) +from app.jobs.valkey_queue import MigrationRunSignalClaim +from app.pg_introspect.introspect import ( + capture_postgres_snapshot, + connect_guarded_postgres, +) +from app.security import decrypt_text + +__all__ = [ + "make_stored_postgres_durable_dry_run_attempt_handler", + "make_stored_postgres_live_preflight_factory", + "make_stored_postgres_migration_run_handler", +] + +_CONNECT_TIMEOUT_SECONDS = 10.0 +_MAX_CONNECT_TIMEOUT_SECONDS = 60.0 + + +def _provider_error() -> MigrationDryRunWorkerError: + """Return the one non-reflecting provider acquisition error.""" + + return MigrationDryRunWorkerError( + "migration live-preflight provider failed" + ) + + +def make_stored_postgres_live_preflight_factory( + session_factory: SessionFactory, + *, + connect_timeout_seconds: float = _CONNECT_TIMEOUT_SECONDS, +) -> LivePreflightFactory: + """Compose exact stored metadata with the guarded PostgreSQL connector. + + The returned capability decrypts only after the single-query live-attempt + guard succeeds, pins the connection through the existing DNS/SSRF/TLS + boundary, repeats the exact guarded-target lookup after connection open, + scopes snapshot capture to that exact connection, and always closes it. + It grants no arbitrary-SQL or apply authority and is not wired into + application startup. + """ + + if not callable(session_factory): + raise ValueError("live-preflight session factory is invalid") + if ( + isinstance(connect_timeout_seconds, bool) + or not isinstance(connect_timeout_seconds, (int, float)) + or not math.isfinite(connect_timeout_seconds) + or not 0.0 < connect_timeout_seconds <= _MAX_CONNECT_TIMEOUT_SECONDS + ): + raise ValueError( + "live-preflight connect timeout must be greater than 0 " + "and at most 60 seconds" + ) + bounded_connect_timeout_seconds = float(connect_timeout_seconds) + + @asynccontextmanager + async def stored_postgres_live_preflight( + request: LivePreflightRequest, + ) -> AsyncIterator[LivePreflightExecution]: + try: + async with session_factory() as session: + target = await load_guarded_live_preflight_target( + session, request + ) + dsn = decrypt_text(target.dsn_ciphertext, target.dsn_nonce) + connection = await connect_guarded_postgres( + dsn, timeout=bounded_connect_timeout_seconds + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise _provider_error() from None + + async def capture_exact_connection( + capture_connection: asyncpg.Connection, + ) -> dict: + if capture_connection is not connection: + raise MigrationDryRunWorkerError( + "live-preflight capture connection is invalid" + ) + return await capture_postgres_snapshot( + capture_connection, target.schema_filter + ) + + body_failed = False + try: + try: + async with session_factory() as session: + revalidated_target = ( + await load_guarded_live_preflight_target( + session, request + ) + ) + if revalidated_target != target: + raise _provider_error() + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise _provider_error() from None + + yield LivePreflightExecution( + connection=connection, + capture_snapshot=capture_exact_connection, + ) + except BaseException: + body_failed = True + raise + finally: + try: + await connection.close() + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + if not body_failed: + raise _provider_error() from None + + return stored_postgres_live_preflight + + +def make_stored_postgres_durable_dry_run_attempt_handler( + session_factory: SessionFactory, + sandbox_factory: IsolatedSandboxFactory, + *, + lock_timeout_ms: int = 1_000, + sandbox_statement_timeout_ms: int = 30_000, + preflight_statement_timeout_ms: int = 5_000, + sandbox_stage_timeout_seconds: float = 300.0, + preflight_stage_timeout_seconds: float = 30.0, + connect_timeout_seconds: float = _CONNECT_TIMEOUT_SECONDS, +) -> MigrationRunAttemptHandler: + """Bind the stored-target provider to one durable metadata authority. + + The caller still injects isolated sandbox lifecycle and must explicitly + wire the returned attempt handler into a consumer. The identity check + prevents that consumer from supplying a different session factory for run + state while the live provider resolves credential-bearing target metadata. + This composition grants no startup, arbitrary-SQL, or apply authority. + """ + + live_preflight_factory = make_stored_postgres_live_preflight_factory( + session_factory, + connect_timeout_seconds=connect_timeout_seconds, + ) + durable_handler = make_durable_dry_run_attempt_handler( + sandbox_factory, + live_preflight_factory, + lock_timeout_ms=lock_timeout_ms, + sandbox_statement_timeout_ms=sandbox_statement_timeout_ms, + preflight_statement_timeout_ms=preflight_statement_timeout_ms, + sandbox_stage_timeout_seconds=sandbox_stage_timeout_seconds, + preflight_stage_timeout_seconds=preflight_stage_timeout_seconds, + ) + + async def handle_stored_postgres_attempt( + attempt_session_factory: SessionFactory, + signal_claim: MigrationRunSignalClaim, + attempt_claim: MigrationRunAttemptClaim, + ) -> None: + if attempt_session_factory is not session_factory: + raise MigrationDryRunWorkerError( + "migration dry-run composition is invalid" + ) + await durable_handler( + attempt_session_factory, signal_claim, attempt_claim + ) + + return handle_stored_postgres_attempt + + +def make_stored_postgres_migration_run_handler( + session_factory: SessionFactory, + sandbox_factory: IsolatedSandboxFactory, + *, + worker_identity: str, + attempt_lease_seconds: int = 60, + heartbeat_interval_s: float | None = None, + lock_timeout_ms: int = 1_000, + sandbox_statement_timeout_ms: int = 30_000, + preflight_statement_timeout_ms: int = 5_000, + sandbox_stage_timeout_seconds: float = 300.0, + preflight_stage_timeout_seconds: float = 30.0, + connect_timeout_seconds: float = _CONNECT_TIMEOUT_SECONDS, +) -> MigrationRunHandler: + """Bind the stored dry-run capability to durable attempt leasing. + + The returned execution-neutral handler can be injected into the existing + UUID-only signal consumer. This composition does not start that consumer, + provision a sandbox, accept SQL, or grant apply authority. + """ + + attempt_handler = make_stored_postgres_durable_dry_run_attempt_handler( + session_factory, + sandbox_factory, + lock_timeout_ms=lock_timeout_ms, + sandbox_statement_timeout_ms=sandbox_statement_timeout_ms, + preflight_statement_timeout_ms=preflight_statement_timeout_ms, + sandbox_stage_timeout_seconds=sandbox_stage_timeout_seconds, + preflight_stage_timeout_seconds=preflight_stage_timeout_seconds, + connect_timeout_seconds=connect_timeout_seconds, + ) + return make_attempt_bound_migration_run_handler( + attempt_handler, + worker_identity=worker_identity, + attempt_lease_seconds=attempt_lease_seconds, + heartbeat_interval_s=heartbeat_interval_s, + ) diff --git a/backend/app/jobs/migration_dispatch_relay.py b/backend/app/jobs/migration_dispatch_relay.py new file mode 100644 index 000000000..2c2e9d781 --- /dev/null +++ b/backend/app/jobs/migration_dispatch_relay.py @@ -0,0 +1,82 @@ +"""Publish identifier-only migration outbox claims without execution authority.""" + +from __future__ import annotations + +import asyncio +import datetime as dt +import logging +import math +from collections.abc import Callable + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.forward.migration_run import ( + MigrationDispatchClaim, + claim_one_migration_dispatch, + mark_migration_dispatch_published, +) +from app.jobs.valkey_queue import enqueue_migration_run_signal + +_logger = logging.getLogger(__name__) + + +class MigrationDispatchSignalUnavailable(RuntimeError): + """Raised so the caller-owned transaction rolls an unpublished claim back.""" + + +async def publish_one_migration_dispatch( + session: AsyncSession, + *, + now: dt.datetime | None = None, +) -> MigrationDispatchClaim | None: + """Publish one due run UUID and acknowledge only its exact outbox claim. + + The caller owns the open transaction and must roll it back when this + function raises. The Valkey sorted-set member is the run UUID, so retrying + after an ambiguous acknowledgement is idempotent at the signal layer. This + function neither loads a plan nor starts a worker or SQL execution. + """ + + claim = await claim_one_migration_dispatch(session, now=now) + if claim is None: + return None + if not await enqueue_migration_run_signal(claim.migration_run_uuid, now): + raise MigrationDispatchSignalUnavailable( + "migration dispatch signal unavailable" + ) + if now is None: + await mark_migration_dispatch_published(session, claim=claim) + else: + await mark_migration_dispatch_published(session, claim=claim, now=now) + return claim + + +async def run_migration_dispatch_relay_forever( + session_factory: Callable[[], AsyncSession], + *, + poll_interval_s: float = 1.0, +) -> None: + """Publish due identifier-only outbox rows until lifecycle cancellation. + + Every claim owns a fresh metadata transaction. A successful context exit + commits the exact-attempt acknowledgement; any publication or database + failure exits through rollback before a bounded retry delay. Detailed + exception text is deliberately excluded from logs because drivers and + adapters may include connection strings or uncontrolled target metadata. + """ + + if not math.isfinite(poll_interval_s) or not 0 < poll_interval_s <= 60: + raise ValueError("migration dispatch relay interval must be between 0 and 60") + + while True: + try: + async with session_factory() as session: + async with session.begin(): + claim = await publish_one_migration_dispatch(session) + except Exception: # noqa: BLE001 + _logger.warning("migration_dispatch_relay_iteration_failed") + await asyncio.sleep(poll_interval_s) + continue + + if claim is None: + await asyncio.sleep(poll_interval_s) diff --git a/backend/app/jobs/migration_dry_run_worker.py b/backend/app/jobs/migration_dry_run_worker.py new file mode 100644 index 000000000..552f4b5c4 --- /dev/null +++ b/backend/app/jobs/migration_dry_run_worker.py @@ -0,0 +1,620 @@ +"""Bind durable migration attempts to isolated and read-only capabilities. + +Queue signals remain UUID-only. Concrete sandbox lifecycle, target credential +resolution, route isolation, and application startup remain injected deployment +responsibilities; this module owns only deterministic attempt orchestration. +""" + +from __future__ import annotations + +import asyncio +import datetime as dt +import math +import re +import uuid +from collections.abc import Mapping +from dataclasses import dataclass, field, replace + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.elements import ColumnElement + +from app.forward.isolated_dry_run import ( + MAX_LOCK_TIMEOUT_MS, + MAX_STATEMENT_TIMEOUT_MS as MAX_SANDBOX_STATEMENT_TIMEOUT_MS, + execute_isolated_dry_run, +) +from app.forward.live_preflight import ( + MAX_STATEMENT_TIMEOUT_MS as MAX_PREFLIGHT_STATEMENT_TIMEOUT_MS, + execute_bound_live_preflight, +) +from app.forward.migration_run import ( + MigrationRunAttemptClaim, + MigrationRunTransition, + complete_isolated_dry_run, + complete_live_preflight, + transition_migration_run, +) +from app.jobs.migration_dry_run_worker_contract import ( + IsolatedSandboxExecution, + IsolatedSandboxFactory, + IsolatedSandboxRequest, + LivePreflightExecution, + LivePreflightFactory, + LivePreflightRequest, + MigrationDryRunWorkerError, + SessionFactory, + _MigrationDryRunWork, + _invalid_metadata, + _make_work, +) +from app.jobs.migration_run_consumer import MigrationRunAttemptHandler +from app.jobs.valkey_queue import MigrationRunSignalClaim +from app.models import ( + DbConnection, + MigrationPlan, + MigrationRun, + MigrationRunAttempt, + SchemaSnapshot, +) + +__all__ = [ + "IsolatedSandboxExecution", + "IsolatedSandboxRequest", + "GuardedLivePreflightTarget", + "LivePreflightExecution", + "LivePreflightRequest", + "MigrationDryRunWorkerError", + "guard_live_preflight_handoff", + "load_guarded_live_preflight_target", + "make_durable_dry_run_attempt_handler", +] + +MAX_SANDBOX_STAGE_TIMEOUT_SECONDS = 900.0 +MAX_PREFLIGHT_STAGE_TIMEOUT_SECONDS = 60.0 +_SCHEMA_FILTER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_$]{0,62}") + + +@dataclass(frozen=True) +class GuardedLivePreflightTarget: + """Encrypted target material and snapshot scope from one exact guard.""" + + dsn_ciphertext: bytes = field(repr=False) + dsn_nonce: bytes = field(repr=False) + base_schema_snapshot_uuid: uuid.UUID + schema_filter: str | None = field(repr=False) + + +def _validated_live_preflight_time( + request: LivePreflightRequest, + now: dt.datetime | None, + *, + error_message: str, +) -> dt.datetime: + """Validate exact handoff metadata before any database I/O.""" + + checked_at = now if now is not None else dt.datetime.now(dt.timezone.utc) + uuids = ( + getattr(request, "migration_run_uuid", None), + getattr(request, "migration_plan_uuid", None), + getattr(request, "project_space_uuid", None), + getattr(request, "db_connection_uuid", None), + getattr(request, "migration_run_attempt_uuid", None), + ) + if ( + not isinstance(request, LivePreflightRequest) + or not isinstance(checked_at, dt.datetime) + or checked_at.tzinfo is None + or checked_at.utcoffset() is None + or not all(isinstance(value, uuid.UUID) for value in uuids) + or isinstance(request.attempt_number, bool) + or not isinstance(request.attempt_number, int) + or request.attempt_number < 1 + or isinstance(request.expected_state_version, bool) + or not isinstance(request.expected_state_version, int) + or request.expected_state_version < 1 + ): + raise MigrationDryRunWorkerError(error_message) + return checked_at + + +def _live_preflight_handoff_conditions( + request: LivePreflightRequest, + checked_at: dt.datetime, +) -> tuple[ColumnElement[bool], ...]: + """Return the single canonical exact-attempt live-reader predicate.""" + + return ( + MigrationRunAttempt.migration_run_attempt_uuid + == request.migration_run_attempt_uuid, + MigrationRunAttempt.migration_run_uuid == request.migration_run_uuid, + MigrationRunAttempt.attempt_number == request.attempt_number, + MigrationRunAttempt.status == "active", + MigrationRunAttempt.lease_expires_at > checked_at, + MigrationRun.migration_run_uuid == request.migration_run_uuid, + MigrationRun.migration_plan_uuid == request.migration_plan_uuid, + MigrationRun.project_space_uuid == request.project_space_uuid, + MigrationRun.run_kind == "dry_run", + MigrationRun.state == "live_preflight_running", + MigrationRun.state_version == request.expected_state_version, + MigrationRun.cancellation_requested.is_(False), + MigrationPlan.migration_plan_uuid == request.migration_plan_uuid, + MigrationPlan.project_space_uuid == request.project_space_uuid, + MigrationPlan.db_connection_uuid == request.db_connection_uuid, + MigrationPlan.statement_digest == MigrationRun.plan_digest, + MigrationPlan.expires_at > checked_at, + ) + + +async def guard_live_preflight_handoff( + session: AsyncSession, + request: LivePreflightRequest, + *, + now: dt.datetime | None = None, +) -> None: + """Fail closed unless one fresh query matches the exact live-reader lease. + + Concrete providers can call this server-owned guard immediately before + resolving the stored target. It returns no credential or connection and + does not eliminate the gap between this observation and provider access. + """ + + error_message = "migration live-preflight handoff is invalid" + checked_at = _validated_live_preflight_time( + request, now, error_message=error_message + ) + try: + matched_attempt_uuid = await session.scalar( + select(MigrationRunAttempt.migration_run_attempt_uuid) + .select_from(MigrationRunAttempt) + .join( + MigrationRun, + MigrationRun.migration_run_uuid + == MigrationRunAttempt.migration_run_uuid, + ) + .join( + MigrationPlan, + MigrationPlan.migration_plan_uuid + == MigrationRun.migration_plan_uuid, + ) + .where(*_live_preflight_handoff_conditions(request, checked_at)) + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError(error_message) from None + if matched_attempt_uuid != request.migration_run_attempt_uuid: + raise MigrationDryRunWorkerError(error_message) + + +async def load_guarded_live_preflight_target( + session: AsyncSession, + request: LivePreflightRequest, + *, + now: dt.datetime | None = None, +) -> GuardedLivePreflightTarget: + """Release encrypted target material for one exact active live attempt. + + This performs one metadata statement and deliberately does not decrypt the + DSN, open a target connection, or grant SQL execution authority. + """ + + error_message = "migration live-preflight target is invalid" + checked_at = _validated_live_preflight_time( + request, now, error_message=error_message + ) + try: + result = await session.execute( + select( + DbConnection.dsn_ciphertext, + DbConnection.dsn_nonce, + SchemaSnapshot.schema_snapshot_uuid, + SchemaSnapshot.schema_filter, + ) + .select_from(MigrationRunAttempt) + .join( + MigrationRun, + MigrationRun.migration_run_uuid + == MigrationRunAttempt.migration_run_uuid, + ) + .join( + MigrationPlan, + MigrationPlan.migration_plan_uuid + == MigrationRun.migration_plan_uuid, + ) + .join( + DbConnection, + DbConnection.db_connection_uuid + == MigrationPlan.db_connection_uuid, + ) + .join( + SchemaSnapshot, + SchemaSnapshot.schema_snapshot_uuid + == MigrationPlan.base_schema_snapshot_uuid, + ) + .where( + *_live_preflight_handoff_conditions(request, checked_at), + DbConnection.db_connection_uuid == request.db_connection_uuid, + DbConnection.project_space_uuid == request.project_space_uuid, + SchemaSnapshot.project_space_uuid + == request.project_space_uuid, + SchemaSnapshot.db_connection_uuid + == request.db_connection_uuid, + SchemaSnapshot.status == "succeeded", + SchemaSnapshot.finished_at.is_not(None), + ) + ) + row = result.one_or_none() + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError(error_message) from None + if row is None: + raise MigrationDryRunWorkerError(error_message) + ciphertext, nonce, snapshot_uuid, schema_filter = row + if ( + not isinstance(ciphertext, bytes) + or not ciphertext + or not isinstance(nonce, bytes) + or len(nonce) != 12 + or not isinstance(snapshot_uuid, uuid.UUID) + or ( + schema_filter is not None + and ( + not isinstance(schema_filter, str) + or _SCHEMA_FILTER_RE.fullmatch(schema_filter) is None + ) + ) + ): + raise MigrationDryRunWorkerError(error_message) + return GuardedLivePreflightTarget( + bytes(ciphertext), + bytes(nonce), + snapshot_uuid, + schema_filter, + ) + + +async def _load_and_begin( + session_factory: SessionFactory, + attempt_claim: MigrationRunAttemptClaim, +) -> _MigrationDryRunWork: + """Load exact metadata and durably enter the isolated stage when queued.""" + + transition_time = dt.datetime.now(dt.timezone.utc) + try: + async with session_factory() as session: + async with session.begin(): + run = await session.scalar( + select(MigrationRun) + .where( + MigrationRun.migration_run_uuid + == attempt_claim.migration_run_uuid + ) + .with_for_update() + ) + if run is None: + raise _invalid_metadata() + plan = await session.scalar( + select(MigrationPlan).where( + MigrationPlan.migration_plan_uuid + == run.migration_plan_uuid + ) + ) + if plan is None: + raise _invalid_metadata() + work = _make_work( + run, plan, attempt_claim, now=transition_time + ) + if work.state == "queued": + transition = await transition_migration_run( + session, + migration_run_uuid=work.migration_run_uuid, + expected_state_version=work.state_version, + next_state="sandbox_running", + event_type="sandbox_started", + evidence={ + "attempt_number": work.attempt_number, + "migration_run_attempt_uuid": str( + work.migration_run_attempt_uuid + ), + }, + actor_user_uuid=None, + now=transition_time, + ) + if transition.state != "sandbox_running": + raise _invalid_metadata() + work = replace( + work, + state=transition.state, + state_version=transition.state_version, + ) + return work + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except MigrationDryRunWorkerError: + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError( + "migration dry-run metadata load failed" + ) from None + + +async def _refresh_live_stage( + session_factory: SessionFactory, + attempt_claim: MigrationRunAttemptClaim, + work: _MigrationDryRunWork, +) -> _MigrationDryRunWork: + """Recheck cancellation, plan integrity, and state immediately before target I/O.""" + + refresh_time = dt.datetime.now(dt.timezone.utc) + try: + async with session_factory() as session: + async with session.begin(): + run = await session.scalar( + select(MigrationRun) + .where( + MigrationRun.migration_run_uuid + == work.migration_run_uuid + ) + .with_for_update() + ) + if run is None: + raise _invalid_metadata() + plan = await session.scalar( + select(MigrationPlan).where( + MigrationPlan.migration_plan_uuid + == run.migration_plan_uuid + ) + ) + if plan is None: + raise _invalid_metadata() + refreshed = _make_work( + run, + plan, + attempt_claim, + now=refresh_time, + expected_state_version=work.state_version, + ) + if ( + refreshed.state != "live_preflight_running" + or refreshed.migration_run_uuid != work.migration_run_uuid + or refreshed.migration_plan_uuid != work.migration_plan_uuid + or refreshed.project_space_uuid != work.project_space_uuid + or refreshed.db_connection_uuid != work.db_connection_uuid + or refreshed.base_schema_snapshot_uuid + != work.base_schema_snapshot_uuid + or refreshed.migration_run_attempt_uuid + != work.migration_run_attempt_uuid + or refreshed.attempt_number != work.attempt_number + or refreshed.plan_digest != work.plan_digest + ): + raise _invalid_metadata() + return refreshed + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except MigrationDryRunWorkerError: + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError( + "migration live-preflight metadata refresh failed" + ) from None + + +async def _complete_isolated_stage( + session_factory: SessionFactory, + work: _MigrationDryRunWork, + result: Mapping[str, object], +) -> MigrationRunTransition: + async with session_factory() as session: + async with session.begin(): + return await complete_isolated_dry_run( + session, + migration_run_uuid=work.migration_run_uuid, + expected_state_version=work.state_version, + result=result, + actor_user_uuid=None, + ) + + +async def _complete_live_stage( + session_factory: SessionFactory, + work: _MigrationDryRunWork, + result: Mapping[str, object], +) -> MigrationRunTransition: + async with session_factory() as session: + async with session.begin(): + return await complete_live_preflight( + session, + migration_run_uuid=work.migration_run_uuid, + expected_state_version=work.state_version, + result=result, + actor_user_uuid=None, + ) + + +def _require_timeout(value: int, *, maximum: int, label: str) -> None: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or not 1 <= value <= maximum + ): + raise ValueError(f"{label} is outside the allowed range") + + +def _require_stage_timeout( + value: float, + *, + maximum: float, + label: str, +) -> None: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or not 0 < value <= maximum + ): + raise ValueError(f"{label} is outside the allowed range") + + +def make_durable_dry_run_attempt_handler( + sandbox_factory: IsolatedSandboxFactory, + live_preflight_factory: LivePreflightFactory, + *, + lock_timeout_ms: int = 1_000, + sandbox_statement_timeout_ms: int = 30_000, + preflight_statement_timeout_ms: int = 5_000, + sandbox_stage_timeout_seconds: float = 300.0, + preflight_stage_timeout_seconds: float = 30.0, +) -> MigrationRunAttemptHandler: + """Compose one attempt-bound dry run without concrete credential authority. + + The returned handler is compatible with + ``make_attempt_bound_migration_run_handler``. Concrete sandbox lifecycle, + target credential resolution, route isolation, process-level termination, + and application startup remain injected deployment responsibilities. + Stage timeouts request cooperative task cancellation; they cannot forcibly + terminate a provider that suppresses cancellation inside this process. + """ + + if not callable(sandbox_factory) or not callable(live_preflight_factory): + raise ValueError("migration dry-run capability factory is invalid") + _require_timeout( + lock_timeout_ms, + maximum=MAX_LOCK_TIMEOUT_MS, + label="migration dry-run lock timeout", + ) + _require_timeout( + sandbox_statement_timeout_ms, + maximum=MAX_SANDBOX_STATEMENT_TIMEOUT_MS, + label="migration dry-run statement timeout", + ) + _require_timeout( + preflight_statement_timeout_ms, + maximum=MAX_PREFLIGHT_STATEMENT_TIMEOUT_MS, + label="migration live-preflight statement timeout", + ) + _require_stage_timeout( + sandbox_stage_timeout_seconds, + maximum=MAX_SANDBOX_STAGE_TIMEOUT_SECONDS, + label="migration sandbox stage timeout", + ) + _require_stage_timeout( + preflight_stage_timeout_seconds, + maximum=MAX_PREFLIGHT_STAGE_TIMEOUT_SECONDS, + label="migration preflight stage timeout", + ) + + async def handle_attempt( + session_factory: SessionFactory, + signal_claim: MigrationRunSignalClaim, + attempt_claim: MigrationRunAttemptClaim, + ) -> None: + if signal_claim.migration_run_uuid != attempt_claim.migration_run_uuid: + raise MigrationDryRunWorkerError( + "migration dry-run claim is invalid" + ) + work = await _load_and_begin(session_factory, attempt_claim) + + if work.state == "sandbox_running": + async def execute_sandbox_stage() -> Mapping[str, object]: + async with sandbox_factory(work.sandbox_request()) as sandbox: + if ( + not isinstance(sandbox, IsolatedSandboxExecution) + or not callable(sandbox.capture_snapshot) + ): + raise MigrationDryRunWorkerError( + "isolated dry-run capability is invalid" + ) + return await execute_isolated_dry_run( + sandbox.connection, + work.plan_json, + expected_plan_digest=work.plan_digest, + capture_snapshot=sandbox.capture_snapshot, + lock_timeout_ms=lock_timeout_ms, + statement_timeout_ms=sandbox_statement_timeout_ms, + ) + + try: + isolated_result = await asyncio.wait_for( + execute_sandbox_stage(), + timeout=sandbox_stage_timeout_seconds, + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError( + "isolated dry-run stage failed" + ) from None + try: + transition = await _complete_isolated_stage( + session_factory, work, isolated_result + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError( + "isolated dry-run completion failed" + ) from None + if transition.state != "live_preflight_running": + raise MigrationDryRunWorkerError( + "isolated dry-run completion is invalid" + ) + work = replace( + work, + state=transition.state, + state_version=transition.state_version, + ) + + if work.state != "live_preflight_running": + raise MigrationDryRunWorkerError( + "migration dry-run stage is invalid" + ) + work = await _refresh_live_stage( + session_factory, attempt_claim, work + ) + + async def execute_live_stage() -> Mapping[str, object]: + async with live_preflight_factory( + work.live_preflight_request() + ) as live_target: + if not isinstance( + live_target, LivePreflightExecution + ) or not callable(live_target.capture_snapshot): + raise MigrationDryRunWorkerError( + "live preflight capability is invalid" + ) + return await execute_bound_live_preflight( + live_target.connection, + work.plan_json, + capture_snapshot=live_target.capture_snapshot, + statement_timeout_ms=preflight_statement_timeout_ms, + ) + + try: + preflight_result = await asyncio.wait_for( + execute_live_stage(), + timeout=preflight_stage_timeout_seconds, + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError( + "live preflight stage failed" + ) from None + try: + terminal = await _complete_live_stage( + session_factory, work, preflight_result + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise MigrationDryRunWorkerError( + "live preflight completion failed" + ) from None + if terminal.state not in {"passed", "drifted", "failed"}: + raise MigrationDryRunWorkerError( + "live preflight completion is invalid" + ) + + return handle_attempt diff --git a/backend/app/jobs/migration_dry_run_worker_contract.py b/backend/app/jobs/migration_dry_run_worker_contract.py new file mode 100644 index 000000000..34c1e68bb --- /dev/null +++ b/backend/app/jobs/migration_dry_run_worker_contract.py @@ -0,0 +1,254 @@ +"""Least-authority contracts for durable dry-run worker orchestration.""" + +from __future__ import annotations + +import datetime as dt +import json +import uuid +from collections.abc import Callable, Mapping +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from typing import TypeAlias, cast + +import asyncpg +from sqlalchemy.ext.asyncio import AsyncSession + +from app.forward.isolated_dry_run import ( + IsolatedPostgresConnection, + SnapshotCapture as IsolatedSnapshotCapture, +) +from app.forward.live_preflight import SnapshotCapture as LiveSnapshotCapture +from app.forward.migration_plan import verify_migration_plan_digest +from app.forward.migration_run import MigrationRunAttemptClaim + +SessionFactory: TypeAlias = Callable[[], AsyncSession] + + +class MigrationDryRunWorkerError(RuntimeError): + """Expose one fixed worker-boundary failure without provider details.""" + + +@dataclass(frozen=True) +class IsolatedSandboxRequest: + """Non-secret materialization identity for one disposable sandbox lease.""" + + migration_run_uuid: uuid.UUID + migration_plan_uuid: uuid.UUID + project_space_uuid: uuid.UUID + base_schema_snapshot_uuid: uuid.UUID + migration_run_attempt_uuid: uuid.UUID + postgresql_major: int + base_digest: str + attempt_number: int + + +@dataclass(frozen=True) +class LivePreflightRequest: + """Stored target identity for one separately constrained read-only lease.""" + + migration_run_uuid: uuid.UUID + migration_plan_uuid: uuid.UUID + project_space_uuid: uuid.UUID + db_connection_uuid: uuid.UUID + migration_run_attempt_uuid: uuid.UUID + attempt_number: int + expected_state_version: int + + +@dataclass(frozen=True) +class IsolatedSandboxExecution: + """Already-provisioned isolated connection plus same-sandbox introspection.""" + + connection: IsolatedPostgresConnection + capture_snapshot: IsolatedSnapshotCapture + + +@dataclass(frozen=True) +class LivePreflightExecution: + """Already-authorized read-only target connection plus fresh introspection.""" + + connection: asyncpg.Connection + capture_snapshot: LiveSnapshotCapture + + +IsolatedSandboxFactory: TypeAlias = Callable[ + [IsolatedSandboxRequest], + AbstractAsyncContextManager[IsolatedSandboxExecution], +] +LivePreflightFactory: TypeAlias = Callable[ + [LivePreflightRequest], + AbstractAsyncContextManager[LivePreflightExecution], +] + + +@dataclass(frozen=True) +class _MigrationDryRunWork: + migration_run_uuid: uuid.UUID + migration_plan_uuid: uuid.UUID + project_space_uuid: uuid.UUID + db_connection_uuid: uuid.UUID + base_schema_snapshot_uuid: uuid.UUID + migration_run_attempt_uuid: uuid.UUID + attempt_number: int + state: str + state_version: int + postgresql_major: int + base_digest: str + target_digest: str + plan_digest: str + plan_json: Mapping[str, object] + + def sandbox_request(self) -> IsolatedSandboxRequest: + """Return the least-authority input needed to lease a sandbox.""" + + return IsolatedSandboxRequest( + migration_run_uuid=self.migration_run_uuid, + migration_plan_uuid=self.migration_plan_uuid, + project_space_uuid=self.project_space_uuid, + base_schema_snapshot_uuid=self.base_schema_snapshot_uuid, + migration_run_attempt_uuid=self.migration_run_attempt_uuid, + postgresql_major=self.postgresql_major, + base_digest=self.base_digest, + attempt_number=self.attempt_number, + ) + + def live_preflight_request(self) -> LivePreflightRequest: + """Return the identifier-only input needed to lease a live reader.""" + + return LivePreflightRequest( + migration_run_uuid=self.migration_run_uuid, + migration_plan_uuid=self.migration_plan_uuid, + project_space_uuid=self.project_space_uuid, + db_connection_uuid=self.db_connection_uuid, + migration_run_attempt_uuid=self.migration_run_attempt_uuid, + attempt_number=self.attempt_number, + expected_state_version=self.state_version, + ) + + +def _invalid_metadata() -> MigrationDryRunWorkerError: + return MigrationDryRunWorkerError( + "migration dry-run metadata contract is invalid" + ) + + +def _copy_plan_json(value: object) -> dict[str, object]: + if not isinstance(value, Mapping): + raise _invalid_metadata() + try: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + copied = json.loads(encoded) + except (TypeError, ValueError, OverflowError): + raise _invalid_metadata() from None + if not isinstance(copied, dict): + raise _invalid_metadata() + return cast(dict[str, object], copied) + + +def _make_work( + run: object, + plan: object, + attempt_claim: MigrationRunAttemptClaim, + *, + now: dt.datetime, + expected_state_version: int | None = None, +) -> _MigrationDryRunWork: + """Validate one attempt-bound metadata snapshot before external I/O.""" + + if now.tzinfo is None or now.utcoffset() is None: + raise _invalid_metadata() + plan_json = _copy_plan_json(getattr(plan, "plan_json", None)) + required_state_version = ( + attempt_claim.acquired_state_version + if expected_state_version is None + else expected_state_version + ) + if ( + isinstance(required_state_version, bool) + or not isinstance(required_state_version, int) + or required_state_version < 1 + ): + raise _invalid_metadata() + statement_digest = getattr(plan, "statement_digest", None) + try: + digest_valid = isinstance( + statement_digest, str + ) and verify_migration_plan_digest(plan_json, statement_digest) + except Exception: # noqa: BLE001 + digest_valid = False + expires_at = getattr(plan, "expires_at", None) + postgresql_major = plan_json.get("postgresql_major") + invalid = ( + not isinstance( + getattr(attempt_claim, "migration_run_attempt_uuid", None), uuid.UUID + ) + or not isinstance( + getattr(attempt_claim, "migration_run_uuid", None), uuid.UUID + ) + or getattr(run, "migration_run_uuid", None) + != attempt_claim.migration_run_uuid + or getattr(run, "run_kind", None) != "dry_run" + or getattr(run, "state", None) + not in {"queued", "sandbox_running", "live_preflight_running"} + or getattr(run, "cancellation_requested", None) is not False + or isinstance(getattr(run, "state_version", None), bool) + or not isinstance(getattr(run, "state_version", None), int) + or getattr(run, "state_version", 0) < 1 + or getattr(run, "state_version", None) != required_state_version + or isinstance(attempt_claim.attempt_number, bool) + or not isinstance(attempt_claim.attempt_number, int) + or attempt_claim.attempt_number < 1 + or not isinstance(getattr(run, "project_space_uuid", None), uuid.UUID) + or not isinstance(getattr(run, "migration_plan_uuid", None), uuid.UUID) + or getattr(plan, "migration_plan_uuid", None) + != getattr(run, "migration_plan_uuid", None) + or getattr(plan, "project_space_uuid", None) + != getattr(run, "project_space_uuid", None) + or not isinstance(getattr(plan, "db_connection_uuid", None), uuid.UUID) + or not isinstance( + getattr(plan, "base_schema_snapshot_uuid", None), uuid.UUID + ) + or getattr(run, "plan_digest", None) + != getattr(plan, "statement_digest", None) + or not digest_valid + or not isinstance(expires_at, dt.datetime) + or expires_at.tzinfo is None + or expires_at.utcoffset() is None + or expires_at <= now + or isinstance(postgresql_major, bool) + or not isinstance(postgresql_major, int) + or not 14 <= postgresql_major <= 18 + or plan_json.get("compiler_version") + != getattr(plan, "compiler_version", None) + or plan_json.get("base_digest") != getattr(plan, "base_digest", None) + or plan_json.get("target_digest") != getattr(plan, "target_digest", None) + or plan_json.get("plan_digest") + != getattr(plan, "statement_digest", None) + or plan_json.get("can_dry_run") is not True + or plan_json.get("blockers") != [] + ) + if invalid: + raise _invalid_metadata() + validated_postgresql_major = cast(int, postgresql_major) + return _MigrationDryRunWork( + migration_run_uuid=attempt_claim.migration_run_uuid, + migration_plan_uuid=getattr(run, "migration_plan_uuid"), + project_space_uuid=getattr(run, "project_space_uuid"), + db_connection_uuid=getattr(plan, "db_connection_uuid"), + base_schema_snapshot_uuid=getattr(plan, "base_schema_snapshot_uuid"), + migration_run_attempt_uuid=attempt_claim.migration_run_attempt_uuid, + attempt_number=attempt_claim.attempt_number, + state=getattr(run, "state"), + state_version=getattr(run, "state_version"), + postgresql_major=validated_postgresql_major, + base_digest=getattr(plan, "base_digest"), + target_digest=getattr(plan, "target_digest"), + plan_digest=getattr(plan, "statement_digest"), + plan_json=plan_json, + ) diff --git a/backend/app/jobs/migration_run_consumer.py b/backend/app/jobs/migration_run_consumer.py new file mode 100644 index 000000000..85b95d4a5 --- /dev/null +++ b/backend/app/jobs/migration_run_consumer.py @@ -0,0 +1,559 @@ +"""Consume UUID-only migration-run signals without execution authority. + +The consumer owns only Valkey lease completion and retry cadence. An injected +handler receives the exact signal claim and remains responsible for loading +durable metadata, enforcing optimistic state transitions, and eventually +performing an isolated dry run. Keeping that boundary explicit prevents queue +payloads from becoming plan, credential, or SQL authority. +""" + +from __future__ import annotations + +import datetime as dt +import logging +import math +from asyncio import FIRST_COMPLETED, create_task, gather, sleep, wait +from collections.abc import Awaitable, Callable +from typing import TypeAlias + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.forward.migration_run import ( + APPLY_RUN_STATES, + DRY_RUN_STATES, + MAX_MIGRATION_ATTEMPT_LEASE_SECONDS, + MigrationRunAttemptClaim, + MigrationRunContractError, + acquire_migration_run_attempt, + finish_migration_run_attempt, + renew_migration_run_attempt, + transition_migration_run, +) +from app.jobs.valkey_queue import ( + MigrationRunSignalClaim, + ack_migration_run_signal, + claim_due_migration_run_signal, + release_migration_run_signal, + renew_migration_run_signal, +) +from app.models import MigrationRun, MigrationRunAttempt +from app.settings import settings + +_logger = logging.getLogger(__name__) + +_ACTIVE_DRY_RUN_STATES = frozenset( + {"queued", "sandbox_running", "live_preflight_running"} +) +_ACTIVE_APPLY_RUN_STATES = frozenset( + {"queued", "applying", "reconciling", "verifying"} +) + +MigrationRunHandler: TypeAlias = Callable[ + [Callable[[], AsyncSession], MigrationRunSignalClaim], Awaitable[None] +] +MigrationRunAttemptHandler: TypeAlias = Callable[ + [ + Callable[[], AsyncSession], + MigrationRunSignalClaim, + MigrationRunAttemptClaim, + ], + Awaitable[None], +] + + +class MigrationRunConsumerError(RuntimeError): + """Report a fixed consumer failure without carrying handler details.""" + + +class MigrationRunSignalLeaseLost(MigrationRunConsumerError): + """Report that exact signal-lease completion no longer belongs to this worker.""" + + +class MigrationRunAttemptLeaseLost(MigrationRunConsumerError): + """Report that the DB-durable attempt no longer belongs to this worker.""" + + +class MigrationRunAttemptHandlerError(MigrationRunConsumerError): + """Replace attempt-handler failures with one fixed non-secret error.""" + + +def _validate_interval(value: float, *, label: str, maximum: float) -> None: + if not math.isfinite(value) or not 0 < value <= maximum: + raise ValueError( + f"migration run consumer {label} must be between 0 and {maximum:g}" + ) + + +async def _acquire_attempt( + session_factory: Callable[[], AsyncSession], + signal_claim: MigrationRunSignalClaim, + *, + worker_identity: str, + lease_seconds: int, +) -> MigrationRunAttemptClaim: + """Commit one exact attempt acquisition in its own metadata transaction.""" + + async with session_factory() as session: + async with session.begin(): + return await acquire_migration_run_attempt( + session, + migration_run_uuid=signal_claim.migration_run_uuid, + worker_identity=worker_identity, + signal_lease_token=signal_claim.lease_token, + lease_seconds=lease_seconds, + ) + + +async def _settle_non_executable_run( + session_factory: Callable[[], AsyncSession], + signal_claim: MigrationRunSignalClaim, +) -> bool: + """Settle cancellation or terminal redelivery without replaying work.""" + + async with session_factory() as session: + async with session.begin(): + run = await session.scalar( + select(MigrationRun) + .where( + MigrationRun.migration_run_uuid + == signal_claim.migration_run_uuid + ) + .with_for_update() + ) + if run is None: + return False + states = ( + DRY_RUN_STATES + if run.run_kind == "dry_run" + else APPLY_RUN_STATES + if run.run_kind == "apply" + else frozenset() + ) + active_states = ( + _ACTIVE_DRY_RUN_STATES + if run.run_kind == "dry_run" + else _ACTIVE_APPLY_RUN_STATES + if run.run_kind == "apply" + else frozenset() + ) + is_terminal = run.state in states and run.state not in active_states + can_acknowledge_cancellation = ( + run.cancellation_requested is True + and ( + run.run_kind == "dry_run" + or (run.run_kind == "apply" and run.state == "queued") + ) + ) + if not is_terminal and not can_acknowledge_cancellation: + return False + active_attempt = await session.scalar( + select(MigrationRunAttempt) + .where( + MigrationRunAttempt.migration_run_uuid + == run.migration_run_uuid, + MigrationRunAttempt.status == "active", + ) + .with_for_update() + .limit(1) + ) + if active_attempt is not None: + abandoned_at = dt.datetime.now(dt.timezone.utc) + active_attempt.status = "abandoned" + active_attempt.finished_at = max( + abandoned_at, active_attempt.last_heartbeat_at + ) + if is_terminal: + return True + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=run.state_version, + next_state="cancelled", + event_type="cancellation_acknowledged", + evidence={}, + actor_user_uuid=None, + ) + return True + + +async def _finish_attempt( + session_factory: Callable[[], AsyncSession], + signal_claim: MigrationRunSignalClaim, + attempt_claim: MigrationRunAttemptClaim, + *, + worker_identity: str, + succeeded: bool, +) -> bool: + """Commit one exact completion without retaining handler-owned details.""" + + async with session_factory() as session: + async with session.begin(): + return await finish_migration_run_attempt( + session, + claim=attempt_claim, + worker_identity=worker_identity, + signal_lease_token=signal_claim.lease_token, + succeeded=succeeded, + ) + + +async def _attempt_handler_succeeded_without_retaining_error( + handler: MigrationRunAttemptHandler, + session_factory: Callable[[], AsyncSession], + signal_claim: MigrationRunSignalClaim, + attempt_claim: MigrationRunAttemptClaim, +) -> bool: + """Discard execution errors before constructing the fixed lifecycle error.""" + + try: + await handler(session_factory, signal_claim, attempt_claim) + except Exception: # noqa: BLE001 + return False + return True + + +async def _renew_attempt_until_cancelled( + session_factory: Callable[[], AsyncSession], + signal_claim: MigrationRunSignalClaim, + attempt_claim: MigrationRunAttemptClaim, + *, + worker_identity: str, + heartbeat_interval_s: float, + lease_seconds: int, +) -> None: + """Renew the exact durable owner using fresh committed transactions.""" + + while True: + await sleep(heartbeat_interval_s) + async with session_factory() as session: + async with session.begin(): + renewed = await renew_migration_run_attempt( + session, + claim=attempt_claim, + worker_identity=worker_identity, + signal_lease_token=signal_claim.lease_token, + lease_seconds=lease_seconds, + ) + if not renewed: + return + + +async def _run_attempt_handler_under_exact_lease( + handler: MigrationRunAttemptHandler, + session_factory: Callable[[], AsyncSession], + signal_claim: MigrationRunSignalClaim, + attempt_claim: MigrationRunAttemptClaim, + *, + worker_identity: str, + heartbeat_interval_s: float, + lease_seconds: int, +) -> bool: + """Cancel attempt execution as soon as durable ownership is lost.""" + + handler_task = create_task( + _attempt_handler_succeeded_without_retaining_error( + handler, session_factory, signal_claim, attempt_claim + ) + ) + heartbeat_task = create_task( + _renew_attempt_until_cancelled( + session_factory, + signal_claim, + attempt_claim, + worker_identity=worker_identity, + heartbeat_interval_s=heartbeat_interval_s, + lease_seconds=lease_seconds, + ) + ) + try: + done, _ = await wait( + {handler_task, heartbeat_task}, + return_when=FIRST_COMPLETED, + ) + if handler_task not in done: + handler_task.cancel() + await gather(handler_task, return_exceptions=True) + await gather(heartbeat_task, return_exceptions=True) + raise MigrationRunAttemptLeaseLost( + "migration run attempt renewal ended without handler completion" + ) from None + heartbeat_task.cancel() + await gather(heartbeat_task, return_exceptions=True) + return handler_task.result() + finally: + for task in (handler_task, heartbeat_task): + if not task.done(): + task.cancel() + await gather(handler_task, heartbeat_task, return_exceptions=True) + + +def make_attempt_bound_migration_run_handler( + handler: MigrationRunAttemptHandler, + *, + worker_identity: str, + attempt_lease_seconds: int = 60, + heartbeat_interval_s: float | None = None, +) -> MigrationRunHandler: + """Bind one injected executor to both signal and durable attempt ownership. + + The returned handler remains execution-neutral: it accepts no connection, + credential, plan, statement, or target data. It commits acquisition before + calling the injected executor, renews with fresh transactions, and records + exact-owner completion before the outer consumer may acknowledge Valkey. + """ + + if ( + isinstance(attempt_lease_seconds, bool) + or not isinstance(attempt_lease_seconds, int) + or not 1 + <= attempt_lease_seconds + <= MAX_MIGRATION_ATTEMPT_LEASE_SECONDS + ): + raise ValueError( + "migration run consumer attempt lease must be between 1 and " + f"{MAX_MIGRATION_ATTEMPT_LEASE_SECONDS}" + ) + heartbeat = ( + attempt_lease_seconds / 3 + if heartbeat_interval_s is None + else heartbeat_interval_s + ) + _validate_interval( + heartbeat, + label="attempt heartbeat interval", + maximum=attempt_lease_seconds, + ) + if heartbeat >= attempt_lease_seconds: + raise ValueError( + "migration run consumer attempt heartbeat interval must be shorter " + "than lease" + ) + + async def attempt_bound_handler( + session_factory: Callable[[], AsyncSession], + signal_claim: MigrationRunSignalClaim, + ) -> None: + try: + attempt_claim = await _acquire_attempt( + session_factory, + signal_claim, + worker_identity=worker_identity, + lease_seconds=attempt_lease_seconds, + ) + except MigrationRunContractError: + if await _settle_non_executable_run( + session_factory, signal_claim + ): + return + raise + try: + succeeded = await _run_attempt_handler_under_exact_lease( + handler, + session_factory, + signal_claim, + attempt_claim, + worker_identity=worker_identity, + heartbeat_interval_s=heartbeat, + lease_seconds=attempt_lease_seconds, + ) + except BaseException: + try: + await _finish_attempt( + session_factory, + signal_claim, + attempt_claim, + worker_identity=worker_identity, + succeeded=False, + ) + except Exception: # noqa: BLE001 + _logger.warning("migration_run_attempt_abandon_failed") + raise + + if not await _finish_attempt( + session_factory, + signal_claim, + attempt_claim, + worker_identity=worker_identity, + succeeded=succeeded, + ): + raise MigrationRunAttemptLeaseLost( + "migration run attempt completion lost its exact lease" + ) + if not succeeded: + raise MigrationRunAttemptHandlerError( + "migration run attempt handler failed" + ) + + return attempt_bound_handler + + +async def _handler_succeeded_without_retaining_error( + handler: MigrationRunHandler, + session_factory: Callable[[], AsyncSession], + claim: MigrationRunSignalClaim, +) -> bool: + """Discard handler exceptions before the fixed public error is created.""" + + try: + await handler(session_factory, claim) + except Exception: # noqa: BLE001 + return False + return True + + +async def _renew_claim_until_cancelled( + claim: MigrationRunSignalClaim, + *, + heartbeat_interval_s: float, + lease_seconds: float, +) -> None: + """Keep one exact claim live until cancelled or ownership is lost.""" + + while True: + await sleep(heartbeat_interval_s) + if not await renew_migration_run_signal( + claim, lease_seconds=lease_seconds + ): + return + + +async def _run_handler_under_exact_lease( + handler: MigrationRunHandler, + session_factory: Callable[[], AsyncSession], + claim: MigrationRunSignalClaim, + *, + heartbeat_interval_s: float, + lease_seconds: float, +) -> bool: + """Cancel handler authority immediately when exact renewal is lost.""" + + handler_task = create_task( + _handler_succeeded_without_retaining_error(handler, session_factory, claim) + ) + heartbeat_task = create_task( + _renew_claim_until_cancelled( + claim, + heartbeat_interval_s=heartbeat_interval_s, + lease_seconds=lease_seconds, + ) + ) + try: + done, _ = await wait( + {handler_task, heartbeat_task}, + return_when=FIRST_COMPLETED, + ) + if handler_task not in done: + handler_task.cancel() + await gather(handler_task, return_exceptions=True) + await gather(heartbeat_task, return_exceptions=True) + raise MigrationRunSignalLeaseLost( + "migration run renewal ended without handler completion" + ) from None + heartbeat_task.cancel() + await gather(heartbeat_task, return_exceptions=True) + return handler_task.result() + finally: + for task in (handler_task, heartbeat_task): + if not task.done(): + task.cancel() + await gather(handler_task, heartbeat_task, return_exceptions=True) + + +async def process_one_migration_run_signal( + session_factory: Callable[[], AsyncSession], + handler: MigrationRunHandler, + *, + now: dt.datetime | None = None, + retry_delay_s: float = 5.0, + lease_seconds: float | None = None, + heartbeat_interval_s: float | None = None, +) -> bool: + """Process one exact signal lease and return whether work was claimed. + + The handler receives the exact claim rather than an unbound UUID. A + successful handler must win exact-lease acknowledgement. A failed handler + receives no acknowledgement and the same exact lease is released at a + bounded future score. Handler exceptions are deliberately replaced with a + fixed error so DSNs, SQL, credentials, or target data cannot escape through + lifecycle logs. + """ + + _validate_interval(retry_delay_s, label="retry delay", maximum=3600) + duration = ( + settings.migration_run_signal_lease_seconds + if lease_seconds is None + else lease_seconds + ) + _validate_interval(duration, label="lease", maximum=3600) + heartbeat = duration / 3 if heartbeat_interval_s is None else heartbeat_interval_s + _validate_interval( + heartbeat, + label="heartbeat interval", + maximum=duration, + ) + if heartbeat >= duration: + raise ValueError( + "migration run consumer heartbeat interval must be shorter than lease" + ) + current = now or dt.datetime.now(dt.timezone.utc) + if current.tzinfo is None or current.utcoffset() is None: + raise ValueError("migration run consumer time must include a timezone") + + claim = await claim_due_migration_run_signal( + now=current, lease_seconds=duration + ) + if claim is None: + return False + + if not await _run_handler_under_exact_lease( + handler, + session_factory, + claim, + heartbeat_interval_s=heartbeat, + lease_seconds=duration, + ): + retry_at = current + dt.timedelta(seconds=retry_delay_s) + if not await release_migration_run_signal(claim, retry_at): + raise MigrationRunSignalLeaseLost( + "migration run retry release lost its exact lease" + ) + raise MigrationRunConsumerError("migration run handler failed") + + if not await ack_migration_run_signal(claim): + raise MigrationRunSignalLeaseLost( + "migration run acknowledgement lost its exact lease" + ) + return True + + +async def run_migration_run_consumer_forever( + session_factory: Callable[[], AsyncSession], + handler: MigrationRunHandler, + *, + poll_interval_s: float = 1.0, + retry_delay_s: float = 5.0, +) -> None: + """Run the injected migration handler until lifecycle cancellation. + + This function is intentionally not wired into application startup until a + compatible isolated-dry-run handler exists. Cancellation is not caught; + every other iteration failure emits only a stable non-secret code and + waits before another claim. + """ + + _validate_interval(poll_interval_s, label="poll interval", maximum=60) + _validate_interval(retry_delay_s, label="retry delay", maximum=3600) + while True: + try: + processed = await process_one_migration_run_signal( + session_factory, + handler, + retry_delay_s=retry_delay_s, + ) + except Exception: # noqa: BLE001 + _logger.warning("migration_run_consumer_iteration_failed") + await sleep(poll_interval_s) + continue + if not processed: + await sleep(poll_interval_s) diff --git a/backend/app/jobs/valkey_queue.py b/backend/app/jobs/valkey_queue.py index 2f2eb4f5d..f7a7f30fb 100644 --- a/backend/app/jobs/valkey_queue.py +++ b/backend/app/jobs/valkey_queue.py @@ -3,8 +3,10 @@ import datetime as dt import importlib import logging +import math import uuid from collections.abc import Iterable +from dataclasses import dataclass from typing import Any from app.settings import settings @@ -22,11 +24,82 @@ return nil """ +MAX_EXPIRED_SIGNAL_RECLAIMS = 100 + +_CLAIM_MIGRATION_RUN_SIGNAL_SCRIPT = """ +local expired = redis.call( + 'ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[1], 'LIMIT', 0, ARGV[4] +) +for _, id in ipairs(expired) do + redis.call('ZREM', KEYS[2], id) + redis.call('HDEL', KEYS[3], id) + redis.call('ZADD', KEYS[1], ARGV[1], id) +end + +local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1) +if #ids == 0 then + return nil +end +if redis.call('ZREM', KEYS[1], ids[1]) ~= 1 then + return nil +end +redis.call('ZADD', KEYS[2], ARGV[2], ids[1]) +redis.call('HSET', KEYS[3], ids[1], ARGV[3]) +return ids[1] +""" + +_ACK_MIGRATION_RUN_SIGNAL_SCRIPT = """ +if redis.call('HGET', KEYS[2], ARGV[1]) ~= ARGV[2] then + return 0 +end +local removed = redis.call('ZREM', KEYS[1], ARGV[1]) +redis.call('HDEL', KEYS[2], ARGV[1]) +return removed +""" + +_RENEW_MIGRATION_RUN_SIGNAL_SCRIPT = """ +if redis.call('HGET', KEYS[2], ARGV[1]) ~= ARGV[2] then + return 0 +end +local current_expiry = redis.call('ZSCORE', KEYS[1], ARGV[1]) +if not current_expiry then + return 0 +end +if tonumber(current_expiry) <= tonumber(ARGV[3]) then + return 0 +end +if tonumber(ARGV[4]) > tonumber(current_expiry) then + redis.call('ZADD', KEYS[1], ARGV[4], ARGV[1]) +end +return 1 +""" + +_RELEASE_MIGRATION_RUN_SIGNAL_SCRIPT = """ +if redis.call('HGET', KEYS[3], ARGV[1]) ~= ARGV[2] then + return 0 +end +if redis.call('ZREM', KEYS[2], ARGV[1]) ~= 1 then + redis.call('HDEL', KEYS[3], ARGV[1]) + return 0 +end +redis.call('HDEL', KEYS[3], ARGV[1]) +redis.call('ZADD', KEYS[1], ARGV[3], ARGV[1]) +return 1 +""" + class ValkeyQueueUnavailable(RuntimeError): """Raised when Valkey is selected but the Python client is unavailable.""" +@dataclass(frozen=True) +class MigrationRunSignalClaim: + """One UUID-only signal claim bound to an opaque, exact lease token.""" + + migration_run_uuid: uuid.UUID + lease_token: uuid.UUID + + def _parse_sentinel_hosts(raw: str | None) -> list[tuple[str, int]]: """Parse VALKEY_SENTINEL_HOSTS as comma-separated host:port entries.""" @@ -74,6 +147,13 @@ def valkey_queue_config_summary() -> dict[str, object]: "enabled": valkey_queue_enabled(), "mode": valkey_queue_mode(), "queue_key": settings.valkey_queue_key, + "migration_run_queue_key": settings.valkey_migration_run_queue_key, + "migration_run_processing_key": ( + settings.valkey_migration_run_processing_key + ), + "migration_run_signal_lease_seconds": ( + settings.migration_run_signal_lease_seconds + ), "sentinel_master": settings.valkey_sentinel_master, "sentinel_count": len(sentinel_hosts), "lock_ttl_seconds": settings.valkey_lock_ttl_seconds, @@ -140,31 +220,243 @@ async def enqueue_job_signal( await _close_client(client) -async def pop_due_job_signal( +async def enqueue_migration_run_signal( + migration_run_uuid: uuid.UUID, + run_after: dt.datetime | None = None, +) -> bool: + """Publish only one migration-run UUID on its isolated Valkey key.""" + + if not valkey_queue_enabled(): + return False + + _validate_migration_signal_keys() + due_at = run_after or dt.datetime.now(dt.timezone.utc) + if due_at.tzinfo is None or due_at.utcoffset() is None: + raise ValueError("migration run signal time must include a timezone") + client: Any | None = None + try: + client = await _client() + await client.zadd( + settings.valkey_migration_run_queue_key, + {str(migration_run_uuid): due_at.timestamp()}, + ) + return True + except Exception: # noqa: BLE001 + _logger.warning("Valkey migration-run enqueue signal failed", exc_info=True) + return False + finally: + if client is not None: + await _close_client(client) + + +def _require_aware_migration_signal_time(value: dt.datetime) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("migration run signal time must include a timezone") + + +def _validate_migration_signal_keys() -> None: + keys = ( + settings.valkey_queue_key, + settings.valkey_migration_run_queue_key, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + ) + if any(not key for key in keys) or len(set(keys)) != len(keys): + raise ValueError("Valkey queue and migration signal keys must be distinct") + + +async def claim_due_migration_run_signal( + *, now: dt.datetime | None = None, -) -> uuid.UUID | None: - """Pop one due job ID from Valkey, if the optional backend is configured.""" + lease_seconds: float | None = None, +) -> MigrationRunSignalClaim | None: + """Atomically reclaim expired leases and claim one due run UUID. + + The ready sorted-set payload remains only ``migration_run_uuid``. The + consumer-generated lease token is stored on the isolated processing side + and must match for acknowledgement or retry release, preventing a stale + worker from completing a successor lease. This primitive does not load a + plan, credentials, target metadata, or SQL. + """ if not valkey_queue_enabled(): return None - + _validate_migration_signal_keys() current = now or dt.datetime.now(dt.timezone.utc) + _require_aware_migration_signal_time(current) + duration = ( + settings.migration_run_signal_lease_seconds + if lease_seconds is None + else lease_seconds + ) + if not math.isfinite(duration) or not 0 < duration <= 3600: + raise ValueError("migration run signal lease must be between 0 and 3600") + lease_token = uuid.uuid4() client: Any | None = None try: client = await _client() value = await client.eval( - _POP_DUE_JOB_SCRIPT, - 1, - settings.valkey_queue_key, + _CLAIM_MIGRATION_RUN_SIGNAL_SCRIPT, + 3, + settings.valkey_migration_run_queue_key, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, current.timestamp(), + current.timestamp() + duration, + str(lease_token), + MAX_EXPIRED_SIGNAL_RECLAIMS, ) + if value is None: + return None + try: + text_value = ( + value.decode("utf-8") if isinstance(value, bytes) else str(value) + ) + run_uuid = uuid.UUID(text_value) + except (UnicodeDecodeError, ValueError): + await client.eval( + _ACK_MIGRATION_RUN_SIGNAL_SCRIPT, + 2, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + value if isinstance(value, bytes) else str(value), + str(lease_token), + ) + _logger.warning("valkey_migration_signal_invalid_uuid") + return None + return MigrationRunSignalClaim(run_uuid, lease_token) except Exception: # noqa: BLE001 - _logger.warning("Valkey job pop signal failed", exc_info=True) + _logger.warning("valkey_migration_signal_claim_failed") return None finally: if client is not None: await _close_client(client) + +async def ack_migration_run_signal(claim: MigrationRunSignalClaim) -> bool: + """Acknowledge only the currently leased instance of one run signal.""" + + if not valkey_queue_enabled(): + return False + _validate_migration_signal_keys() + client: Any | None = None + try: + client = await _client() + removed = await client.eval( + _ACK_MIGRATION_RUN_SIGNAL_SCRIPT, + 2, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + str(claim.migration_run_uuid), + str(claim.lease_token), + ) + return int(removed) == 1 + except Exception: # noqa: BLE001 + _logger.warning("valkey_migration_signal_ack_failed") + return False + finally: + if client is not None: + await _close_client(client) + + +async def renew_migration_run_signal( + claim: MigrationRunSignalClaim, + *, + now: dt.datetime | None = None, + lease_seconds: float | None = None, +) -> bool: + """Extend only the exact active lease without shortening its expiry.""" + + if not valkey_queue_enabled(): + return False + _validate_migration_signal_keys() + current = now or dt.datetime.now(dt.timezone.utc) + _require_aware_migration_signal_time(current) + duration = ( + settings.migration_run_signal_lease_seconds + if lease_seconds is None + else lease_seconds + ) + if not math.isfinite(duration) or not 0 < duration <= 3600: + raise ValueError("migration run signal lease must be between 0 and 3600") + client: Any | None = None + try: + client = await _client() + renewed = await client.eval( + _RENEW_MIGRATION_RUN_SIGNAL_SCRIPT, + 2, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + str(claim.migration_run_uuid), + str(claim.lease_token), + current.timestamp(), + current.timestamp() + duration, + ) + return int(renewed) == 1 + except Exception: # noqa: BLE001 + _logger.warning("valkey_migration_signal_renew_failed") + return False + finally: + if client is not None: + await _close_client(client) + + +async def release_migration_run_signal( + claim: MigrationRunSignalClaim, + retry_at: dt.datetime, +) -> bool: + """Return only an exact active lease to the UUID-only ready queue.""" + + if not valkey_queue_enabled(): + return False + _validate_migration_signal_keys() + _require_aware_migration_signal_time(retry_at) + client: Any | None = None + try: + client = await _client() + released = await client.eval( + _RELEASE_MIGRATION_RUN_SIGNAL_SCRIPT, + 3, + settings.valkey_migration_run_queue_key, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + str(claim.migration_run_uuid), + str(claim.lease_token), + retry_at.timestamp(), + ) + return int(released) == 1 + except Exception: # noqa: BLE001 + _logger.warning("valkey_migration_signal_release_failed") + return False + finally: + if client is not None: + await _close_client(client) + + +async def pop_due_job_signal( + now: dt.datetime | None = None, +) -> uuid.UUID | None: + """Pop one due job ID from Valkey, if the optional backend is configured.""" + + if not valkey_queue_enabled(): + return None + + current = now or dt.datetime.now(dt.timezone.utc) + try: + client = await _client() + try: + value = await client.eval( + _POP_DUE_JOB_SCRIPT, + 1, + settings.valkey_queue_key, + current.timestamp(), + ) + finally: + await _close_client(client) + except Exception: # noqa: BLE001 + _logger.warning("Valkey job pop signal failed", exc_info=True) + return None + if value is None: return None if isinstance(value, bytes): diff --git a/backend/app/jobs/worker.py b/backend/app/jobs/worker.py index 29eec7e38..06edd23a7 100644 --- a/backend/app/jobs/worker.py +++ b/backend/app/jobs/worker.py @@ -26,6 +26,20 @@ _logger = logging.getLogger(__name__) Handler: TypeAlias = Callable[[Callable[[], AsyncSession], JobQueue], Awaitable[None]] +JOB_HANDLER_UNAVAILABLE = "job_handler_unavailable" +JOB_HANDLER_FAILED = "job_handler_failed" + + +def sanitize_job_error_message(_error: BaseException) -> str: + """Return fixed durable evidence without serializing an exception. + + Handler exceptions may contain decrypted DSNs, SQL, credentials, or sampled + target values. The detailed exception therefore belongs in neither the + metadata database nor downstream API responses. Operators correlate the + fixed code with bounded metrics and handler-specific sanitized evidence. + """ + + return JOB_HANDLER_FAILED def _mark_job_running(job: JobQueue) -> JobQueue: @@ -142,7 +156,7 @@ async def run_worker_forever( if handler is None: async with session.begin(): job.status = "failed" - job.last_error = f"Unknown job_type: {job.job_type}" + job.last_error = JOB_HANDLER_UNAVAILABLE job.finished_at = dt.datetime.now(dt.timezone.utc) continue @@ -160,11 +174,11 @@ async def run_worker_forever( outcome="succeeded", duration_s=duration_s, ) - except Exception as e: # noqa: BLE001 + except Exception as exc: # noqa: BLE001 duration_s = time.perf_counter() - started async with session.begin(): job.status = "failed" - job.last_error = str(e) + job.last_error = sanitize_job_error_message(exc) job.finished_at = dt.datetime.now(dt.timezone.utc) _publish_job_metrics( diff --git a/backend/app/main.py b/backend/app/main.py index ae5788af1..d12e24fd5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from app.api.annotations import router as annotations_router @@ -15,13 +16,18 @@ from app.api.auth_routes import router as auth_router from app.api.diagram_views import router as diagram_views_router from app.api.me import router as me_router +from app.api.migration_plans import router as migration_plans_router +from app.api.migration_runs import router as migration_runs_router from app.api.projects import router as projects_router from app.api.share import router as share_router +from app.api.schema_models import router as schema_models_router from app.api.snapshots import router as snapshots_router from app.auth import try_get_subject_for_rate_limit from app.csrf import CSRF_HEADER_NAME, generate_csrf_token, make_csrf_middleware from app.db import SessionLocal, get_pooler_detection from app.jobs.snapshot_job import handle_snapshot_job +from app.jobs.migration_dispatch_relay import run_migration_dispatch_relay_forever +from app.jobs.valkey_queue import valkey_queue_enabled from app.jobs.worker import run_worker_forever from app.observability import setup_observability from app.rate_limit import ( @@ -29,6 +35,7 @@ RateLimitPolicy, make_rate_limit_middleware, ) +from app.request_validation import request_validation_exception_handler from app.security_headers import make_security_headers_middleware from app.settings import settings @@ -37,12 +44,34 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: """Run application startup/shutdown hooks. - Starts a background job worker on startup and ensures it is cancelled and + Starts configured background lifecycles and ensures each is cancelled and awaited on shutdown. """ + if settings.migration_dispatch_relay_enabled and not valkey_queue_enabled(): + raise RuntimeError( + "migration dispatch relay requires the Valkey queue backend" + ) + handlers = {"snapshot": handle_snapshot_job} - task = asyncio.create_task(run_worker_forever(SessionLocal, handlers)) + tasks = [ + asyncio.create_task( + run_worker_forever(SessionLocal, handlers), + name="job-queue-worker", + ) + ] + if settings.migration_dispatch_relay_enabled: + tasks.append( + asyncio.create_task( + run_migration_dispatch_relay_forever( + SessionLocal, + poll_interval_s=( + settings.migration_dispatch_relay_poll_interval_seconds + ), + ), + name="migration-dispatch-relay", + ) + ) try: # Best-effort pooler detection (log once for ops visibility). try: @@ -56,20 +85,25 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: logging.getLogger(__name__).exception("db_pooler_detection failed") yield finally: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) app = FastAPI(title="pg-erd-cloud backend", lifespan=lifespan) +app.add_exception_handler( + RequestValidationError, + request_validation_exception_handler, +) CORS_ALLOW_HEADERS = [ "Authorization", "Content-Type", + "Idempotency-Key", + "If-Match", CSRF_HEADER_NAME, ] +CORS_EXPOSE_HEADERS = ["ETag"] _rate_limiter = InMemoryFixedWindowRateLimiter( max_keys=settings.api_rate_limit_max_keys @@ -132,8 +166,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: # actually need cookie-based auth. allow_credentials=False, # Explicit allowlist (avoid "*") so CORS behavior is reviewable. - allow_methods=["GET", "POST", "OPTIONS"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=CORS_ALLOW_HEADERS, + expose_headers=CORS_EXPOSE_HEADERS, ) # Observability should be registered after other middleware so it can capture @@ -176,3 +211,6 @@ async def csrf_token() -> dict[str, str]: app.include_router(me_router) app.include_router(share_router) app.include_router(auth_router) +app.include_router(schema_models_router) +app.include_router(migration_plans_router) +app.include_router(migration_runs_router) diff --git a/backend/app/models.py b/backend/app/models.py index 396b12e99..16888cfe1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,6 +4,8 @@ import uuid from sqlalchemy import ( + Boolean, + CheckConstraint, DateTime, ForeignKey, Index, @@ -11,6 +13,7 @@ LargeBinary, Text, UniqueConstraint, + text, ) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -164,6 +167,446 @@ class SchemaSnapshotData(Base): ) +class SchemaModel(Base): + """Project-scoped editable schema identity with immutable revisions.""" + + __tablename__ = "schema_model" + + schema_model_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + project_space_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + index=True, + ) + model_name: Mapped[str] = mapped_column(Text()) + current_revision_number: Mapped[int] = mapped_column(Integer()) + created_by_user_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid") + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + updated_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow + ) + + __table_args__ = ( + UniqueConstraint( + "project_space_uuid", "model_name", name="uq_schema_model__project_name" + ), + ) + + +class SchemaModelRevision(Base): + """Immutable canonical JSON revision used as migration-plan input.""" + + __tablename__ = "schema_model_revision" + + schema_model_revision_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + schema_model_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("schema_model.schema_model_uuid", ondelete="CASCADE"), + index=True, + ) + revision_number: Mapped[int] = mapped_column(Integer()) + revision_digest: Mapped[str] = mapped_column(Text()) + model_json: Mapped[dict] = mapped_column(JSONB()) + base_schema_snapshot_uuid: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("schema_snapshot.schema_snapshot_uuid", ondelete="RESTRICT"), + nullable=True, + ) + created_by_user_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid") + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + + __table_args__ = ( + UniqueConstraint( + "schema_model_uuid", + "revision_number", + name="uq_schema_model_revision__model_number", + ), + ) + + +class MigrationPlan(Base): + """Immutable server-compiled plan bound to one target and base snapshot.""" + + __tablename__ = "migration_plan" + + migration_plan_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + project_space_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + index=True, + ) + schema_model_revision_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey( + "schema_model_revision.schema_model_revision_uuid", ondelete="RESTRICT" + ), + index=True, + ) + db_connection_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("db_connection.db_connection_uuid", ondelete="RESTRICT"), + ) + base_schema_snapshot_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("schema_snapshot.schema_snapshot_uuid", ondelete="RESTRICT"), + ) + compiler_version: Mapped[str] = mapped_column(Text()) + base_digest: Mapped[str] = mapped_column(Text()) + target_digest: Mapped[str] = mapped_column(Text()) + statement_digest: Mapped[str] = mapped_column(Text()) + plan_json: Mapped[dict] = mapped_column(JSONB()) + created_by_user_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid") + ) + expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + + __table_args__ = ( + UniqueConstraint( + "schema_model_revision_uuid", + "db_connection_uuid", + "base_schema_snapshot_uuid", + "statement_digest", + name="uq_migration_plan__immutable_identity", + ), + Index("ix_migration_plan__expires_at", "expires_at"), + ) + + +class MigrationRun(Base): + """Durable dry-run or apply attempt bound to one immutable plan.""" + + __tablename__ = "migration_run" + + migration_run_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + project_space_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("project_space.project_space_uuid", ondelete="CASCADE"), + ) + migration_plan_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("migration_plan.migration_plan_uuid", ondelete="RESTRICT"), + ) + passed_dry_run_uuid: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("migration_run.migration_run_uuid", ondelete="RESTRICT"), + nullable=True, + ) + run_kind: Mapped[str] = mapped_column(Text()) + state: Mapped[str] = mapped_column(Text()) + state_version: Mapped[int] = mapped_column(Integer(), default=1) + idempotency_key_hash: Mapped[str] = mapped_column(Text()) + plan_digest: Mapped[str] = mapped_column(Text()) + request_digest: Mapped[str] = mapped_column(Text()) + confirmation_digest: Mapped[str | None] = mapped_column(Text(), nullable=True) + destructive_confirmation: Mapped[bool | None] = mapped_column( + Boolean(), nullable=True + ) + latest_event_digest: Mapped[str] = mapped_column(Text()) + requested_by_user_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("user_account.user_account_uuid") + ) + cancellation_requested: Mapped[bool] = mapped_column(Boolean(), default=False) + observed_base_digest: Mapped[str | None] = mapped_column(Text(), nullable=True) + evidence_json: Mapped[dict] = mapped_column(JSONB()) + error_code: Mapped[str | None] = mapped_column(Text(), nullable=True) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + updated_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow + ) + started_at: Mapped[dt.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + finished_at: Mapped[dt.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + UniqueConstraint( + "project_space_uuid", + "run_kind", + "idempotency_key_hash", + name="uq_migration_run__idempotent_action", + ), + CheckConstraint( + "run_kind IN ('dry_run', 'apply')", + name="ck_migration_run__run_kind", + ), + CheckConstraint( + "state IN ('queued', 'sandbox_running', 'live_preflight_running', " + "'passed', 'drifted', 'failed', 'applying', 'reconciling', " + "'verifying', 'verified', 'drifted_no_apply', 'not_applied', " + "'verification_failed', 'failed_rolled_back', " + "'applied_with_drift', 'outcome_unknown', 'cancelled')", + name="ck_migration_run__state", + ), + CheckConstraint( + "(run_kind = 'dry_run' AND state IN ('queued', 'sandbox_running', " + "'live_preflight_running', 'passed', 'drifted', 'failed', " + "'cancelled')) OR " + "(run_kind = 'apply' AND state IN ('queued', 'applying', " + "'reconciling', 'verifying', 'verified', 'drifted_no_apply', " + "'not_applied', 'verification_failed', 'failed_rolled_back', " + "'applied_with_drift', 'outcome_unknown', 'cancelled'))", + name="ck_migration_run__kind_state", + ), + CheckConstraint( + "state_version >= 1", name="ck_migration_run__state_version" + ), + CheckConstraint( + "latest_event_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__latest_event_digest", + ), + CheckConstraint( + "idempotency_key_hash ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__idempotency_key_hash", + ), + CheckConstraint( + "plan_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__plan_digest", + ), + CheckConstraint( + "request_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__request_digest", + ), + CheckConstraint( + "observed_base_digest IS NULL OR " + "observed_base_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__observed_base_digest", + ), + CheckConstraint( + "confirmation_digest IS NULL OR " + "confirmation_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run__confirmation_digest", + ), + CheckConstraint( + "(run_kind = 'dry_run' AND passed_dry_run_uuid IS NULL AND " + "confirmation_digest IS NULL AND destructive_confirmation IS NULL) OR " + "(run_kind = 'apply' AND passed_dry_run_uuid IS NOT NULL AND " + "confirmation_digest IS NOT NULL AND destructive_confirmation IS NOT NULL)", + name="ck_migration_run__apply_confirmation", + ), + Index("ix_migration_run__migration_plan_uuid", "migration_plan_uuid"), + Index("ix_migration_run__passed_dry_run_uuid", "passed_dry_run_uuid"), + Index("ix_migration_run__project_state", "project_space_uuid", "state"), + ) + + +class MigrationRunDispatch(Base): + """Transactional outbox intent for one isolated dry-run dispatch.""" + + __tablename__ = "migration_run_dispatch" + + migration_run_dispatch_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + migration_run_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("migration_run.migration_run_uuid", ondelete="CASCADE"), + ) + dispatch_kind: Mapped[str] = mapped_column(Text()) + status: Mapped[str] = mapped_column(Text()) + attempt_count: Mapped[int] = mapped_column(Integer(), default=0) + not_before: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + published_at: Mapped[dt.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + UniqueConstraint( + "migration_run_uuid", + name="uq_migration_run_dispatch__migration_run_uuid", + ), + CheckConstraint( + "dispatch_kind = 'isolated_dry_run'", + name="ck_migration_run_dispatch__dispatch_kind", + ), + CheckConstraint( + "status IN ('pending', 'published')", + name="ck_migration_run_dispatch__status", + ), + CheckConstraint( + "attempt_count >= 0", + name="ck_migration_run_dispatch__attempt_count", + ), + CheckConstraint( + "(status = 'pending' AND published_at IS NULL) OR " + "(status = 'published' AND published_at IS NOT NULL)", + name="ck_migration_run_dispatch__published_at", + ), + Index( + "ix_migration_run_dispatch__status_not_before", + "status", + "not_before", + ), + ) + + +class MigrationRunAttempt(Base): + """Durable, lease-bound worker ownership history for one migration run.""" + + __tablename__ = "migration_run_attempt" + + migration_run_attempt_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + migration_run_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("migration_run.migration_run_uuid", ondelete="CASCADE"), + ) + attempt_number: Mapped[int] = mapped_column(Integer()) + acquired_state_version: Mapped[int] = mapped_column(Integer()) + status: Mapped[str] = mapped_column(Text()) + worker_identity_hash: Mapped[str] = mapped_column(Text()) + signal_lease_token_hash: Mapped[str] = mapped_column(Text()) + lease_expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True)) + acquired_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True)) + last_heartbeat_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[dt.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + UniqueConstraint( + "migration_run_uuid", + "attempt_number", + name="uq_migration_run_attempt__run_number", + ), + CheckConstraint( + "attempt_number >= 1", + name="ck_migration_run_attempt__attempt_number", + ), + CheckConstraint( + "acquired_state_version >= 1", + name="ck_migration_run_attempt__acquired_state_version", + ), + CheckConstraint( + "status IN ('active', 'completed', 'abandoned')", + name="ck_migration_run_attempt__status", + ), + CheckConstraint( + "worker_identity_hash ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_attempt__worker_identity_hash", + ), + CheckConstraint( + "signal_lease_token_hash ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_attempt__signal_lease_token_hash", + ), + CheckConstraint( + "last_heartbeat_at >= acquired_at AND " + "lease_expires_at > acquired_at AND " + "((status = 'active' AND finished_at IS NULL) OR " + "(status IN ('completed', 'abandoned') AND finished_at IS NOT NULL " + "AND finished_at >= last_heartbeat_at))", + name="ck_migration_run_attempt__timestamps", + ), + Index( + "ix_migration_run_attempt__active_run", + "migration_run_uuid", + unique=True, + postgresql_where=text("status = 'active'"), + ), + Index( + "ix_migration_run_attempt__lease_expiry", + "status", + "lease_expires_at", + ), + ) + + +class MigrationRunEvent(Base): + """Append-only, bounded evidence for one migration-run transition.""" + + __tablename__ = "migration_run_event" + + migration_run_event_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + migration_run_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("migration_run.migration_run_uuid", ondelete="CASCADE"), + ) + sequence_number: Mapped[int] = mapped_column(Integer()) + event_type: Mapped[str] = mapped_column(Text()) + state_before: Mapped[str | None] = mapped_column(Text(), nullable=True) + state_after: Mapped[str] = mapped_column(Text()) + evidence_json: Mapped[dict] = mapped_column(JSONB()) + previous_event_digest: Mapped[str | None] = mapped_column( + Text(), nullable=True + ) + event_digest: Mapped[str] = mapped_column(Text()) + actor_user_uuid: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("user_account.user_account_uuid"), + nullable=True, + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), default=utcnow + ) + + __table_args__ = ( + UniqueConstraint( + "migration_run_uuid", + "sequence_number", + name="uq_migration_run_event__run_sequence", + ), + CheckConstraint( + "sequence_number >= 1", + name="ck_migration_run_event__sequence_number", + ), + CheckConstraint( + "(sequence_number = 1 AND previous_event_digest IS NULL) OR " + "(sequence_number > 1 AND previous_event_digest IS NOT NULL)", + name="ck_migration_run_event__previous_digest", + ), + CheckConstraint( + "previous_event_digest IS NULL OR " + "previous_event_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_event__previous_digest_format", + ), + CheckConstraint( + "event_digest ~ '^[0-9a-f]{64}$'", + name="ck_migration_run_event__event_digest", + ), + CheckConstraint( + "event_type ~ '^[a-z][a-z0-9_]{0,63}$'", + name="ck_migration_run_event__event_type", + ), + CheckConstraint( + "state_before IS NULL OR state_before IN ('queued', 'sandbox_running', 'live_preflight_running', 'passed', 'drifted', 'failed', 'applying', 'reconciling', 'verifying', 'verified', 'drifted_no_apply', 'not_applied', 'verification_failed', 'failed_rolled_back', 'applied_with_drift', 'outcome_unknown', 'cancelled')", + name="ck_migration_run_event__state_before", + ), + CheckConstraint( + "state_after IN ('queued', 'sandbox_running', 'live_preflight_running', 'passed', 'drifted', 'failed', 'applying', 'reconciling', 'verifying', 'verified', 'drifted_no_apply', 'not_applied', 'verification_failed', 'failed_rolled_back', 'applied_with_drift', 'outcome_unknown', 'cancelled')", + name="ck_migration_run_event__state_after", + ), + ) + + + class JobQueue(Base): """Lightweight DB-backed job queue (MVP).""" diff --git a/backend/app/observability.py b/backend/app/observability.py index fcc189b34..0fe958fb1 100644 --- a/backend/app/observability.py +++ b/backend/app/observability.py @@ -128,6 +128,7 @@ async def middleware( request_id = raw_request_id else: request_id = str(uuid.uuid4()) + request.state.request_id = request_id start = time.perf_counter() try: diff --git a/backend/app/permissions.py b/backend/app/permissions.py index cd7388944..0f10d24a8 100644 --- a/backend/app/permissions.py +++ b/backend/app/permissions.py @@ -8,7 +8,7 @@ from app.models import ProjectMember -_ROLE_RANK = {"viewer": 0, "editor": 1, "owner": 2} +_ROLE_RANK = {"viewer": 0, "editor": 1, "deployer": 2, "owner": 3} async def require_project_member( diff --git a/backend/app/pg_introspect/introspect.py b/backend/app/pg_introspect/introspect.py index 598fb29c6..18669a8a5 100644 --- a/backend/app/pg_introspect/introspect.py +++ b/backend/app/pg_introspect/introspect.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import datetime as dt import ssl from urllib.parse import parse_qsl, urlparse @@ -10,6 +11,9 @@ from app.pg_introspect.column_examples import add_column_examples from app.pg_introspect.dsn_guard import validate_postgres_dsn_target from app.pg_introspect.forward_ddl import ForwardDdlBatch +from app.pg_introspect.snapshot_contract import ( + CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, +) from app.sanitize import sanitize_for_storage @@ -60,9 +64,11 @@ def _verified_tls_context(dsn: str, server_hostname: str) -> ssl.SSLContext: return context -async def _connect_guarded_postgres( +async def connect_guarded_postgres( dsn: str, *, timeout: float ) -> asyncpg.Connection: + """Open one PostgreSQL connection after DNS/SSRF/TLS target validation.""" + target = await validate_postgres_dsn_target(dsn) connect_host: str | list[str] = ( target.hosts[0] if len(target.hosts) == 1 else list(target.hosts) @@ -94,7 +100,7 @@ async def _connect_guarded_postgres( async def probe_postgres(dsn: str) -> str: """SSRF-guarded connectivity check: connect and return the server version.""" - conn = await _connect_guarded_postgres(dsn, timeout=10) + conn = await connect_guarded_postgres(dsn, timeout=10) try: await conn.fetchval("SELECT 1") return str(await conn.fetchval("SHOW server_version")) @@ -113,7 +119,7 @@ async def apply_postgres_ddl( TLS hostname handling. """ - conn = await _connect_guarded_postgres(dsn, timeout=15) + conn = await connect_guarded_postgres(dsn, timeout=15) try: tx = conn.transaction() await tx.start() @@ -130,55 +136,106 @@ async def apply_postgres_ddl( await conn.close() +async def capture_postgres_snapshot( + conn: asyncpg.Connection, schema_filter: str | None +) -> dict: + """Capture a snapshot on a caller-owned connection and transaction. + + The caller owns connection authorization, transaction isolation, commit or + rollback, and connection closure. Keeping those capabilities outside this + function lets live preflight bind snapshot evidence and bounded checks to + one already-authorized repeatable-read transaction. + """ + + try: + transaction_active = conn.is_in_transaction() + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception: # noqa: BLE001 + raise RuntimeError( + "postgres snapshot capture transaction is missing" + ) from None + if transaction_active is not True: + raise RuntimeError( + "postgres snapshot capture transaction is missing" + ) + + version = await conn.fetchval("SHOW server_version") + schema_name = schema_filter + include_system = False + + schemas = await conn.fetch(queries.SCHEMAS_SQL, schema_name, include_system) + relations = await conn.fetch( + queries.RELATIONS_SQL, schema_name, include_system + ) + columns = await conn.fetch(queries.COLUMNS_SQL, schema_name, include_system) + constraints = await conn.fetch( + queries.CONSTRAINTS_SQL, schema_name, include_system + ) + indexes = await conn.fetch(queries.INDEXES_SQL, schema_name, include_system) + pk_columns = await conn.fetch( + queries.PK_COLUMNS_SQL, schema_name, include_system + ) + fk_edges = await conn.fetch( + queries.FK_EDGES_SQL, schema_name, include_system + ) + citus_distributed_tables: list[asyncpg.Record] = [] + has_citus = await conn.fetchval( + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_extension " + "WHERE extname = 'citus')" + ) + if has_citus: + savepoint = conn.transaction() + await savepoint.start() + try: + citus_distributed_tables = await conn.fetch( + queries.CITUS_DISTRIBUTED_TABLES_SQL, + schema_name, + include_system, + ) + except ( + asyncpg.InsufficientPrivilegeError, + asyncpg.UndefinedColumnError, + asyncpg.UndefinedFunctionError, + asyncpg.UndefinedTableError, + ): + await savepoint.rollback() + citus_distributed_tables = [] + else: + await savepoint.commit() + + snapshot = { + "snapshot_contract_version": CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, + "captured_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "server_version": str(version), + "schema_filter": schema_filter, + "schemas": [dict(r) for r in schemas], + "relations": [dict(r) for r in relations], + "columns": add_column_examples([dict(r) for r in columns]), + "constraints": [dict(r) for r in constraints], + "indexes": [dict(r) for r in indexes], + "pk_columns": [dict(r) for r in pk_columns], + "fk_edges": [dict(r) for r in fk_edges], + "citus_distributed_tables": [dict(r) for r in citus_distributed_tables], + } + sanitized = sanitize_for_storage(snapshot) + return sanitized # type: ignore[return-value] + + async def introspect_postgres(dsn: str, schema_filter: str | None) -> dict: """Introspect a PostgreSQL database and return a snapshot JSON.""" # Note: avoid logging DSN. - conn = await _connect_guarded_postgres(dsn, timeout=10) + conn = await connect_guarded_postgres(dsn, timeout=10) try: - version = await conn.fetchval("SHOW server_version") - schema_name = schema_filter - include_system = False - - schemas = await conn.fetch(queries.SCHEMAS_SQL, schema_name, include_system) - relations = await conn.fetch(queries.RELATIONS_SQL, schema_name, include_system) - columns = await conn.fetch(queries.COLUMNS_SQL, schema_name, include_system) - constraints = await conn.fetch( - queries.CONSTRAINTS_SQL, schema_name, include_system - ) - indexes = await conn.fetch(queries.INDEXES_SQL, schema_name, include_system) - pk_columns = await conn.fetch( - queries.PK_COLUMNS_SQL, schema_name, include_system - ) - fk_edges = await conn.fetch(queries.FK_EDGES_SQL, schema_name, include_system) - citus_distributed_tables = [] - has_citus = await conn.fetchval( - "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'citus')" - ) - if has_citus: - try: - citus_distributed_tables = await conn.fetch( - queries.CITUS_DISTRIBUTED_TABLES_SQL, - schema_name, - include_system, - ) - except asyncpg.UndefinedTableError: - citus_distributed_tables = [] - - snapshot = { - "captured_at": dt.datetime.now(dt.timezone.utc).isoformat(), - "server_version": str(version), - "schema_filter": schema_filter, - "schemas": [dict(r) for r in schemas], - "relations": [dict(r) for r in relations], - "columns": add_column_examples([dict(r) for r in columns]), - "constraints": [dict(r) for r in constraints], - "indexes": [dict(r) for r in indexes], - "pk_columns": [dict(r) for r in pk_columns], - "fk_edges": [dict(r) for r in fk_edges], - "citus_distributed_tables": [dict(r) for r in citus_distributed_tables], - } - - return sanitize_for_storage(snapshot) # type: ignore[return-value] + tx = conn.transaction(isolation="repeatable_read", readonly=True) + await tx.start() + try: + snapshot = await capture_postgres_snapshot(conn, schema_filter) + except BaseException: + await tx.rollback() + raise + await tx.commit() + return snapshot finally: await conn.close() diff --git a/backend/app/pg_introspect/queries.py b/backend/app/pg_introspect/queries.py index c0b2d15b2..20114627e 100644 --- a/backend/app/pg_introspect/queries.py +++ b/backend/app/pg_introspect/queries.py @@ -34,6 +34,13 @@ c.relname AS relation_name, c.relkind::text AS relation_kind, pg_catalog.obj_description(c.oid, 'pg_class') AS relation_comment, + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_attribute dropped + WHERE dropped.attrelid = c.oid + AND dropped.attnum > 0 + AND dropped.attisdropped + ) AS has_dropped_columns, c.relispartition AS is_partition, pg_catalog.pg_get_partkeydef(c.oid) AS partition_key, pg_catalog.pg_get_expr(c.relpartbound, c.oid) AS partition_bound, @@ -99,6 +106,8 @@ a.attnotnull AS is_not_null, a.atthasdef AS has_default, pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) AS default_expr, + a.attidentity::text AS identity, + a.attgenerated::text AS generated, pg_catalog.col_description(a.attrelid, a.attnum) AS column_comment FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid @@ -328,6 +337,8 @@ n.nspname AS schema_name, rel.oid AS relation_oid, rel.relname AS relation_name, + con.condeferrable AS is_deferrable, + con.condeferred AS is_initially_deferred, k.ordinality AS column_ordinal, a.attname AS column_name FROM pk con diff --git a/backend/app/pg_introspect/snapshot_contract.py b/backend/app/pg_introspect/snapshot_contract.py new file mode 100644 index 000000000..8ec54dbf3 --- /dev/null +++ b/backend/app/pg_introspect/snapshot_contract.py @@ -0,0 +1,3 @@ +"""Version marker for PostgreSQL snapshot capability semantics.""" + +CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION = 1 diff --git a/backend/app/request_validation.py b/backend/app/request_validation.py new file mode 100644 index 000000000..86498f798 --- /dev/null +++ b/backend/app/request_validation.py @@ -0,0 +1,72 @@ +"""Return secret-safe validation failures for sensitive request bodies.""" + +import asyncio +from collections.abc import Callable, Coroutine +from typing import Any + +from fastapi import Request +from fastapi.routing import APIRoute +from fastapi.exception_handlers import ( + request_validation_exception_handler as default_validation_handler, +) +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from starlette.responses import JSONResponse, Response + + +def _is_legacy_apply_path(path: str) -> bool: + """Recognize only the stored-connection legacy apply request path.""" + + return path.startswith("/api/connections/") and path.endswith("/apply-sql") + + +def _is_sensitive_body_path(path: str) -> bool: + """Recognize endpoints whose rejected bodies must never be reflected.""" + + return _is_legacy_apply_path(path) or path == "/api/dbml/convert" + + +def _sensitive_validation_response() -> JSONResponse: + """Return the fixed response shared by sensitive-body rejection paths.""" + + return JSONResponse( + status_code=422, + content={"detail": "request validation failed"}, + ) + + +class SecretSafeLegacyApplyRoute(APIRoute): + """Validate the sensitive legacy apply body before route dependencies.""" + + def get_route_handler( + self, + ) -> Callable[[Request], Coroutine[Any, Any, Response]]: + route_handler = super().get_route_handler() + + async def guarded_route_handler(request: Request) -> Response: + if _is_legacy_apply_path(request.url.path): + try: + payload = await request.json() + from app.schemas import ApplySqlIn + + ApplySqlIn.model_validate(payload) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except (ValidationError, ValueError, TypeError): + return _sensitive_validation_response() + return await route_handler(request) + + return guarded_route_handler + + +async def request_validation_exception_handler( + request: Request, + error: Exception, +) -> Response: + """Avoid reflecting SQL/DBML while retaining normal validation elsewhere.""" + + if _is_sensitive_body_path(request.url.path): + return _sensitive_validation_response() + if not isinstance(error, RequestValidationError): + raise error + return await default_validation_handler(request, error) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d7c6de77d..cd719d919 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -4,7 +4,7 @@ import uuid from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class ProjectCreateIn(BaseModel): @@ -34,7 +34,7 @@ class ProjectMemberAddIn(BaseModel): description="OIDC sub, or dev: in dev mode", ) # MVP: restrict to non-owner roles. Owner is assigned at project creation. - project_role: Literal["viewer", "editor"] = Field(default="viewer") + project_role: Literal["viewer", "editor", "deployer"] = Field(default="viewer") class ProjectMemberOut(BaseModel): @@ -73,9 +73,11 @@ class ApplySqlIn(BaseModel): sql: str = Field( min_length=1, max_length=262_144, + pattern=r"^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$", description=( "Conservative PostgreSQL DDL subset with unquoted snake_case " - "identifiers. Arbitrary SQL is rejected." + "identifiers. Arbitrary SQL is rejected; non-text controls are " + "rejected while tab, LF, and CR remain valid transport text." ), ) # Default to a rolled-back pre-flight; the caller must opt in to persist. @@ -131,6 +133,214 @@ class SnapshotDetailOut(BaseModel): snapshot_json: dict | None +class SchemaModelCreateIn(BaseModel): + """Create a named editable model and its first immutable revision.""" + + model_name: str = Field( + min_length=1, max_length=255, pattern=r"^[^\x00-\x1F\x7F]+$" + ) + model_json: dict + base_schema_snapshot_uuid: uuid.UUID | None = None + + +class SchemaModelReviseIn(BaseModel): + """Save a successor revision under optimistic concurrency control.""" + + model_json: dict + base_schema_snapshot_uuid: uuid.UUID | None = None + + +class SchemaModelDetailOut(BaseModel): + """Editable model identity together with one immutable revision.""" + + schema_model_uuid: uuid.UUID + model_name: str + schema_model_revision_uuid: uuid.UUID + revision_number: int + revision_digest: str + model_json: dict + base_schema_snapshot_uuid: uuid.UUID | None + + +class MigrationPlanCreateIn(BaseModel): + """Bind one model revision to an exact target connection and snapshot.""" + + db_connection_uuid: uuid.UUID + base_schema_snapshot_uuid: uuid.UUID + + +class MigrationPlanObjectRef(BaseModel): + """Structured PostgreSQL object identity carried beside rendered SQL.""" + + database: str | None = None + schema_name: str | None = None + table_name: str | None = None + column_name: str | None = None + + +class MigrationPlanRisk(BaseModel): + """Conservative operational and data-integrity risk for one statement.""" + + severity: Literal["safe", "warning", "destructive"] + lock_mode: str + possible_rewrite: bool + table_scan: bool + data_loss: bool + detail: str + + +class MigrationPlanStatement(BaseModel): + """One server-compiled statement and its execution authority metadata.""" + + kind: str + target: str + object_ref: MigrationPlanObjectRef + sql: str + transactional: bool + dependencies: list[str] + dependency_refs: list[MigrationPlanObjectRef] + reversible: bool + risk: MigrationPlanRisk + required_privileges: list[str] + preconditions: list[dict[str, object]] + + +class MigrationPlanBlocker(BaseModel): + """Unsupported semantic change that suppresses executable statements.""" + + code: str + object: str + object_ref: MigrationPlanObjectRef + detail: str + + +class MigrationPlanRiskSummary(BaseModel): + """Statement counts grouped by conservative risk severity.""" + + safe: int + warning: int + destructive: int + + +class MigrationPlanOut(BaseModel): + """Immutable structured plan preview returned for review and dry run.""" + + migration_plan_uuid: uuid.UUID + project_space_uuid: uuid.UUID + schema_model_revision_uuid: uuid.UUID + db_connection_uuid: uuid.UUID + base_schema_snapshot_uuid: uuid.UUID + plan_digest: str + base_digest: str + target_digest: str + compiler_version: str + snapshot_contract_version: int = Field(ge=1) + postgresql_major: int = Field(ge=14, le=18) + created_by_user_uuid: uuid.UUID + created_at: dt.datetime + can_dry_run: bool + requires_destructive_confirmation: bool + statements: list[MigrationPlanStatement] + proposed_statements: list[MigrationPlanStatement] + blockers: list[MigrationPlanBlocker] + risk_summary: MigrationPlanRiskSummary + expires_at: dt.datetime + + +class MigrationRunEventOut(BaseModel): + """One ordered, sanitized event in a durable migration-run history.""" + + sequence_number: int = Field(ge=1) + event_type: str + state_before: str | None + state_after: str + evidence: dict[str, object] + previous_event_digest: str | None + event_digest: str + actor_user_uuid: uuid.UUID | None + created_at: dt.datetime + + +class MigrationRunCancelIn(BaseModel): + """Bind a cancellation intent to the exact optimistic run version.""" + + expected_state_version: int = Field(ge=1, strict=True) + + +class MigrationRunCreateIn(BaseModel): + """Bind one idempotent dry-run intent to an immutable migration plan.""" + + plan_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class MigrationApplyRunCreateIn(BaseModel): + """Bind an execution-free apply intent to exact reviewed evidence.""" + + model_config = ConfigDict(extra="forbid") + + plan_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + passed_dry_run_uuid: uuid.UUID + target_connection_name: str = Field( + min_length=1, + max_length=128, + pattern=r"^[^\x00-\x1F\x7F]+$", + ) + destructive_acknowledged: bool = Field(strict=True) + + +MigrationRunState = Literal[ + "queued", + "sandbox_running", + "live_preflight_running", + "passed", + "drifted", + "failed", + "cancelled", + "applying", + "reconciling", + "verifying", + "verified", + "drifted_no_apply", + "not_applied", + "verification_failed", + "failed_rolled_back", + "applied_with_drift", + "outcome_unknown", +] + + +class MigrationRunActionOut(BaseModel): + """Accepted durable run action selected by the control-plane CAS.""" + + migration_run_uuid: uuid.UUID + state: MigrationRunState + state_version: int = Field(ge=1) + cancellation_requested: bool + reused: bool + + +class MigrationRunOut(BaseModel): + """Authorized immutable view of one durable run and its event history.""" + + migration_run_uuid: uuid.UUID + project_space_uuid: uuid.UUID + migration_plan_uuid: uuid.UUID + run_kind: Literal["dry_run", "apply"] + state: MigrationRunState + state_version: int = Field(ge=1) + plan_digest: str + requested_by_user_uuid: uuid.UUID + cancellation_requested: bool + observed_base_digest: str | None + evidence: dict[str, object] + error_code: str | None + created_at: dt.datetime + updated_at: dt.datetime + started_at: dt.datetime | None + finished_at: dt.datetime | None + events: list[MigrationRunEventOut] + + class WideTablesOut(BaseModel): """Wide / denormalized table findings for a snapshot.""" diff --git a/backend/app/settings.py b/backend/app/settings.py index 93903ec6e..28b0118be 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -106,7 +106,25 @@ def _load_app_secret_from_file(cls, data: object) -> object: valkey_sentinel_hosts: str | None = None valkey_sentinel_master: str | None = None valkey_queue_key: str = "pg-erd-cloud:job-queue" + valkey_migration_run_queue_key: str = "pg-erd-cloud:migration-run-queue" + valkey_migration_run_processing_key: str = ( + "pg-erd-cloud:migration-run-processing" + ) + valkey_migration_run_lease_token_key: str = ( + "pg-erd-cloud:migration-run-lease-token" # noqa: S105 - Valkey key name + ) + migration_run_signal_lease_seconds: float = Field( + 60.0, gt=0.0, le=3600.0 + ) valkey_lock_ttl_seconds: int = Field(300, ge=1) + migration_dispatch_relay_enabled: bool = False + migration_dispatch_relay_poll_interval_seconds: float = Field( + 1.0, gt=0.0, le=60.0 + ) + + # Transitional compatibility only. Persistent browser-authored DDL stays + # fail-closed unless an operator explicitly enables the legacy route. + legacy_persistent_apply_enabled: bool = False # Optional OIDC (Casdoor). If set, JWTs are verified. oidc_issuer: str | None = None diff --git a/backend/app/spec/dbml_import.py b/backend/app/spec/dbml_import.py index b93454a9a..7830227dc 100644 --- a/backend/app/spec/dbml_import.py +++ b/backend/app/spec/dbml_import.py @@ -13,53 +13,134 @@ * quoted identifiers ``"My Table"``; comments ``//``; multi-word types Ignored (parsed over, not errors): ``Project``/``Enum``/``TableGroup``/``Note`` -blocks, ``indexes`` blocks, header colors. ponytail: line-oriented parser, not a -grammar — good for the 95% of DBML in the wild; a hostile file degrades to -skipped lines, never an exception. +blocks, ``indexes`` blocks, header colors. This is a bounded line-oriented +parser, not a complete grammar. Unsupported ordinary lines are skipped, while +ambiguous identifiers and resource-limit violations fail closed. """ from __future__ import annotations +import hashlib import re from typing import Any -_COLUMN_RE = re.compile( - r"^(?:\"(?P[^\"]+)\"|(?P\w+))\s+" - r"(?P[\w]+(?:\([^)]*\))?(?:\[\])?)" +from app.ddl.export import quote_identifier + + +class DbmlIdentifierError(ValueError): + """Raised when DBML identifier text cannot be represented safely.""" + + +_POSTGRES_IDENTIFIER_MAX_BYTES = 63 +_DBML_MAX_CHARACTERS = 524_288 +_DBML_MAX_LINES = 10_000 +_COLUMN_TAIL_RE = re.compile( + r"^(?P[\w]+(?:\([^)]*\))?(?:\[\])?)" r"(?:\s*\[(?P.*)\])?\s*$" ) # a dotted path whose segments may be quoted (quotes can contain spaces) -_PATH = r'(?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))*' +_PATH = r'(?:"(?:""|[^"])+"|\w+)(?:\.(?:"(?:""|[^"])+"|\w+))*' _REF_RE = re.compile( r"ref\s*(?:\w+\s*)?:?\s*" rf"(?P{_PATH})\s*(?P[<>-])\s*(?P{_PATH})", re.IGNORECASE, ) _INLINE_REF_RE = re.compile(rf"ref:\s*(?P[<>-])\s*(?P{_PATH})", re.IGNORECASE) -_PATH_SEGMENT_RE = re.compile(r'"[^"]+"|[^.]+') +_PATH_SEGMENT_RE = re.compile(r'"(?:""|[^"])+"|[^.]+') + + +def _validate_identifier(identifier: str) -> str: + """Validate one decoded identifier against PostgreSQL's lossless boundary.""" + if not identifier: + raise DbmlIdentifierError("DBML identifiers must not be empty") + if "\x00" in identifier: + raise DbmlIdentifierError("DBML identifiers must not contain NUL") + if len(identifier.encode("utf-8")) > _POSTGRES_IDENTIFIER_MAX_BYTES: + raise DbmlIdentifierError("DBML identifier exceeds 63 UTF-8 bytes") + return identifier -def _consume_table_name(line: str, start: int) -> tuple[str, int] | None: - """Return the table identifier and the offset after it, using only linear scans.""" +def _consume_identifier(line: str, start: int) -> tuple[str, int] | None: + """Decode one quoted or unquoted DBML identifier from ``line``.""" if start >= len(line): return None if line[start] == '"': - end = line.find('"', start + 1) - if end <= start + 1: - return None - return line[start + 1 : end], end + 1 + pos = start + 1 + decoded: list[str] = [] + while pos < len(line): + if line[pos] != '"': + decoded.append(line[pos]) + pos += 1 + continue + if pos + 1 < len(line) and line[pos + 1] == '"': + decoded.append('"') + pos += 2 + continue + return _validate_identifier("".join(decoded)), pos + 1 + raise DbmlIdentifierError("unterminated quoted DBML identifier") pos = start - while pos < len(line) and (line[pos].isalnum() or line[pos] in "_."): + while pos < len(line) and (line[pos].isalnum() or line[pos] == "_"): pos += 1 if pos == start: return None + return _validate_identifier(line[start:pos]), pos - raw = line[start:pos] - parts = raw.split(".") - if any(part == "" for part in parts): + +def _consume_identifier_path( + line: str, start: int, *, maximum_segments: int +) -> tuple[list[str], int] | None: + """Decode a bounded dotted identifier path without ambiguous segmentation.""" + first = _consume_identifier(line, start) + if first is None: return None - return raw, pos + identifier, pos = first + segments = [identifier] + while pos < len(line) and line[pos] == ".": + if len(segments) >= maximum_segments: + raise DbmlIdentifierError("DBML identifier path has too many segments") + following = _consume_identifier(line, pos + 1) + if following is None: + raise DbmlIdentifierError("DBML identifier path contains an empty segment") + identifier, pos = following + segments.append(identifier) + return segments, pos + + +def _strip_dbml_comment(line: str) -> str: + """Remove ``//`` comments only when the marker is outside quoted names.""" + pos = 0 + in_quote = False + while pos < len(line): + if line[pos] == '"': + if in_quote and pos + 1 < len(line) and line[pos + 1] == '"': + pos += 2 + continue + in_quote = not in_quote + pos += 1 + continue + if not in_quote and line.startswith("//", pos): + return line[:pos] + pos += 1 + return line + + +def _bounded_derived_identifier(candidate: str) -> str: + """Keep a generated identifier stable without PostgreSQL truncation.""" + encoded = candidate.encode("utf-8") + if len(encoded) <= _POSTGRES_IDENTIFIER_MAX_BYTES: + return candidate + digest = hashlib.sha256(encoded).hexdigest()[:8] + byte_budget = _POSTGRES_IDENTIFIER_MAX_BYTES - len(digest) - 1 + prefix: list[str] = [] + used = 0 + for character in candidate: + width = len(character.encode("utf-8")) + if used + width > byte_budget: + break + prefix.append(character) + used += width + return f"{''.join(prefix)}_{digest}" def _table_header_tail_ok(tail: str) -> bool: @@ -93,21 +174,15 @@ def _parse_table_header(line: str) -> tuple[str, str] | None: pos = 5 while pos < len(line) and line[pos].isspace(): pos += 1 - consumed = _consume_table_name(line, pos) + consumed = _consume_identifier_path(line, pos, maximum_segments=2) if consumed is None: return None - raw_name, pos = consumed + segments, pos = consumed if not _table_header_tail_ok(line[pos:]): return None - return _split_table_name(raw_name) - - -def _split_table_name(raw: str) -> tuple[str, str]: - raw = raw.strip().strip('"') - if "." in raw: - schema, _, name = raw.partition(".") - return schema.strip('"'), name.strip('"') - return "public", raw + if len(segments) == 2: + return segments[0], segments[1] + return "public", segments[0] def _split_col_ref(raw: str) -> tuple[str, str, str]: @@ -115,7 +190,14 @@ def _split_col_ref(raw: str) -> tuple[str, str, str]: Splits on dots *outside* quotes so '"Order Items".account_id' works. """ - parts = [p.strip('"') for p in _PATH_SEGMENT_RE.findall(raw.strip())] + parts = [ + p[1:-1].replace('""', '"') if p.startswith('"') else p + for p in _PATH_SEGMENT_RE.findall(raw.strip()) + ] + for part in parts: + _validate_identifier(part) + if len(parts) > 3: + raise DbmlIdentifierError("DBML reference path has too many segments") if len(parts) >= 3: return parts[0], parts[1], parts[2] if len(parts) == 2: @@ -123,8 +205,26 @@ def _split_col_ref(raw: str) -> tuple[str, str, str]: return "public", "", parts[0] +def _parse_column(line: str) -> tuple[str, re.Match[str]] | None: + """Parse a column line after decoding its identifier exactly once.""" + consumed = _consume_identifier(line, 0) + if consumed is None: + return None + column_name, pos = consumed + if pos >= len(line) or not line[pos].isspace(): + return None + tail = line[pos:].lstrip() + match = _COLUMN_TAIL_RE.match(tail) + if match is None: + return None + return column_name, match + + def parse_dbml(text: str) -> dict[str, Any]: """Parse DBML text into snapshot JSON (relations/columns/pk_columns/fk_edges).""" + if len(text) > _DBML_MAX_CHARACTERS: + raise DbmlIdentifierError("DBML text exceeds 524288 characters") + relations: list[dict[str, Any]] = [] columns: list[dict[str, Any]] = [] pk_columns: list[dict[str, Any]] = [] @@ -135,13 +235,22 @@ def parse_dbml(text: str) -> dict[str, Any]: current: tuple[str, str] | None = None in_ignored_block = 0 in_indexes = False - - for raw_line in text.splitlines(): + col_counts_by_oid: dict[int, int] = {} + + # ``str.splitlines`` treats Unicode separators such as U+0085 as newlines, + # but those characters are valid data inside a quoted PostgreSQL identifier. + # DBML's line grammar is LF-delimited; retain every other character so the + # identifier decoder can validate and round-trip it losslessly. + for line_number, raw_line in enumerate(text.split("\n"), start=1): + if line_number > _DBML_MAX_LINES: + raise DbmlIdentifierError("DBML text exceeds 10000 lines") # ReDoS guard: no legitimate DBML line approaches this length; capping # input size per regex call bounds worst-case backtracking to O(1). if len(raw_line) > 4096: - continue - line = raw_line.split("//", 1)[0].strip() + raise DbmlIdentifierError("DBML line exceeds 4096 characters") + if "\x00" in raw_line: + raise DbmlIdentifierError("DBML text must not contain NUL") + line = _strip_dbml_comment(raw_line).strip() if not line: continue @@ -181,13 +290,16 @@ def parse_dbml(text: str) -> dict[str, Any]: # standalone Ref (works inside or outside a table body) if re.match(r"^ref\b", line, re.IGNORECASE): rm = _REF_RE.search(line) - if rm: - fs, ft, fc = _split_col_ref(rm.group("from")) - ts, tt, tc = _split_col_ref(rm.group("to")) - if rm.group("op") == "<": # a < b means b references a - fs, ft, fc, ts, tt, tc = ts, tt, tc, fs, ft, fc - if ft and tt: - fk_specs.append((fs, ft, fc, ts, tt, tc)) + if rm is None: + if '"' in line: + raise DbmlIdentifierError("invalid DBML reference expression") + continue + fs, ft, fc = _split_col_ref(rm.group("from")) + ts, tt, tc = _split_col_ref(rm.group("to")) + if rm.group("op") == "<": # a < b means b references a + fs, ft, fc, ts, tt, tc = ts, tt, tc, fs, ft, fc + if ft and tt: + fk_specs.append((fs, ft, fc, ts, tt, tc)) continue if current is None: @@ -200,18 +312,20 @@ def parse_dbml(text: str) -> dict[str, Any]: in_indexes = False continue - cm = _COLUMN_RE.match(line) - if not cm: + parsed_column = _parse_column(line) + if parsed_column is None: continue - col_name = (cm.group("qname") or cm.group("name")).strip('"') + col_name, cm = parsed_column settings = (cm.group("settings") or "").lower() oid = oid_by_table[current] is_pk = bool(re.search(r"\bpk\b|primary\s+key", settings)) + column_position = col_counts_by_oid.get(oid, 0) + 1 + col_counts_by_oid[oid] = column_position columns.append( { "relation_oid": oid, "column_name": col_name, - "column_position": sum(1 for c in columns if c["relation_oid"] == oid) + 1, + "column_position": column_position, "data_type": cm.group("type"), "is_not_null": is_pk or "not null" in settings, "has_default": "default:" in settings, @@ -223,7 +337,10 @@ def parse_dbml(text: str) -> dict[str, Any]: pk_columns.append( {"relation_oid": oid, "column_name": col_name, "column_ordinal": len(pk_columns) + 1} ) - im = _INLINE_REF_RE.search(cm.group("settings") or "") + raw_settings = cm.group("settings") or "" + im = _INLINE_REF_RE.search(raw_settings) + if re.search(r"\bref\s*:", raw_settings, re.IGNORECASE) and im is None: + raise DbmlIdentifierError("invalid inline DBML reference expression") if im: ts, tt, tc = _split_col_ref(im.group("to")) if im.group("op") == "<": @@ -241,7 +358,7 @@ def parse_dbml(text: str) -> dict[str, Any]: fk_edges.append( { "fk_constraint_oid": 100000 + i, - "fk_constraint_name": f"fk_{ct}_{cc}", + "fk_constraint_name": _bounded_derived_identifier(f"fk_{ct}_{cc}"), "child_relation_oid": child, "parent_relation_oid": parent, "child_column_name": cc, @@ -283,11 +400,13 @@ def _build_constraints( pk_cols_by_oid.setdefault(pk["relation_oid"], []).append(pk["column_name"]) for oid, cols in pk_cols_by_oid.items(): rel = rel_by_oid[oid] - quoted = ", ".join(f'"{c}"' for c in cols) + quoted = ", ".join(quote_identifier(c) for c in cols) constraints.append( { "constraint_oid": 200000 + oid, - "constraint_name": f"pk_{rel['relation_name']}", + "constraint_name": _bounded_derived_identifier( + f"pk_{rel['relation_name']}" + ), "constraint_type": "p", "schema_name": rel["schema_name"], "relation_oid": oid, @@ -314,9 +433,10 @@ def _build_constraints( ) ], "constraint_def": ( - f'FOREIGN KEY ("{edge["child_column_name"]}") REFERENCES ' - f'"{parent["schema_name"]}"."{parent["relation_name"]}" ' - f'("{edge["parent_column_name"]}")' + f"FOREIGN KEY ({quote_identifier(edge['child_column_name'])}) " + f"REFERENCES {quote_identifier(parent['schema_name'])}." + f"{quote_identifier(parent['relation_name'])} " + f"({quote_identifier(edge['parent_column_name'])})" ), } ) diff --git a/backend/app/spec/relationship_inference.py b/backend/app/spec/relationship_inference.py index da55119e0..6d1e9671c 100644 --- a/backend/app/spec/relationship_inference.py +++ b/backend/app/spec/relationship_inference.py @@ -42,8 +42,6 @@ def infer_relationships(snapshot: dict[str, Any] | None) -> list[dict[str, Any]] pk_columns = snapshot.get("pk_columns") or [] fk_edges = snapshot.get("fk_edges") or [] - rel_by_oid: dict[Any, dict[str, Any]] = {r.get("relation_oid"): r for r in relations} - # relation_name (lower) -> list of relation dicts (there may be same name in # multiple schemas; we only infer within the same schema to avoid noise). by_name: dict[str, list[dict[str, Any]]] = {} diff --git a/backend/tests/test_api_apply_sql.py b/backend/tests/test_api_apply_sql.py index f64f7da12..cf28f897e 100644 --- a/backend/tests/test_api_apply_sql.py +++ b/backend/tests/test_api_apply_sql.py @@ -5,8 +5,9 @@ import pytest from fastapi import HTTPException -from app.api.connections import apply_sql +from app.api.connections import apply_sql, router from app.auth import CurrentUser +from app.db import get_session from app.db_introspect import apply_database_sql from app.schemas import ApplySqlIn @@ -23,6 +24,18 @@ def _body(dry_run=True): return ApplySqlIn(sql="CREATE TABLE safe_table (id bigint);", dry_run=dry_run) +def test_live_apply_authorization_reads_from_primary_session() -> None: + route = next( + route + for route in router.routes + if getattr(route, "path", "") == "/api/connections/{db_connection_uuid}/apply-sql" + ) + + assert any( + dependency.call is get_session for dependency in route.dependant.dependencies + ) + + @pytest.mark.asyncio async def test_apply_sql_returns_404_when_missing(): session = AsyncMock() @@ -80,6 +93,69 @@ async def test_apply_sql_reports_ok_true_on_success(): apply_mock.assert_awaited_once() +@pytest.mark.asyncio +async def test_persistent_legacy_apply_is_disabled_before_target_access(): + """Default-deny persistent compatibility apply before credential access.""" + + session = AsyncMock() + session.scalar = AsyncMock(return_value=uuid.uuid4()) + session.get = AsyncMock(return_value=_conn()) + with patch( + "app.api.connections.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.connections.decrypt_text" + ) as decrypt_mock, patch( + "app.api.connections.apply_database_sql", new_callable=AsyncMock + ) as apply_mock: + with pytest.raises(HTTPException) as exc_info: + await apply_sql( + db_connection_uuid=uuid.uuid4(), + body=_body(dry_run=False), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "persistent legacy apply is disabled" + session.get.assert_not_awaited() + decrypt_mock.assert_not_called() + apply_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_live_apply_requires_deployer_role_while_dry_run_requires_editor(): + session = AsyncMock() + project_uuid = uuid.uuid4() + session.scalar = AsyncMock(return_value=project_uuid) + session.get = AsyncMock(return_value=_conn()) + with patch( + "app.api.connections.require_project_member", new_callable=AsyncMock + ) as membership, patch( + "app.api.connections.settings.legacy_persistent_apply_enabled", True + ), patch( + "app.api.connections.decrypt_text", return_value="postgresql://u@db.example.com/x" + ), patch( + "app.api.connections.apply_database_sql", new_callable=AsyncMock + ): + await apply_sql( + db_connection_uuid=uuid.uuid4(), + body=_body(dry_run=False), + user=_user(), + session=session, + ) + await apply_sql( + db_connection_uuid=uuid.uuid4(), + body=_body(dry_run=True), + user=_user(), + session=session, + ) + + minimum_roles = [ + call.kwargs.get("minimum_role") for call in membership.await_args_list + ] + assert minimum_roles == [None, "deployer", None, "editor"] + + @pytest.mark.asyncio async def test_apply_sql_reports_ok_false_on_error(): session = AsyncMock() @@ -87,6 +163,8 @@ async def test_apply_sql_reports_ok_false_on_error(): session.get = AsyncMock(return_value=_conn()) with patch( "app.api.connections.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.connections.settings.legacy_persistent_apply_enabled", True ), patch( "app.api.connections.decrypt_text", return_value="postgresql://u@db.example.com/x" ), patch( diff --git a/backend/tests/test_api_connections.py b/backend/tests/test_api_connections.py index 41a131eca..88bfea062 100644 --- a/backend/tests/test_api_connections.py +++ b/backend/tests/test_api_connections.py @@ -4,6 +4,7 @@ import uuid import datetime as dt +from unittest.mock import MagicMock import pytest from fastapi import FastAPI, HTTPException @@ -117,7 +118,13 @@ def test_create_connection_success(monkeypatch: pytest.MonkeyPatch) -> None: async def mock_require(*args: object, **kwargs: object) -> str: return "editor" + async def allow_target(_dsn: str) -> None: + return None + monkeypatch.setattr("app.api.connections.require_project_member", mock_require) + monkeypatch.setattr( + "app.api.connections.validate_database_dsn_target", allow_target + ) def mock_encrypt(text: str) -> EncryptedBlob: return EncryptedBlob(ciphertext=b"encrypted_dsn", nonce=b"nonce") @@ -151,6 +158,39 @@ def fake_get_session() -> FakeSession: assert added_obj.project_space_uuid == project_uuid +def test_create_connection_rejects_unsafe_dsn_before_storage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject a link-local database target before encryption or persistence.""" + + async def mock_require(*args: object, **kwargs: object) -> str: + return "editor" + + encrypt = MagicMock() + monkeypatch.setattr("app.api.connections.require_project_member", mock_require) + monkeypatch.setattr("app.api.connections.encrypt_text", encrypt) + + session = FakeSession() + + def fake_get_session() -> FakeSession: + return session + + app.dependency_overrides[get_session] = fake_get_session + + response = TestClient(app).post( + f"/api/connections/by-project/{uuid.uuid4()}", + json={ + "conn_name": "Blocked target", + "dsn": "postgresql://user:secret@169.254.169.254:5432/app", + }, + ) + + assert response.status_code == 422 + assert response.json() == {"detail": "database DSN target is not allowed"} + encrypt.assert_not_called() + assert session.added == [] + + def test_create_connection_access_denied(monkeypatch: pytest.MonkeyPatch) -> None: async def mock_require(*args: object, **kwargs: object) -> str: raise HTTPException(status_code=403, detail="insufficient project role") diff --git a/backend/tests/test_api_dbml.py b/backend/tests/test_api_dbml.py new file mode 100644 index 000000000..06be1d95f --- /dev/null +++ b/backend/tests/test_api_dbml.py @@ -0,0 +1,33 @@ +"""API contracts for authenticated DBML conversion.""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi import HTTPException + +from app.api.dbml import convert_dbml +from app.auth import CurrentUser +from app.schemas import DbmlConvertIn + + +def _user() -> CurrentUser: + """Return an authenticated user for the pure conversion endpoint.""" + return CurrentUser( + user_account_uuid=uuid.uuid4(), subject="dbml-test", display_name=None + ) + + +@pytest.mark.asyncio +async def test_dbml_identifier_error_returns_fixed_non_reflecting_422() -> None: + """Malformed identifiers fail as fixed client errors without echoing input.""" + sensitive_marker = "sensitive-identifier-value" + body = DbmlConvertIn(dbml=f'Table "{sensitive_marker}\x00" {{\n id integer\n}}') + + with pytest.raises(HTTPException) as exc_info: + await convert_dbml(body=body, user=_user()) + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == "invalid DBML identifier" + assert sensitive_marker not in str(exc_info.value.detail) diff --git a/backend/tests/test_api_migration_plans.py b/backend/tests/test_api_migration_plans.py new file mode 100644 index 000000000..a3bd23f8c --- /dev/null +++ b/backend/tests/test_api_migration_plans.py @@ -0,0 +1,864 @@ +from __future__ import annotations + +import datetime as dt +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException +from sqlalchemy import UniqueConstraint +from sqlalchemy.exc import IntegrityError + +from app.api.migration_plans import ( + EXPIRED_PLAN_RETENTION, + MAX_PLAN_STATEMENTS, + _cleanup_expired_unreferenced_plans, + _load_plan_inputs, + create_migration_plan, + get_migration_plan, +) +from app.auth import CurrentUser +from app.forward.migration_plan import compile_migration_plan +from app.forward.schema_model import SchemaModelValidationError +from app.models import MigrationPlan +from app.schemas import MigrationPlanCreateIn, MigrationPlanOut + + +def _user() -> CurrentUser: + return CurrentUser(uuid.uuid4(), "planner", "Planner") + + +def _model() -> dict: + return {"format_version": 1, "postgresql_major": 18, "schemas": []} + + +class FakeSession: + def __init__(self) -> None: + self.added: list[object] = [] + self.scalar = AsyncMock(return_value=None) + self.flush = AsyncMock() + self.commit = AsyncMock() + self.rollback = AsyncMock() + self.get = AsyncMock(return_value=None) + self.execute = AsyncMock(return_value=SimpleNamespace(rowcount=0)) + + def add(self, value: object) -> None: + self.added.append(value) + + +def _stored_plan() -> SimpleNamespace: + plan_json = compile_migration_plan(_model(), _model()) + return SimpleNamespace( + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + schema_model_revision_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + statement_digest=plan_json["plan_digest"], + base_digest=plan_json["base_digest"], + target_digest=plan_json["target_digest"], + compiler_version=plan_json["compiler_version"], + plan_json=plan_json, + created_by_user_uuid=uuid.uuid4(), + created_at=dt.datetime.now(dt.timezone.utc), + expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=1), + ) + + +@pytest.mark.asyncio +async def test_get_migration_plan_returns_immutable_member_preview() -> None: + plan = _stored_plan() + session = FakeSession() + session.get.return_value = plan + + with patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ) as membership: + out = await get_migration_plan( + migration_plan_uuid=plan.migration_plan_uuid, + user=_user(), + session=session, + ) + + assert out.migration_plan_uuid == plan.migration_plan_uuid + assert out.plan_digest == plan.statement_digest + assert out.project_space_uuid == plan.project_space_uuid + assert out.schema_model_revision_uuid == plan.schema_model_revision_uuid + assert out.db_connection_uuid == plan.db_connection_uuid + assert out.base_schema_snapshot_uuid == plan.base_schema_snapshot_uuid + assert out.snapshot_contract_version == 1 + assert out.postgresql_major == 18 + assert out.created_by_user_uuid == plan.created_by_user_uuid + assert out.created_at == plan.created_at + membership.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_migration_plan_rejects_tampered_persisted_payload() -> None: + plan = _stored_plan() + plan.plan_json["can_dry_run"] = False + session = FakeSession() + session.get.return_value = plan + + with patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await get_migration_plan( + migration_plan_uuid=plan.migration_plan_uuid, + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "migration plan integrity verification failed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("field", "value"), + [ + ("compiler_version", "pg-erd-forward/tampered"), + ("base_digest", "0" * 64), + ("target_digest", "0" * 64), + ], +) +async def test_get_migration_plan_rejects_denormalized_binding_mismatch( + field: str, value: str +) -> None: + plan = _stored_plan() + setattr(plan, field, value) + session = FakeSession() + session.get.return_value = plan + + with patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await get_migration_plan( + migration_plan_uuid=plan.migration_plan_uuid, + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "migration plan integrity verification failed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing", [True, False]) +async def test_get_migration_plan_masks_missing_and_non_member_identity( + missing: bool, +) -> None: + plan = _stored_plan() + session = FakeSession() + session.get.return_value = None if missing else plan + denied = HTTPException(status_code=403, detail="project access denied") + + with patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock(side_effect=denied), + ): + with pytest.raises(HTTPException) as exc_info: + await get_migration_plan( + migration_plan_uuid=plan.migration_plan_uuid, + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "migration plan not found" + + +def _inputs() -> tuple: + project_uuid = uuid.uuid4() + connection_uuid = uuid.uuid4() + snapshot_uuid = uuid.uuid4() + model_uuid = uuid.uuid4() + model = SimpleNamespace( + schema_model_uuid=model_uuid, project_space_uuid=project_uuid + ) + revision = SimpleNamespace( + schema_model_revision_uuid=uuid.uuid4(), + schema_model_uuid=model_uuid, + revision_digest="a" * 64, + model_json=_model(), + ) + connection = SimpleNamespace( + db_connection_uuid=connection_uuid, project_space_uuid=project_uuid + ) + snapshot = SimpleNamespace( + schema_snapshot_uuid=snapshot_uuid, + project_space_uuid=project_uuid, + db_connection_uuid=connection_uuid, + status="succeeded", + ) + snapshot_data = SimpleNamespace(snapshot_json={"snapshot_contract_version": 1, "server_version": "18.2", "relations": [], "columns": [], "pk_columns": [], "fk_edges": [], "indexes": []}) + return model, revision, connection, snapshot, snapshot_data + + +@pytest.mark.asyncio +async def test_create_migration_plan_binds_revision_connection_snapshot_and_hashes() -> None: + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + session = FakeSession() + user = _user() + run_sync = AsyncMock(side_effect=lambda function, *args: function(*args)) + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ) as membership, patch( + "app.api.migration_plans.anyio.to_thread.run_sync", new=run_sync + ): + out = await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=user, + session=session, + ) + + stored = next(item for item in session.added if isinstance(item, MigrationPlan)) + assert stored.schema_model_revision_uuid == revision.schema_model_revision_uuid + assert stored.db_connection_uuid == connection.db_connection_uuid + assert stored.base_schema_snapshot_uuid == snapshot.schema_snapshot_uuid + assert stored.statement_digest == out.plan_digest + assert stored.compiler_version == "pg-erd-forward/v1" + assert out.can_dry_run is True + assert out.proposed_statements == [] + membership.assert_awaited_once() + run_sync.assert_awaited_once() + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_migration_plan_rejects_cross_project_binding() -> None: + model, revision, connection, snapshot, snapshot_data = _inputs() + connection.project_space_uuid = uuid.uuid4() + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock( + return_value=(model, revision, connection, snapshot, snapshot_data) + ), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=FakeSession(), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "migration plan input not found" + + +def test_migration_plans_do_not_use_plan_digest_as_database_idempotency_key() -> None: + """The same SQL may be planned for two targets or recreated after expiry.""" + unique_column_sets = { + tuple(column.name for column in constraint.columns) + for constraint in MigrationPlan.__table__.constraints + if isinstance(constraint, UniqueConstraint) + } + + assert ("project_space_uuid", "statement_digest") not in unique_column_sets + assert ( + "schema_model_revision_uuid", + "db_connection_uuid", + "base_schema_snapshot_uuid", + "statement_digest", + ) in unique_column_sets + assert ("expires_at",) in { + tuple(column.name for column in index.columns) + for index in MigrationPlan.__table__.indexes + } + + +def test_migration_plan_openapi_contract_uses_structured_models() -> None: + schema = MigrationPlanOut.model_json_schema() + + for binding in ( + "project_space_uuid", + "schema_model_revision_uuid", + "db_connection_uuid", + "base_schema_snapshot_uuid", + "snapshot_contract_version", + "postgresql_major", + "created_by_user_uuid", + "created_at", + ): + assert binding in schema["required"] + + assert schema["properties"]["statements"]["items"] == { + "$ref": "#/$defs/MigrationPlanStatement" + } + assert schema["properties"]["blockers"]["items"] == { + "$ref": "#/$defs/MigrationPlanBlocker" + } + assert schema["properties"]["risk_summary"] == { + "$ref": "#/$defs/MigrationPlanRiskSummary" + } + + +def test_migration_plan_preview_route_is_published_in_openapi() -> None: + from app.main import app + + operation = app.openapi()["paths"][ + "/api/migration-plans/{migration_plan_uuid}" + ]["get"] + + assert operation["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/MigrationPlanOut" + } + + +def test_dry_run_creation_route_publishes_exact_intent_contract() -> None: + """OpenAPI exposes the digest body and required idempotency header.""" + + from app.main import app + + operation = app.openapi()["paths"][ + "/api/migration-plans/{migration_plan_uuid}/dry-runs" + ]["post"] + parameters = {item["name"]: item for item in operation["parameters"]} + + assert parameters["Idempotency-Key"]["in"] == "header" + assert parameters["Idempotency-Key"]["required"] is True + assert operation["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/MigrationRunCreateIn" + } + assert operation["responses"]["202"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/MigrationRunActionOut" + } + + +@pytest.mark.asyncio +async def test_create_migration_plan_reuses_unexpired_immutable_identity() -> None: + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + compiled = compile_migration_plan(_model(), _model()) + existing = SimpleNamespace( + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=inputs[0].project_space_uuid, + schema_model_revision_uuid=revision.schema_model_revision_uuid, + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + statement_digest=compiled["plan_digest"], + base_digest=compiled["base_digest"], + target_digest=compiled["target_digest"], + compiler_version=compiled["compiler_version"], + plan_json=compiled, + created_by_user_uuid=uuid.uuid4(), + created_at=dt.datetime.now(dt.timezone.utc), + expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=1), + ) + session = FakeSession() + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans.compile_migration_plan", return_value=compiled + ), patch( + "app.api.migration_plans._existing_plan", + new=AsyncMock(return_value=existing), + ): + out = await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=session, + ) + + assert out.migration_plan_uuid == existing.migration_plan_uuid + assert session.added == [] + session.flush.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_migration_plan_rejects_recently_expired_identity() -> None: + """An expired identity remains a conflict until its retention window ends.""" + + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + existing = _stored_plan() + existing.expires_at = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=1) + session = FakeSession() + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans._existing_plan", + new=AsyncMock(return_value=existing), + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert "expired" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_migration_plan_reuses_concurrent_insert_winner() -> None: + """A losing concurrent insert rolls back and returns the immutable winner.""" + + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + winner = _stored_plan() + session = FakeSession() + session.commit.side_effect = IntegrityError( + "INSERT INTO migration_plan", {}, RuntimeError("duplicate key") + ) + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans._existing_plan", + new=AsyncMock(side_effect=[None, winner]), + ): + out = await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=session, + ) + + assert out.migration_plan_uuid == winner.migration_plan_uuid + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_migration_plan_reraises_when_concurrent_winner_is_absent() -> None: + """A uniqueness failure without a visible winner preserves the DB error.""" + + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + session = FakeSession() + error = IntegrityError( + "INSERT INTO migration_plan", {}, RuntimeError("duplicate key") + ) + session.commit.side_effect = error + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans._existing_plan", + new=AsyncMock(side_effect=[None, None]), + ): + with pytest.raises(IntegrityError) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=session, + ) + + assert exc_info.value is error + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cleanup_deletes_only_old_unreferenced_plans_in_one_project() -> None: + """Retention cleanup is tenant-scoped and excludes plans with run history.""" + + project_uuid = uuid.uuid4() + now = dt.datetime(2026, 8, 11, tzinfo=dt.timezone.utc) + session = FakeSession() + session.execute.return_value = SimpleNamespace(rowcount=2) + + deleted = await _cleanup_expired_unreferenced_plans( + session, project_space_uuid=project_uuid, now=now + ) + + statement = session.execute.await_args.args[0] + compiled = statement.compile() + assert project_uuid in compiled.params.values() + assert now - EXPIRED_PLAN_RETENTION in compiled.params.values() + assert "NOT (EXISTS" in str(compiled) + assert deleted == 2 + session.commit.assert_awaited_once() + + session.execute.reset_mock() + session.commit.reset_mock() + session.execute.return_value = SimpleNamespace(rowcount=0) + assert ( + await _cleanup_expired_unreferenced_plans( + session, project_space_uuid=project_uuid, now=now + ) + == 0 + ) + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing_index", range(5)) +async def test_load_plan_inputs_returns_none_for_every_missing_resource( + missing_index: int, +) -> None: + """Every absent revision/model/connection/snapshot/data binding is masked.""" + + revision_uuid = uuid.uuid4() + body = MigrationPlanCreateIn( + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + ) + revision = SimpleNamespace(schema_model_uuid=uuid.uuid4()) + resources: list[object | None] = [ + revision, + SimpleNamespace(), + SimpleNamespace(), + SimpleNamespace(), + SimpleNamespace(), + ] + resources[missing_index] = None + session = FakeSession() + session.get.side_effect = resources + + assert await _load_plan_inputs(session, revision_uuid, body) is None + + +@pytest.mark.asyncio +async def test_load_plan_inputs_returns_complete_binding() -> None: + """A complete immutable input set is returned in authority order.""" + + inputs = _inputs() + _, revision, _, _, _ = inputs + session = FakeSession() + session.get.side_effect = [revision, inputs[0], *inputs[2:]] + body = MigrationPlanCreateIn( + db_connection_uuid=inputs[2].db_connection_uuid, + base_schema_snapshot_uuid=inputs[3].schema_snapshot_uuid, + ) + + loaded = await _load_plan_inputs( + session, revision.schema_model_revision_uuid, body + ) + + assert loaded == inputs + + +@pytest.mark.asyncio +async def test_get_migration_plan_preserves_non_authorization_http_error() -> None: + """Only project denial is IDOR-masked; infrastructure HTTP errors survive.""" + + plan = _stored_plan() + session = FakeSession() + session.get.return_value = plan + upstream = HTTPException(status_code=503, detail="membership unavailable") + + with patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock(side_effect=upstream), + ): + with pytest.raises(HTTPException) as exc_info: + await get_migration_plan( + migration_plan_uuid=plan.migration_plan_uuid, + user=_user(), + session=session, + ) + + assert exc_info.value is upstream + + +@pytest.mark.asyncio +async def test_create_migration_plan_masks_missing_input() -> None: + """A missing input set exposes no partial resource identity.""" + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=None), + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=uuid.uuid4(), + body=MigrationPlanCreateIn( + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + ), + user=_user(), + session=FakeSession(), + ) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_migration_plan_preserves_membership_service_error() -> None: + """Non-denial membership errors are not mislabeled as missing inputs.""" + + inputs = _inputs() + upstream = HTTPException(status_code=503, detail="membership unavailable") + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock(side_effect=upstream), + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=inputs[1].schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=inputs[2].db_connection_uuid, + base_schema_snapshot_uuid=inputs[3].schema_snapshot_uuid, + ), + user=_user(), + session=FakeSession(), + ) + + assert exc_info.value is upstream + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("case", "expected_detail"), + [ + ("wrong_connection", "base snapshot was not captured"), + ("snapshot_running", "base snapshot is not usable"), + ("wrong_revision", "model revision binding is invalid"), + ], +) +async def test_create_migration_plan_rejects_invalid_input_bindings( + case: str, + expected_detail: str, +) -> None: + """Connection, snapshot status, and revision identity fail independently.""" + + inputs = list(_inputs()) + if case == "wrong_connection": + inputs[3].db_connection_uuid = uuid.uuid4() + elif case == "snapshot_running": + inputs[3].status = "running" + else: + inputs[1].schema_model_uuid = uuid.uuid4() + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=tuple(inputs)), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=inputs[1].schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=inputs[2].db_connection_uuid, + base_schema_snapshot_uuid=inputs[3].schema_snapshot_uuid, + ), + user=_user(), + session=FakeSession(), + ) + + assert exc_info.value.status_code == 422 + assert expected_detail in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_migration_plan_maps_compiler_validation_to_422() -> None: + """Canonical snapshot/model validation errors remain non-executable input.""" + + inputs = _inputs() + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans.anyio.to_thread.run_sync", + new=AsyncMock(side_effect=SchemaModelValidationError("unsupported catalog")), + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=inputs[1].schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=inputs[2].db_connection_uuid, + base_schema_snapshot_uuid=inputs[3].schema_snapshot_uuid, + ), + user=_user(), + session=FakeSession(), + ) + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == "unsupported catalog" + + +@pytest.mark.asyncio +async def test_create_migration_plan_rejects_expired_concurrent_winner() -> None: + """A concurrently selected expired winner never becomes a fresh preview.""" + + inputs = _inputs() + winner = _stored_plan() + winner.expires_at = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=1) + session = FakeSession() + session.commit.side_effect = IntegrityError( + "INSERT INTO migration_plan", {}, RuntimeError("duplicate key") + ) + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans._existing_plan", + new=AsyncMock(side_effect=[None, winner]), + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=inputs[1].schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=inputs[2].db_connection_uuid, + base_schema_snapshot_uuid=inputs[3].schema_snapshot_uuid, + ), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + session.rollback.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_migration_plan_masks_non_member_as_not_found() -> None: + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + denied = HTTPException(status_code=403, detail="project access denied") + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock(side_effect=denied), + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=FakeSession(), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "migration plan input not found" + + +@pytest.mark.asyncio +async def test_create_migration_plan_rejects_excessive_statement_count() -> None: + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + compiled = { + "compiler_version": "pg-erd-forward/v1", + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "plan_digest": "c" * 64, + "statements": [{} for _ in range(MAX_PLAN_STATEMENTS + 1)], + "blockers": [], + "risk_summary": {"safe": 0, "warning": 0, "destructive": 0}, + "can_dry_run": True, + "requires_destructive_confirmation": False, + } + session = FakeSession() + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans.compile_migration_plan", return_value=compiled + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 413 + assert exc_info.value.detail == "migration plan is too large" + assert session.added == [] + + +@pytest.mark.asyncio +async def test_create_migration_plan_counts_review_only_proposals_toward_limit() -> None: + inputs = _inputs() + _, revision, connection, snapshot, _ = inputs + compiled = { + "compiler_version": "pg-erd-forward/v1", + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "plan_digest": "c" * 64, + "statements": [], + "proposed_statements": [{} for _ in range(MAX_PLAN_STATEMENTS + 1)], + "blockers": [{"code": "blocked"}], + "risk_summary": {"safe": 0, "warning": 0, "destructive": 0}, + "can_dry_run": False, + "requires_destructive_confirmation": False, + } + + with patch( + "app.api.migration_plans._load_plan_inputs", + new=AsyncMock(return_value=inputs), + ), patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), patch( + "app.api.migration_plans.compile_migration_plan", return_value=compiled + ): + with pytest.raises(HTTPException) as exc_info: + await create_migration_plan( + schema_model_revision_uuid=revision.schema_model_revision_uuid, + body=MigrationPlanCreateIn( + db_connection_uuid=connection.db_connection_uuid, + base_schema_snapshot_uuid=snapshot.schema_snapshot_uuid, + ), + user=_user(), + session=FakeSession(), + ) + + assert exc_info.value.status_code == 413 diff --git a/backend/tests/test_api_migration_runs.py b/backend/tests/test_api_migration_runs.py new file mode 100644 index 000000000..4d2767dcf --- /dev/null +++ b/backend/tests/test_api_migration_runs.py @@ -0,0 +1,1275 @@ +from __future__ import annotations + +import datetime as dt +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, call, patch + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from app.api.migration_plans import _request_id as _plan_request_id +from app.api.migration_plans import create_apply_run, create_dry_run +from app.api.migration_runs import ( + MAX_RETURNED_RUN_EVENTS, + _request_id, + cancel_migration_run, + get_migration_run, +) +from app.auth import CurrentUser +from app.forward.migration_run import ( + MigrationRunCancellation, + MigrationRunContractError, + MigrationRunCreation, + digest_run_event, +) +from app.models import ( + DbConnection, + MigrationPlan, + MigrationRun, + MigrationRunEvent, + SchemaModel, + SchemaModelRevision, +) +from app.schemas import ( + MigrationApplyRunCreateIn, + MigrationRunCancelIn, + MigrationRunCreateIn, + MigrationRunOut, +) + + +def _user() -> CurrentUser: + return CurrentUser(uuid.uuid4(), "reviewer", "Reviewer") + + +def _request(request_id: str = "migration-request-123") -> Request: + """Build an HTTP request carrying the middleware-selected correlation ID.""" + + request = Request( + { + "type": "http", + "method": "POST", + "scheme": "https", + "path": "/api/migration-runs/run/cancel", + "raw_path": b"/api/migration-runs/run/cancel", + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 12345), + "server": ("testserver", 443), + "root_path": "", + "http_version": "1.1", + } + ) + request.state.request_id = request_id + return request + + +def test_request_id_uses_safe_fallback_without_observability_middleware() -> None: + """A directly mounted router still produces a bounded correlation identity.""" + + expected = uuid.uuid4() + request = _request() + del request.scope["state"]["request_id"] + with patch("app.api.migration_runs.uuid.uuid4", return_value=expected): + assert _request_id(request) == str(expected) + + +def test_plan_action_request_id_uses_safe_fallback_without_middleware() -> None: + """The plan router also bounds correlation identity when mounted alone.""" + + expected = uuid.uuid4() + request = _request() + del request.scope["state"]["request_id"] + with patch("app.api.migration_plans.uuid.uuid4", return_value=expected): + assert _plan_request_id(request) == str(expected) + + +def _run() -> MigrationRun: + return MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="sandbox_running", + state_version=2, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + observed_base_digest=None, + evidence_json={"sandbox_version": "postgresql-18"}, + error_code=None, + created_at=dt.datetime(2026, 8, 10, tzinfo=dt.timezone.utc), + updated_at=dt.datetime(2026, 8, 10, 0, 1, tzinfo=dt.timezone.utc), + started_at=dt.datetime(2026, 8, 10, 0, 1, tzinfo=dt.timezone.utc), + finished_at=None, + ) + + +def _plan() -> MigrationPlan: + """Return one immutable plan fixture for public dry-run creation.""" + + return MigrationPlan( + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + schema_model_revision_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + statement_digest="b" * 64, + base_digest="c" * 64, + target_digest="d" * 64, + compiler_version="pg-erd-cloud/1", + plan_json={}, + created_by_user_uuid=uuid.uuid4(), + expires_at=dt.datetime(2026, 8, 12, tzinfo=dt.timezone.utc), + ) + + +def _current_revision(plan: MigrationPlan) -> tuple[SchemaModelRevision, SchemaModel]: + """Return the exact model revision pair an apply request must lock.""" + + model = SchemaModel( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + model_name="reviewed model", + current_revision_number=3, + created_by_user_uuid=uuid.uuid4(), + ) + revision = SchemaModelRevision( + schema_model_revision_uuid=plan.schema_model_revision_uuid, + schema_model_uuid=model.schema_model_uuid, + revision_number=model.current_revision_number, + revision_digest=plan.target_digest, + model_json={}, + created_by_user_uuid=uuid.uuid4(), + ) + return revision, model + + +@pytest.mark.asyncio +async def test_create_dry_run_persists_correlated_editor_intent() -> None: + """An editor gets one accepted queued identity after transaction commit.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + creation = MigrationRunCreation( + migration_run_uuid=uuid.uuid4(), + state="queued", + state_version=1, + cancellation_requested=False, + reused=False, + ) + user = _user() + with ( + patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ) as membership, + patch( + "app.api.migration_plans.create_migration_run", + new=AsyncMock(return_value=creation), + ) as writer, + ): + out = await create_dry_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationRunCreateIn(plan_digest=plan.statement_digest), + request=_request(), + idempotency_key="dry-run-request-1", + user=user, + session=session, + ) + + assert out.migration_run_uuid == creation.migration_run_uuid + assert out.state == "queued" + assert out.state_version == 1 + assert out.cancellation_requested is False + assert out.reused is False + membership.assert_awaited_once_with( + session, + plan.project_space_uuid, + user.user_account_uuid, + minimum_role="editor", + ) + writer.assert_awaited_once_with( + session, + plan=plan, + run_kind="dry_run", + idempotency_key="dry-run-request-1", + requested_by_user_uuid=user.user_account_uuid, + evidence={"request_id": "migration-request-123", "request_source": "api"}, + ) + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_apply_run_persists_deployer_confirmation_without_dispatch() -> None: + """A deployer can persist exact reviewed apply intent, never execute it.""" + + plan = _plan() + passed_run = _run() + passed_run.migration_run_uuid = uuid.uuid4() + passed_run.project_space_uuid = plan.project_space_uuid + passed_run.migration_plan_uuid = plan.migration_plan_uuid + passed_run.run_kind = "dry_run" + passed_run.state = "passed" + passed_run.plan_digest = plan.statement_digest + passed_run.observed_base_digest = plan.base_digest + passed_run.cancellation_requested = False + connection = DbConnection( + db_connection_uuid=plan.db_connection_uuid, + project_space_uuid=plan.project_space_uuid, + conn_name="Production Primary", + dsn_ciphertext=b"ciphertext", + dsn_nonce=b"nonce", + ) + revision, model = _current_revision(plan) + session = SimpleNamespace( + get=AsyncMock(side_effect=[plan, revision, model, passed_run, connection]), + commit=AsyncMock(), + ) + creation = MigrationRunCreation( + migration_run_uuid=uuid.uuid4(), + state="queued", + state_version=1, + cancellation_requested=False, + reused=False, + ) + user = _user() + with ( + patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ) as membership, + patch( + "app.api.migration_plans.create_migration_run", + new=AsyncMock(return_value=creation), + ) as writer, + ): + out = await create_apply_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationApplyRunCreateIn( + plan_digest=plan.statement_digest, + passed_dry_run_uuid=passed_run.migration_run_uuid, + target_connection_name=connection.conn_name, + destructive_acknowledged=False, + ), + request=_request(), + idempotency_key="apply-request-1", + user=user, + session=session, + ) + + assert out.migration_run_uuid == creation.migration_run_uuid + membership.assert_awaited_once_with( + session, + plan.project_space_uuid, + user.user_account_uuid, + minimum_role="deployer", + ) + writer.assert_awaited_once_with( + session, + plan=plan, + run_kind="apply", + idempotency_key="apply-request-1", + requested_by_user_uuid=user.user_account_uuid, + evidence={"request_id": "migration-request-123", "request_source": "api"}, + passed_dry_run=passed_run, + connection=connection, + typed_connection_name="Production Primary", + destructive_acknowledged=False, + model_revision=revision, + schema_model=model, + ) + assert session.get.await_args_list[2] == call( + SchemaModel, model.schema_model_uuid, with_for_update=True + ) + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("membership_error", "status_code", "code"), + [ + ("insufficient project role", 403, "run_role_required"), + ("denied", 404, "migration_plan_not_found"), + ], +) +async def test_create_apply_run_enforces_deployer_and_masks_non_members( + membership_error: str, status_code: int, code: str +) -> None: + """Apply intent authority is server-side and tenant identities stay masked.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + with ( + patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock( + side_effect=HTTPException(status_code=403, detail=membership_error) + ), + ), + patch( + "app.api.migration_plans.create_migration_run", new_callable=AsyncMock + ) as writer, + ): + with pytest.raises(HTTPException) as exc_info: + await create_apply_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationApplyRunCreateIn( + plan_digest=plan.statement_digest, + passed_dry_run_uuid=uuid.uuid4(), + target_connection_name="Production Primary", + destructive_acknowledged=False, + ), + request=_request(), + idempotency_key="apply-request-role", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == status_code + assert exc_info.value.detail["code"] == code + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_apply_run_rejects_stale_plan_before_loading_evidence() -> None: + """A changed preview digest cannot select dry-run or target evidence.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + with ( + patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), + patch( + "app.api.migration_plans.create_migration_run", new_callable=AsyncMock + ) as writer, + ): + with pytest.raises(HTTPException) as exc_info: + await create_apply_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationApplyRunCreateIn( + plan_digest="e" * 64, + passed_dry_run_uuid=uuid.uuid4(), + target_connection_name="Production Primary", + destructive_acknowledged=False, + ), + request=_request(), + idempotency_key="apply-request-stale", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail["code"] == "stale_plan" + assert session.get.await_count == 1 + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("contract_error", "error_code", "status_code", "code"), + [ + ("passed dry run wording may change", "passed_dry_run_invalid", 409, "passed_dry_run_invalid"), + ("target confirmation wording may change", "target_confirmation_mismatch", 409, "target_confirmation_mismatch"), + ("destructive confirmation wording may change", "destructive_confirmation_mismatch", 409, "destructive_confirmation_mismatch"), + ("apply evidence wording may change", "apply_confirmation_invalid", 422, "apply_confirmation_invalid"), + ("stale model wording may change", "stale_revision", 409, "stale_revision"), + ("idempotency conflict wording may change", "idempotency_key_conflict", 409, "idempotency_key_conflict"), + ], +) +async def test_create_apply_run_maps_contract_failures_without_source_values( + contract_error: str, error_code: str, status_code: int, code: str +) -> None: + """Rejected confirmation inputs produce stable bounded action errors.""" + + plan = _plan() + passed_run = _run() + revision, model = _current_revision(plan) + connection = DbConnection( + db_connection_uuid=plan.db_connection_uuid, + project_space_uuid=plan.project_space_uuid, + conn_name="Production Primary", + dsn_ciphertext=b"ciphertext", + dsn_nonce=b"nonce", + ) + session = SimpleNamespace( + get=AsyncMock(side_effect=[plan, revision, model, passed_run, connection]), + commit=AsyncMock(), + ) + with ( + patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), + patch( + "app.api.migration_plans.create_migration_run", + new=AsyncMock( + side_effect=MigrationRunContractError( + contract_error, code=error_code + ) + ), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await create_apply_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationApplyRunCreateIn( + plan_digest=plan.statement_digest, + passed_dry_run_uuid=passed_run.migration_run_uuid, + target_connection_name=connection.conn_name, + destructive_acknowledged=False, + ), + request=_request(), + idempotency_key="apply-request-rejected", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == status_code + assert exc_info.value.detail == { + "code": code, + "detail": "apply intent creation was rejected", + "correlation_id": "migration-request-123", + } + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_dry_run_reuse_returns_durable_cancellation_intent() -> None: + """Idempotent reuse must not hide a cancellation already stored on the run.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + creation = MigrationRunCreation( + migration_run_uuid=uuid.uuid4(), + state="queued", + state_version=2, + cancellation_requested=True, + reused=True, + ) + with ( + patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), + patch( + "app.api.migration_plans.create_migration_run", + new=AsyncMock(return_value=creation), + ), + ): + out = await create_dry_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationRunCreateIn(plan_digest=plan.statement_digest), + request=_request(), + idempotency_key="cancelled-retry", + user=_user(), + session=session, + ) + + assert out.reused is True + assert out.state_version == 2 + assert out.cancellation_requested is True + + +@pytest.mark.asyncio +async def test_create_dry_run_masks_plan_from_non_member() -> None: + """A plan UUID cannot disclose a project to a non-member.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + with ( + patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock(side_effect=HTTPException(status_code=403, detail="denied")), + ), + patch( + "app.api.migration_plans.create_migration_run", new_callable=AsyncMock + ) as writer, + ): + with pytest.raises(HTTPException) as exc_info: + await create_dry_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationRunCreateIn(plan_digest=plan.statement_digest), + request=_request(), + idempotency_key="dry-run-request-1", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == "migration_plan_not_found" + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_dry_run_returns_not_found_for_missing_plan() -> None: + """Unknown plan identities receive the same stable masked response.""" + + session = SimpleNamespace(get=AsyncMock(return_value=None), commit=AsyncMock()) + with patch( + "app.api.migration_plans.create_migration_run", new_callable=AsyncMock + ) as writer: + with pytest.raises(HTTPException) as exc_info: + await create_dry_run( + migration_plan_uuid=uuid.uuid4(), + body=MigrationRunCreateIn(plan_digest="b" * 64), + request=_request(), + idempotency_key="dry-run-request-1", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == "migration_plan_not_found" + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_dry_run_rejects_viewer_without_disclosing_plan_data() -> None: + """An authenticated viewer receives the stable editor-role rejection.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + with ( + patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock( + side_effect=HTTPException( + status_code=403, detail="insufficient project role" + ) + ), + ), + patch( + "app.api.migration_plans.create_migration_run", new_callable=AsyncMock + ) as writer, + ): + with pytest.raises(HTTPException) as exc_info: + await create_dry_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationRunCreateIn(plan_digest=plan.statement_digest), + request=_request(), + idempotency_key="dry-run-request-1", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == { + "code": "run_role_required", + "detail": "editor role required", + "correlation_id": "migration-request-123", + } + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_dry_run_preserves_non_authorization_http_failures() -> None: + """Unexpected dependency HTTP failures are not misclassified as IDOR cases.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + upstream = HTTPException(status_code=503, detail="membership unavailable") + with ( + patch( + "app.api.migration_plans.require_project_member", + new=AsyncMock(side_effect=upstream), + ), + patch( + "app.api.migration_plans.create_migration_run", new_callable=AsyncMock + ) as writer, + ): + with pytest.raises(HTTPException) as exc_info: + await create_dry_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationRunCreateIn(plan_digest=plan.statement_digest), + request=_request(), + idempotency_key="dry-run-request-1", + user=_user(), + session=session, + ) + + assert exc_info.value is upstream + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_dry_run_rejects_stale_preview_digest_after_authorization( +) -> None: + """A stale tab cannot queue a plan identity different from its preview.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + with ( + patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ) as membership, + patch( + "app.api.migration_plans.create_migration_run", new_callable=AsyncMock + ) as writer, + ): + with pytest.raises(HTTPException) as exc_info: + await create_dry_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationRunCreateIn(plan_digest="e" * 64), + request=_request(), + idempotency_key="dry-run-request-1", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == { + "code": "stale_plan", + "detail": "migration plan digest does not match", + "correlation_id": "migration-request-123", + } + membership.assert_awaited_once() + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("message", "status_code", "code"), + [ + ("migration plan integrity verification failed", 409, "plan_integrity_invalid"), + ("migration plan expired", 409, "plan_expired"), + ("migration plan cannot be dry-run", 409, "plan_not_dry_runnable"), + ("idempotency key conflict", 409, "idempotency_key_conflict"), + ("idempotency winner is unavailable", 503, "run_creation_unavailable"), + ("idempotency key length is invalid", 422, "idempotency_key_invalid"), + ], +) +async def test_create_dry_run_maps_contract_failures_to_stable_errors( + message: str, status_code: int, code: str +) -> None: + """Creation failures do not expose internal plan or persistence details.""" + + plan = _plan() + session = SimpleNamespace(get=AsyncMock(return_value=plan), commit=AsyncMock()) + with ( + patch( + "app.api.migration_plans.require_project_member", new_callable=AsyncMock + ), + patch( + "app.api.migration_plans.create_migration_run", + new=AsyncMock(side_effect=MigrationRunContractError(message)), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await create_dry_run( + migration_plan_uuid=plan.migration_plan_uuid, + body=MigrationRunCreateIn(plan_digest=plan.statement_digest), + request=_request(), + idempotency_key="dry-run-request-1", + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == status_code + assert exc_info.value.detail == { + "code": code, + "detail": "dry-run creation was rejected", + "correlation_id": "migration-request-123", + } + session.commit.assert_not_awaited() + + +def _events(run: MigrationRun) -> list[MigrationRunEvent]: + events = [ + MigrationRunEvent( + migration_run_event_uuid=uuid.uuid4(), + migration_run_uuid=run.migration_run_uuid, + sequence_number=1, + event_type="run_queued", + state_before=None, + state_after="queued", + evidence_json={"request_source": "review_ui"}, + previous_event_digest=None, + event_digest="", + actor_user_uuid=run.requested_by_user_uuid, + created_at=run.created_at, + ), + MigrationRunEvent( + migration_run_event_uuid=uuid.uuid4(), + migration_run_uuid=run.migration_run_uuid, + sequence_number=2, + event_type="sandbox_started", + state_before="queued", + state_after="sandbox_running", + evidence_json={"sandbox_version": "postgresql-18"}, + previous_event_digest="", + event_digest="", + actor_user_uuid=None, + created_at=run.updated_at, + ), + ] + previous_digest = None + for event in events: + event.previous_event_digest = previous_digest + event.event_digest = digest_run_event( + migration_run_uuid=event.migration_run_uuid, + sequence_number=event.sequence_number, + event_type=event.event_type, + state_before=event.state_before, + state_after=event.state_after, + evidence=event.evidence_json, + actor_user_uuid=event.actor_user_uuid, + created_at=event.created_at, + previous_event_digest=previous_digest, + ) + previous_digest = event.event_digest + run.latest_event_digest = events[-1].event_digest + return events + + +def _event_digest(event: MigrationRunEvent) -> str: + """Recompute one fixture event after an intentional test mutation.""" + + return digest_run_event( + migration_run_uuid=event.migration_run_uuid, + sequence_number=event.sequence_number, + event_type=event.event_type, + state_before=event.state_before, + state_after=event.state_after, + evidence=event.evidence_json, + actor_user_uuid=event.actor_user_uuid, + created_at=event.created_at, + previous_event_digest=event.previous_event_digest, + ) + + +@pytest.mark.asyncio +async def test_get_migration_run_returns_bounded_authorized_history() -> None: + """A member can poll exact state identity and sanitized ordered evidence.""" + + run = _run() + events = _events(run) + session = SimpleNamespace( + get=AsyncMock(return_value=run), + scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: events)), + ) + with patch( + "app.api.migration_runs.require_project_member", new_callable=AsyncMock + ) as membership: + out = await get_migration_run( + migration_run_uuid=run.migration_run_uuid, + user=_user(), + session=session, + ) + + assert out.migration_run_uuid == run.migration_run_uuid + assert out.migration_plan_uuid == run.migration_plan_uuid + assert out.state == "sandbox_running" + assert out.state_version == 2 + assert [event.sequence_number for event in out.events] == [1, 2] + assert out.events[-1].evidence == {"sandbox_version": "postgresql-18"} + assert out.events[-1].previous_event_digest == out.events[0].event_digest + assert out.events[-1].event_digest == run.latest_event_digest + membership.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_migration_run_masks_non_member_as_not_found() -> None: + """A run UUID cannot disclose a project to a non-member.""" + + run = _run() + session = SimpleNamespace(get=AsyncMock(return_value=run), scalars=AsyncMock()) + with patch( + "app.api.migration_runs.require_project_member", + new=AsyncMock(side_effect=HTTPException(status_code=403, detail="denied")), + ): + with pytest.raises(HTTPException) as exc_info: + await get_migration_run( + migration_run_uuid=run.migration_run_uuid, + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "migration run not found" + session.scalars.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancel_migration_run_persists_correlated_editor_intent() -> None: + """An editor gets one accepted resource after the CAS event commits.""" + + run = _run() + session = SimpleNamespace( + get=AsyncMock(return_value=run), + commit=AsyncMock(), + ) + cancellation = MigrationRunCancellation( + state=run.state, + state_version=run.state_version + 1, + reused=False, + ) + with ( + patch( + "app.api.migration_runs.require_project_member", + new_callable=AsyncMock, + ) as membership, + patch( + "app.api.migration_runs.request_migration_run_cancellation", + new=AsyncMock(return_value=cancellation), + ) as writer, + ): + out = await cancel_migration_run( + migration_run_uuid=run.migration_run_uuid, + body=MigrationRunCancelIn(expected_state_version=run.state_version), + request=_request(), + user=_user(), + session=session, + ) + + assert out.migration_run_uuid == run.migration_run_uuid + assert out.state == run.state + assert out.state_version == run.state_version + 1 + assert out.cancellation_requested is True + assert out.reused is False + membership.assert_awaited_once() + assert membership.await_args.kwargs["minimum_role"] == "editor" + assert writer.await_args.kwargs["evidence"] == { + "request_id": "migration-request-123", + "request_source": "api", + } + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("message", "code"), + [ + ("migration run state version conflict", "stale_run"), + ("terminal migration run cannot be cancelled", "run_not_cancellable"), + ("migration run state is invalid", "run_integrity_invalid"), + ("run evidence is too large", "run_action_rejected"), + ], +) +async def test_cancel_migration_run_maps_contract_errors_without_leaking( + message: str, code: str +) -> None: + """Cancellation failures expose stable codes instead of internal details.""" + + run = _run() + session = SimpleNamespace( + get=AsyncMock(return_value=run), + commit=AsyncMock(), + ) + with ( + patch( + "app.api.migration_runs.require_project_member", + new_callable=AsyncMock, + ), + patch( + "app.api.migration_runs.request_migration_run_cancellation", + new=AsyncMock(side_effect=MigrationRunContractError(message)), + ), + pytest.raises(HTTPException) as exc_info, + ): + await cancel_migration_run( + migration_run_uuid=run.migration_run_uuid, + body=MigrationRunCancelIn(expected_state_version=run.state_version), + request=_request("safe-correlation"), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == { + "code": code, + "detail": "migration run cancellation was rejected", + "correlation_id": "safe-correlation", + } + assert message not in str(exc_info.value.detail) + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("membership_error", "status", "code"), + [ + ("project access denied", 404, "migration_run_not_found"), + ("insufficient project role", 403, "run_role_required"), + ], +) +async def test_cancel_migration_run_masks_nonmembers_but_rejects_viewers( + membership_error: str, status: int, code: str +) -> None: + """Cross-project identities stay hidden while viewers get a role error.""" + + run = _run() + session = SimpleNamespace( + get=AsyncMock(return_value=run), + commit=AsyncMock(), + ) + with ( + patch( + "app.api.migration_runs.require_project_member", + new=AsyncMock( + side_effect=HTTPException(status_code=403, detail=membership_error) + ), + ), + patch( + "app.api.migration_runs.request_migration_run_cancellation", + new_callable=AsyncMock, + ) as writer, + pytest.raises(HTTPException) as exc_info, + ): + await cancel_migration_run( + migration_run_uuid=run.migration_run_uuid, + body=MigrationRunCancelIn(expected_state_version=run.state_version), + request=_request(), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == status + assert exc_info.value.detail["code"] == code + writer.assert_not_awaited() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancel_migration_run_preserves_unexpected_authorization_errors() -> None: + """Only expected membership denials are converted into public action errors.""" + + run = _run() + session = SimpleNamespace( + get=AsyncMock(return_value=run), + commit=AsyncMock(), + ) + with ( + patch( + "app.api.migration_runs.require_project_member", + new=AsyncMock(side_effect=HTTPException(status_code=503, detail="busy")), + ), + pytest.raises(HTTPException) as exc_info, + ): + await cancel_migration_run( + migration_run_uuid=run.migration_run_uuid, + body=MigrationRunCancelIn(expected_state_version=run.state_version), + request=_request(), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "busy" + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancel_migration_run_masks_missing_identity() -> None: + """An unknown run returns the same structured identity error as a nonmember.""" + + session = SimpleNamespace( + get=AsyncMock(return_value=None), + commit=AsyncMock(), + ) + with pytest.raises(HTTPException) as exc_info: + await cancel_migration_run( + migration_run_uuid=uuid.uuid4(), + body=MigrationRunCancelIn(expected_state_version=1), + request=_request(), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == "migration_run_not_found" + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mutation", + [ + "gap", + "chain", + "secret", + "state", + "time", + "final", + "digest", + "predecessor", + "anchor", + "graph", + "genesis", + "cancellation_graph", + "missing_before", + "cancellation_flag", + ], +) +async def test_get_migration_run_fails_closed_for_corrupt_history( + mutation: str, +) -> None: + """Sequence, chain, evidence, and size corruption never reaches a client.""" + + run = _run() + events = _events(run) + if mutation == "gap": + events[1].sequence_number = 3 + elif mutation == "chain": + events[1].state_before = "live_preflight_running" + elif mutation == "secret": + events[1].evidence_json = {"databaseDsn": "postgresql://secret"} + elif mutation == "state": + run.run_kind = "preview" + elif mutation == "time": + events[1].created_at = run.created_at - dt.timedelta(seconds=1) + elif mutation == "final": + events[1].state_after = "live_preflight_running" + elif mutation == "digest": + events[1].event_digest = "f" * 64 + elif mutation == "predecessor": + events[1].previous_event_digest = "f" * 64 + elif mutation == "graph": + events[1].state_after = "passed" + events[1].event_digest = digest_run_event( + migration_run_uuid=events[1].migration_run_uuid, + sequence_number=events[1].sequence_number, + event_type=events[1].event_type, + state_before=events[1].state_before, + state_after=events[1].state_after, + evidence=events[1].evidence_json, + actor_user_uuid=events[1].actor_user_uuid, + created_at=events[1].created_at, + previous_event_digest=events[1].previous_event_digest, + ) + run.state = "passed" + run.latest_event_digest = events[1].event_digest + elif mutation == "genesis": + events[0].event_type = "unexpected_genesis" + events[0].event_digest = _event_digest(events[0]) + events[1].previous_event_digest = events[0].event_digest + events[1].event_digest = _event_digest(events[1]) + run.latest_event_digest = events[1].event_digest + elif mutation == "cancellation_graph": + events[1].event_type = "cancellation_requested" + events[1].event_digest = _event_digest(events[1]) + run.latest_event_digest = events[1].event_digest + elif mutation == "missing_before": + events[1].state_before = None + events[1].event_digest = _event_digest(events[1]) + run.latest_event_digest = events[1].event_digest + elif mutation == "cancellation_flag": + run.cancellation_requested = True + else: + run.latest_event_digest = "f" * 64 + session = SimpleNamespace( + get=AsyncMock(return_value=run), + scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: events)), + ) + with patch( + "app.api.migration_runs.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await get_migration_run( + migration_run_uuid=run.migration_run_uuid, + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "migration run integrity verification failed" + + +@pytest.mark.asyncio +async def test_get_migration_run_rejects_a_sequential_chain_over_event_limit() -> None: + """The size guard rejects a sequential digest chain before replay work.""" + + run = _run() + events = _events(run)[:1] + previous_digest = events[0].event_digest + for sequence_number in range(2, MAX_RETURNED_RUN_EVENTS + 2): + event = MigrationRunEvent( + migration_run_event_uuid=uuid.uuid4(), + migration_run_uuid=run.migration_run_uuid, + sequence_number=sequence_number, + event_type="evidence_recorded", + state_before="queued", + state_after="queued", + evidence_json={"record": sequence_number}, + previous_event_digest=previous_digest, + event_digest="", + actor_user_uuid=None, + created_at=run.created_at + dt.timedelta(microseconds=sequence_number), + ) + event.event_digest = _event_digest(event) + previous_digest = event.event_digest + events.append(event) + run.state = "queued" + run.state_version = len(events) + run.latest_event_digest = previous_digest + session = SimpleNamespace( + get=AsyncMock(return_value=run), + scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: events)), + ) + + with patch( + "app.api.migration_runs.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await get_migration_run( + migration_run_uuid=run.migration_run_uuid, + user=_user(), + session=session, + ) + + assert len(events) == MAX_RETURNED_RUN_EVENTS + 1 + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "migration run integrity verification failed" + + +def test_migration_run_openapi_state_matches_database_contract() -> None: + """The public run state enum exposes every and only persisted state token.""" + + state_schema = MigrationRunOut.model_json_schema()["properties"]["state"] + assert set(state_schema["enum"]) == { + "queued", + "sandbox_running", + "live_preflight_running", + "passed", + "drifted", + "failed", + "cancelled", + "applying", + "reconciling", + "verifying", + "verified", + "drifted_no_apply", + "not_applied", + "verification_failed", + "failed_rolled_back", + "applied_with_drift", + "outcome_unknown", + } + + +@pytest.mark.asyncio +async def test_get_migration_run_supports_valid_apply_history() -> None: + """The same bounded polling contract represents an apply state graph.""" + + run = _run() + run.run_kind = "apply" + run.state = "applying" + events = _events(run) + events[1].event_type = "apply_started" + events[1].state_after = "applying" + events[1].event_digest = digest_run_event( + migration_run_uuid=events[1].migration_run_uuid, + sequence_number=events[1].sequence_number, + event_type=events[1].event_type, + state_before=events[1].state_before, + state_after=events[1].state_after, + evidence=events[1].evidence_json, + actor_user_uuid=events[1].actor_user_uuid, + created_at=events[1].created_at, + previous_event_digest=events[1].previous_event_digest, + ) + run.latest_event_digest = events[1].event_digest + session = SimpleNamespace( + get=AsyncMock(return_value=run), + scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: events)), + ) + with patch( + "app.api.migration_runs.require_project_member", new_callable=AsyncMock + ): + out = await get_migration_run( + migration_run_uuid=run.migration_run_uuid, + user=_user(), + session=session, + ) + + assert out.run_kind == "apply" + assert out.state == "applying" + + +@pytest.mark.asyncio +async def test_get_migration_run_supports_valid_same_state_cancellation() -> None: + """Cancellation evidence advances the version without inventing a state.""" + + run = _run() + events = _events(run) + event = MigrationRunEvent( + migration_run_event_uuid=uuid.uuid4(), + migration_run_uuid=run.migration_run_uuid, + sequence_number=3, + event_type="cancellation_requested", + state_before="sandbox_running", + state_after="sandbox_running", + evidence_json={"request_source": "review_ui"}, + previous_event_digest=events[-1].event_digest, + event_digest="", + actor_user_uuid=run.requested_by_user_uuid, + created_at=run.updated_at + dt.timedelta(seconds=1), + ) + event.event_digest = digest_run_event( + migration_run_uuid=event.migration_run_uuid, + sequence_number=event.sequence_number, + event_type=event.event_type, + state_before=event.state_before, + state_after=event.state_after, + evidence=event.evidence_json, + actor_user_uuid=event.actor_user_uuid, + created_at=event.created_at, + previous_event_digest=event.previous_event_digest, + ) + events.append(event) + run.state_version = 3 + run.latest_event_digest = event.event_digest + run.cancellation_requested = True + session = SimpleNamespace( + get=AsyncMock(return_value=run), + scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: events)), + ) + with patch( + "app.api.migration_runs.require_project_member", new_callable=AsyncMock + ): + out = await get_migration_run( + migration_run_uuid=run.migration_run_uuid, + user=_user(), + session=session, + ) + + assert out.state == "sandbox_running" + assert out.state_version == 3 + assert out.cancellation_requested is True + assert out.events[-1].event_type == "cancellation_requested" + + +@pytest.mark.asyncio +async def test_get_migration_run_handles_missing_and_non_membership_http_errors() -> None: + """Missing rows are masked while non-membership HTTP failures propagate.""" + + session = SimpleNamespace(get=AsyncMock(return_value=None), scalars=AsyncMock()) + with pytest.raises(HTTPException) as missing: + await get_migration_run( + migration_run_uuid=uuid.uuid4(), user=_user(), session=session + ) + assert missing.value.status_code == 404 + + run = _run() + session.get.return_value = run + with patch( + "app.api.migration_runs.require_project_member", + new=AsyncMock(side_effect=HTTPException(status_code=503, detail="unavailable")), + ): + with pytest.raises(HTTPException) as unavailable: + await get_migration_run( + migration_run_uuid=run.migration_run_uuid, + user=_user(), + session=session, + ) + assert unavailable.value.status_code == 503 + session.scalars.assert_not_awaited() diff --git a/backend/tests/test_api_schema_models.py b/backend/tests/test_api_schema_models.py new file mode 100644 index 000000000..e88b45133 --- /dev/null +++ b/backend/tests/test_api_schema_models.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException, Response +from sqlalchemy.exc import IntegrityError + +from app.api.schema_models import ( + _validate_base_snapshot, + create_schema_model, + revise_schema_model, +) +from app.auth import CurrentUser +from app.models import SchemaModel, SchemaModelRevision +from app.schemas import SchemaModelCreateIn, SchemaModelReviseIn + + +def _user() -> CurrentUser: + return CurrentUser(uuid.uuid4(), "test-user", "Test User") + + +def _model() -> dict: + return {"format_version": 1, "postgresql_major": 18, "schemas": []} + + +class FakeWriteSession: + def __init__(self) -> None: + self.added: list[object] = [] + self.get = AsyncMock() + self.flush = AsyncMock() + self.commit = AsyncMock() + self.rollback = AsyncMock() + + def add(self, value: object) -> None: + self.added.append(value) + + +@pytest.mark.parametrize( + ("snapshot_status", "same_project"), + [(None, False), ("succeeded", False), ("running", True)], +) +@pytest.mark.asyncio +async def test_base_snapshot_must_exist_in_project_and_be_succeeded( + snapshot_status: str | None, + same_project: bool, +) -> None: + """Missing, cross-project, and incomplete base snapshots fail closed.""" + + project_uuid = uuid.uuid4() + snapshot = ( + None + if snapshot_status is None + else SimpleNamespace( + project_space_uuid=(project_uuid if same_project else uuid.uuid4()), + status=snapshot_status, + ) + ) + session = FakeWriteSession() + session.get.return_value = snapshot + + with pytest.raises(HTTPException) as exc_info: + await _validate_base_snapshot(session, project_uuid, uuid.uuid4()) + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == "base snapshot is not usable" + + +@pytest.mark.asyncio +async def test_base_snapshot_accepts_succeeded_snapshot_in_same_project() -> None: + """A succeeded snapshot owned by the project is a valid model base.""" + + project_uuid = uuid.uuid4() + session = FakeWriteSession() + session.get.return_value = SimpleNamespace( + project_space_uuid=project_uuid, status="succeeded" + ) + + await _validate_base_snapshot(session, project_uuid, uuid.uuid4()) + + +@pytest.mark.asyncio +async def test_create_schema_model_persists_identity_and_immutable_revision() -> None: + session = FakeWriteSession() + project_uuid = uuid.uuid4() + user = _user() + response = Response() + + with patch( + "app.api.schema_models.require_project_member", new_callable=AsyncMock + ) as membership: + out = await create_schema_model( + project_space_uuid=project_uuid, + body=SchemaModelCreateIn(model_name="Target schema", model_json=_model()), + response=response, + user=user, + session=session, + ) + + membership.assert_awaited_once_with( + session, project_uuid, user.user_account_uuid, minimum_role="editor" + ) + identity = next(item for item in session.added if isinstance(item, SchemaModel)) + revision = next( + item for item in session.added if isinstance(item, SchemaModelRevision) + ) + assert identity.current_revision_number == 1 + assert revision.schema_model_uuid == identity.schema_model_uuid + assert revision.revision_number == 1 + assert revision.model_json == _model() + assert out.revision_digest == revision.revision_digest + assert response.headers["etag"] == f'"{revision.schema_model_revision_uuid}"' + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_schema_model_returns_conflict_for_duplicate_name() -> None: + session = FakeWriteSession() + session.flush.side_effect = IntegrityError( + "INSERT INTO schema_model", {}, RuntimeError("duplicate key") + ) + + with patch( + "app.api.schema_models.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await create_schema_model( + project_space_uuid=uuid.uuid4(), + body=SchemaModelCreateIn( + model_name="Target schema", model_json=_model() + ), + response=Response(), + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "schema model name already exists" + session.rollback.assert_awaited_once() + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_revise_schema_model_rejects_stale_if_match() -> None: + identity = SimpleNamespace( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + model_name="Target schema", + current_revision_number=2, + ) + current = SimpleNamespace( + schema_model_revision_uuid=uuid.uuid4(), revision_digest="a" * 64 + ) + session = FakeWriteSession() + + with patch( + "app.api.schema_models._get_model_for_update", + new=AsyncMock(return_value=(identity, current)), + ), patch( + "app.api.schema_models.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await revise_schema_model( + schema_model_uuid=identity.schema_model_uuid, + body=SchemaModelReviseIn(model_json=_model()), + response=Response(), + if_match='"' + "b" * 64 + '"', + user=_user(), + session=session, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "schema model revision is stale" + assert session.added == [] + + +@pytest.mark.asyncio +async def test_revise_schema_model_uses_revision_uuid_when_base_only_revision_changes() -> None: + from app.forward.schema_model import schema_model_digest + + model_json = _model() + shared_model_digest = schema_model_digest(model_json) + identity = SimpleNamespace( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + model_name="Target schema", + current_revision_number=3, + updated_at=None, + ) + current = SimpleNamespace( + schema_model_revision_uuid=uuid.uuid4(), + revision_number=3, + revision_digest=shared_model_digest, + model_json=model_json, + base_schema_snapshot_uuid=uuid.uuid4(), + ) + + session = FakeWriteSession() + response = Response() + with patch( + "app.api.schema_models._get_model_for_update", + new=AsyncMock(return_value=(identity, current)), + ), patch( + "app.api.schema_models.require_project_member", new_callable=AsyncMock + ): + out = await revise_schema_model( + schema_model_uuid=identity.schema_model_uuid, + body=SchemaModelReviseIn( + model_json=model_json, base_schema_snapshot_uuid=None + ), + response=response, + if_match=f'"{current.schema_model_revision_uuid}"', + user=_user(), + session=session, + ) + + assert out.revision_number == 4 + revision = next( + item for item in session.added if isinstance(item, SchemaModelRevision) + ) + assert revision.base_schema_snapshot_uuid is None + assert response.headers["etag"] == f'"{revision.schema_model_revision_uuid}"' + + +@pytest.mark.asyncio +async def test_revise_schema_model_rejects_weak_if_match() -> None: + identity = SimpleNamespace( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + model_name="Target schema", + current_revision_number=2, + ) + current = SimpleNamespace( + schema_model_revision_uuid=uuid.uuid4(), revision_digest="a" * 64 + ) + + with patch( + "app.api.schema_models._get_model_for_update", + new=AsyncMock(return_value=(identity, current)), + ), patch( + "app.api.schema_models.require_project_member", new_callable=AsyncMock + ): + with pytest.raises(HTTPException) as exc_info: + await revise_schema_model( + schema_model_uuid=identity.schema_model_uuid, + body=SchemaModelReviseIn(model_json=_model()), + response=Response(), + if_match=f'W/"{current.schema_model_revision_uuid}"', + user=_user(), + session=FakeWriteSession(), + ) + + assert exc_info.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_revise_schema_model_creates_next_revision() -> None: + identity = SimpleNamespace( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + model_name="Target schema", + current_revision_number=2, + updated_at=None, + ) + current = SimpleNamespace( + schema_model_revision_uuid=uuid.uuid4(), revision_digest="a" * 64 + ) + session = FakeWriteSession() + user = _user() + response = Response() + + with patch( + "app.api.schema_models._get_model_for_update", + new=AsyncMock(return_value=(identity, current)), + ), patch( + "app.api.schema_models.require_project_member", new_callable=AsyncMock + ) as membership: + out = await revise_schema_model( + schema_model_uuid=identity.schema_model_uuid, + body=SchemaModelReviseIn(model_json=_model()), + response=response, + if_match=f'"{current.schema_model_revision_uuid}"', + user=user, + session=session, + ) + + membership.assert_awaited_once_with( + session, + identity.project_space_uuid, + user.user_account_uuid, + minimum_role="editor", + ) + revision = next( + item for item in session.added if isinstance(item, SchemaModelRevision) + ) + assert revision.revision_number == 3 + assert identity.current_revision_number == 3 + assert out.revision_number == 3 + assert response.headers["etag"] == f'"{revision.schema_model_revision_uuid}"' + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_revise_schema_model_is_idempotent_for_identical_revision() -> None: + model_json = _model() + identity = SimpleNamespace( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + model_name="Target schema", + current_revision_number=2, + updated_at=None, + ) + current = SimpleNamespace( + schema_model_revision_uuid=uuid.uuid4(), + revision_number=2, + revision_digest="placeholder", + model_json=model_json, + base_schema_snapshot_uuid=None, + ) + from app.forward.schema_model import schema_model_digest + + current.revision_digest = schema_model_digest(model_json) + session = FakeWriteSession() + + with patch( + "app.api.schema_models._get_model_for_update", + new=AsyncMock(return_value=(identity, current)), + ), patch( + "app.api.schema_models.require_project_member", new_callable=AsyncMock + ): + out = await revise_schema_model( + schema_model_uuid=identity.schema_model_uuid, + body=SchemaModelReviseIn(model_json=model_json), + response=Response(), + if_match=f'"{current.schema_model_revision_uuid}"', + user=_user(), + session=session, + ) + + assert out.revision_number == 2 + assert session.added == [] + session.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_revise_schema_model_masks_non_member_as_not_found() -> None: + identity = SimpleNamespace( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + model_name="Target schema", + current_revision_number=2, + ) + current = SimpleNamespace(revision_digest="a" * 64) + denied = HTTPException(status_code=403, detail="project access denied") + + with patch( + "app.api.schema_models._get_model_for_update", + new=AsyncMock(return_value=(identity, current)), + ), patch( + "app.api.schema_models.require_project_member", + new=AsyncMock(side_effect=denied), + ): + with pytest.raises(HTTPException) as exc_info: + await revise_schema_model( + schema_model_uuid=identity.schema_model_uuid, + body=SchemaModelReviseIn(model_json=_model()), + response=Response(), + if_match=current.revision_digest, + user=_user(), + session=FakeWriteSession(), + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "schema model not found" diff --git a/backend/tests/test_db_introspect.py b/backend/tests/test_db_introspect.py index a7ee93f56..6fd311136 100644 --- a/backend/tests/test_db_introspect.py +++ b/backend/tests/test_db_introspect.py @@ -129,6 +129,47 @@ async def fake_mysql(dsn: str) -> str: ] +@pytest.mark.asyncio +async def test_validate_database_dsn_target_dispatches_without_connecting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Validate every supported target through its non-connecting SSRF guard.""" + + calls: list[tuple[str, str]] = [] + + async def fake_postgres(dsn: str) -> object: + calls.append(("postgresql", dsn)) + return object() + + async def fake_snowflake(dsn: str) -> object: + calls.append(("snowflake", dsn)) + return object() + + async def fake_mysql(dsn: str) -> object: + calls.append(("mysql", dsn)) + return object() + + monkeypatch.setattr( + db_introspect, "validate_postgres_dsn_target", fake_postgres + ) + monkeypatch.setattr(db_introspect, "_parse_snowflake_dsn", fake_snowflake) + monkeypatch.setattr(db_introspect, "_parse_mysql_dsn", fake_mysql) + + await db_introspect.validate_database_dsn_target( + "postgresql://u:p@db/app" + ) + await db_introspect.validate_database_dsn_target( + "snowflake://u:p@acct/APP" + ) + await db_introspect.validate_database_dsn_target("mysql://u:p@db/app") + + assert calls == [ + ("postgresql", "postgresql://u:p@db/app"), + ("snowflake", "snowflake://u:p@acct/APP"), + ("mysql", "mysql://u:p@db/app"), + ] + + @pytest.mark.asyncio async def test_introspect_database_redacts_password_on_exception( monkeypatch: pytest.MonkeyPatch, diff --git a/backend/tests/test_dbml_import.py b/backend/tests/test_dbml_import.py index 2c034beff..76b74ba86 100644 --- a/backend/tests/test_dbml_import.py +++ b/backend/tests/test_dbml_import.py @@ -1,7 +1,9 @@ from __future__ import annotations +import pytest + from app.ddl.export import snapshot_json_to_sql -from app.spec.dbml_import import parse_dbml +from app.spec.dbml_import import DbmlIdentifierError, parse_dbml BASIC = """ // a typical dbdiagram.io document @@ -90,14 +92,14 @@ def test_dbml_snapshot_feeds_existing_ddl_export(): assert "PRIMARY KEY" in ddl -def test_pathological_long_line_is_skipped_fast(): +def test_pathological_long_line_is_rejected_fast(): import time hostile = 'Table t {\n id int [pk]\n}\nRef: ' + '"a' * 100_000 + "\n" start = time.monotonic() - snap = parse_dbml(hostile) + with pytest.raises(DbmlIdentifierError): + parse_dbml(hostile) assert time.monotonic() - start < 1.0 # no catastrophic backtracking - assert len(snap["relations"]) == 1 def test_pathological_table_header_dots_are_rejected_fast(): @@ -110,3 +112,127 @@ def test_pathological_table_header_dots_are_rejected_fast(): assert {(r["schema_name"], r["relation_name"]) for r in snap["relations"]} == { ("public", "users") } + + +def test_quoted_identifiers_round_trip_through_postgresql_ddl() -> None: + """Quoted DBML names must decode once and re-escape at the DDL sink.""" + text = ''' +Table "odd""schema"."select; -- audit" { + "quote""column" integer [pk] +} +''' + + snapshot = parse_dbml(text) + ddl = snapshot_json_to_sql(snapshot, target_dialect="postgresql") + + assert snapshot["relations"][0]["schema_name"] == 'odd"schema' + assert snapshot["relations"][0]["relation_name"] == "select; -- audit" + assert snapshot["columns"][0]["column_name"] == 'quote"column' + assert 'CREATE SCHEMA IF NOT EXISTS "odd""schema";' in ddl + assert ( + 'CREATE TABLE IF NOT EXISTS "odd""schema"."select; -- audit" (' in ddl + ) + assert '"quote""column" integer NOT NULL' in ddl + assert 'CONSTRAINT "pk_select; -- audit" PRIMARY KEY ("quote""column")' in ddl + + +def test_unicode_line_separator_inside_quoted_identifier_is_data() -> None: + """Only LF separates DBML records; Unicode separators remain identifier data.""" + identifier = "order\x85items" + + snapshot = parse_dbml(f'Table "{identifier}" {{\n id integer\n}}') + + assert snapshot["relations"][0]["relation_name"] == identifier + + +@pytest.mark.parametrize( + "dbml", + [ + 'Table "unterminated {\n id integer\n}', + 'Table public.orders.extra {\n id integer\n}', + 'Table "nul\x00name" {\n id integer\n}', + f'Table "{"é" * 32}" {{\n id integer\n}}', + 'Table users {\n "unterminated integer\n}', + 'Table users {\n "nul\x00column" integer\n}', + 'Ref: catalog.public.users.id > public.accounts.id', + 'Ref: "unterminated > public.accounts.id', + ], +) +def test_invalid_or_ambiguous_dbml_identifiers_fail_closed(dbml: str) -> None: + """Malformed, ambiguous, NUL, and overlong names must not degrade to omission.""" + with pytest.raises(DbmlIdentifierError): + parse_dbml(dbml) + + +def test_comment_markers_and_dots_inside_quoted_names_are_data() -> None: + """DBML comments and path separators apply only outside quoted identifiers.""" + snapshot = parse_dbml( + ''' +Table "odd.schema"."orders//archive" { + "value.part//raw" integer [pk] +} +''' + ) + + relation = snapshot["relations"][0] + assert relation["schema_name"] == "odd.schema" + assert relation["relation_name"] == "orders//archive" + assert snapshot["columns"][0]["column_name"] == "value.part//raw" + + +def test_generated_constraint_names_fit_postgresql_identifier_limit() -> None: + """Derived names must never rely on PostgreSQL's lossy identifier truncation.""" + relation_name = "r" * 61 + snapshot = parse_dbml(f"Table {relation_name} {{\n id integer [pk]\n}}") + + constraint_name = snapshot["constraints"][0]["constraint_name"] + + assert len(constraint_name.encode("utf-8")) <= 63 + assert constraint_name.startswith("pk_") + + +def test_parser_rejects_input_above_authenticated_route_limit() -> None: + """Direct parser callers inherit the route's total-input resource bound.""" + with pytest.raises(DbmlIdentifierError): + parse_dbml("\n".join("x" * 3_000 for _ in range(200))) + + +def test_parser_rejects_more_than_ten_thousand_lines() -> None: + """Line iteration is bounded even when each attacker-controlled line is tiny.""" + with pytest.raises(DbmlIdentifierError): + parse_dbml("\n" * 10_001) + + +def test_column_positions_scale_and_reset_per_relation() -> None: + """Column ordinals stay linear and restart independently for every relation.""" + first_columns = "\n".join( + f" column_{index} integer" for index in range(1_000) + ) + text = f""" +Table wide_relation {{ +{first_columns} +}} +Table second_relation {{ + first_column integer + second_column integer +}} +""" + + snapshot = parse_dbml(text) + relation_oids = { + relation["relation_name"]: relation["relation_oid"] + for relation in snapshot["relations"] + } + wide_positions = [ + column["column_position"] + for column in snapshot["columns"] + if column["relation_oid"] == relation_oids["wide_relation"] + ] + second_positions = [ + column["column_position"] + for column in snapshot["columns"] + if column["relation_oid"] == relation_oids["second_relation"] + ] + + assert wide_positions == list(range(1, 1_001)) + assert second_positions == [1, 2] diff --git a/backend/tests/test_documentation_contract.py b/backend/tests/test_documentation_contract.py new file mode 100644 index 000000000..97c9744fd --- /dev/null +++ b/backend/tests/test_documentation_contract.py @@ -0,0 +1,882 @@ +"""Guard the canonical forward-engineering documentation contract.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + +CANONICAL_DOCUMENTS = ( + Path("ARCHITECTURE.md"), + Path("docs/PRD.md"), + Path("docs/TRD.md"), + Path("docs/UML.md"), + Path("docs/DATA_MODEL.md"), + Path("docs/DOCUMENTATION_AUDIT.md"), + Path("docs/TEST_STRATEGY.md"), + Path("docs/STANDARDS.md"), + Path("docs/security/forward-engineering-threat-model.md"), + Path("docs/runbooks/forward-engineering.md"), + Path("docs/contracts/forward-engineering-v1.md"), + Path("docs/adr/README.md"), + Path("docs/adr/ADR-0001-server-authoritative-planning.md"), + Path("docs/adr/ADR-0002-isolated-dry-run-and-preflight.md"), + Path("docs/adr/ADR-0003-plan-execution-segmentation.md"), + Path("docs/adr/ADR-0004-durable-runs-and-recovery.md"), + Path("docs/adr/ADR-0005-authority-approvals-and-convergence.md"), +) + +MERMAID_DOCUMENTS = ( + Path("ARCHITECTURE.md"), + Path("docs/UML.md"), + Path("docs/DATA_MODEL.md"), +) + +CURRENT_ROUTES = ( + "POST /api/schema-models/by-project/{project_space_uuid}", + "GET /api/schema-models/{schema_model_uuid}", + "PUT /api/schema-models/{schema_model_uuid}", + "POST /api/schema-model-revisions/{schema_model_revision_uuid}/migration-plans", + "GET /api/migration-plans/{migration_plan_uuid}", + "POST /api/migration-plans/{migration_plan_uuid}/dry-runs", + "POST /api/migration-plans/{migration_plan_uuid}/apply-runs", + "GET /api/migration-runs/{migration_run_uuid}", +) + +PLANNED_ROUTES: tuple[str, ...] = () + +README_CORE_LINKS = ( + "ARCHITECTURE.md", + "docs/PRD.md", + "docs/TRD.md", + "docs/adr/README.md", + "docs/contracts/forward-engineering-v1.md", + "docs/UML.md", + "docs/DATA_MODEL.md", + "docs/security/forward-engineering-threat-model.md", + "docs/runbooks/forward-engineering.md", + "docs/DOCUMENTATION_AUDIT.md", +) + + +def _read(relative_path: Path) -> str: + return (REPOSITORY_ROOT / relative_path).read_text(encoding="utf-8") + + +def test_tm05_requires_role_and_operator_opt_in_for_legacy_apply() -> None: + """Keep the threat model aligned with both persistent-apply controls.""" + + threat_model = _read( + Path("docs/security/forward-engineering-threat-model.md") + ) + tm05 = next(line for line in threat_model.splitlines() if "| TM-05 |" in line) + + assert "persistent legacy apply requires deployer plus explicit operator opt-in" in tm05 + + +def test_canonical_forward_engineering_documents_exist_and_are_nonempty() -> None: + """Require every canonical forward-engineering document to contain text.""" + + missing = [ + path.as_posix() + for path in CANONICAL_DOCUMENTS + if not (REPOSITORY_ROOT / path).is_file() + ] + empty = [ + path.as_posix() + for path in CANONICAL_DOCUMENTS + if (REPOSITORY_ROOT / path).is_file() and not _read(path).strip() + ] + + assert missing == [] + assert empty == [] + + +def test_architecture_views_remain_renderable_mermaid_documents() -> None: + """Keep every required architecture view renderable as Mermaid source.""" + + without_mermaid = [ + path.as_posix() for path in MERMAID_DOCUMENTS if "```mermaid" not in _read(path) + ] + + assert without_mermaid == [] + + +def test_v1_contract_separates_current_routes_from_remaining_run_routes() -> None: + """Keep retrieval implemented without presenting execution routes as live.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + + missing_current = [route for route in CURRENT_ROUTES if route not in contract] + missing_planned = [route for route in PLANNED_ROUTES if route not in contract] + + assert missing_current == [] + assert missing_planned == [] + assert "## 5. Current HTTP API contract" in contract + assert "## 8. Migration-plan retrieval and bounded run API" in contract + assert "Implemented" in contract + assert "each route is classified below" in " ".join(contract.split()) + + +def test_published_apply_intent_route_is_not_classified_as_planned() -> None: + """Keep the non-dispatched intent endpoint separate from planned execution.""" + + route = "POST /api/migration-plans/{migration_plan_uuid}/apply-runs" + + assert route in CURRENT_ROUTES + assert route not in PLANNED_ROUTES + + +def test_trd_tracks_current_apply_intent_and_migration_contract() -> None: + """Keep the TRD aligned with the implemented non-dispatched intent slice.""" + + trd = _read(Path("docs/TRD.md")) + normalized = " ".join(trd.split()) + + assert "apply creation Planned" not in normalized + assert "future apply routes must reuse it" not in normalized + assert "apply-intent creation HTTP" in normalized + assert "the apply-intent route reuses it" in normalized + for revision in ( + "0008_schema_model_revision", + "0009_migration_plan", + "0010_migration_run", + "0011_migration_run_attempt", + "0012_apply_intent_confirmation", + "0013_migration_run_cancellation", + ): + assert revision in trd + + +def test_forward_browser_transport_is_partial_without_execution_authority() -> None: + """Track typed browser transport without claiming the forward UI exists.""" + + client = _read(Path("frontend/src/api.ts")) + review_panel = _read( + Path("frontend/src/components/forward/PlanReviewPanel.tsx") + ) + review_surface = _read( + Path("frontend/src/components/forward/PlanReviewSurface.tsx") + ) + modal = _read( + Path("frontend/src/components/forward/ForwardEngineeringModal.tsx") + ) + run_panel = _read( + Path("frontend/src/components/forward/RunStatusPanel.tsx") + ) + run_surface = _read( + Path("frontend/src/components/forward/RunStatusSurface.tsx") + ) + dry_run_intent = _read( + Path("frontend/src/components/forward/DryRunIntentPanel.tsx") + ) + apply_intent = _read( + Path("frontend/src/components/forward/ApplyIntentPanel.tsx") + ) + cancellation_control = _read( + Path("frontend/src/components/forward/RunCancellationControl.tsx") + ) + documents = ( + _read(Path("ARCHITECTURE.md")), + _read(Path("docs/PRD.md")), + _read(Path("docs/TRD.md")), + _read(Path("docs/TEST_STRATEGY.md")), + ) + + for symbol in ( + "getMigrationPlan", + "createDryRun", + "createApplyRun", + "getMigrationRun", + "cancelMigrationRun", + ): + assert symbol in client + for symbol in ( + "MigrationPlan", + "plan.proposed_statements", + "plan.blockers", + "이 화면은 SQL 실행 권한을 갖지 않습니다", + ): + assert symbol in review_panel + for symbol in ( + "getMigrationPlan", + "계획을 불러오는 중입니다", + "계획을 불러오지 못했습니다", + "다시 시도", + "active = false", + ): + assert symbol in review_surface + for symbol in ( + "useDialogAccessibility", + 'role="dialog"', + 'aria-modal="true"', + "PlanReviewSurface", + "RunStatusSurface", + "ApplyIntentPanel", + ): + assert symbol in modal + for symbol in ( + "MigrationRun", + 'role="status"', + "자동 재실행이 금지됩니다", + "서버가 검증한 이벤트 메타데이터만 표시합니다", + ): + assert symbol in run_panel + for symbol in ( + "getMigrationRun", + "TERMINAL_RUN_STATES", + "실행 상태를 불러오는 중입니다", + "실행 상태를 불러오지 못했습니다", + "active = false", + ): + assert symbol in run_surface + for symbol in ( + "createDryRun", + "plan.plan_digest", + "plan.can_dry_run", + "web-dry-run-", + "globalThis.crypto.randomUUID", + "inFlightRef", + "같은 요청 다시 시도", + ): + assert symbol in dry_run_intent + for symbol in ( + "cancelMigrationRun", + "run.state_version", + "isTerminalMigrationRunState", + "inFlightRef", + "요청을 자동으로 반복하지 말고", + "실행 상태 새로고침", + ): + assert symbol in cancellation_control + for symbol in ( + "createApplyRun", + "isExactPassedDryRun", + "run.observed_base_digest === plan.base_digest", + "target_connection_name", + "web-apply-intent-", + "submittedTargetNameRef", + "실제 DDL을 디스패치하거나 실행하지 않습니다", + ): + assert symbol in apply_intent + for document in documents: + normalized = " ".join(document.lower().split()) + assert "typed browser transport is **partially implemented**" in normalized + assert "plan review panel is **partially implemented**" in normalized + assert "stale-response suppression is **partially implemented**" in normalized + assert "forward engineering modal shell is **partially implemented**" in normalized + assert "run status and audit panel is **partially implemented**" in normalized + assert "terminal-aware polling is **partially implemented**" in normalized + assert "dry-run intent control is **partially implemented**" in normalized + assert "cancellation intent control is **partially implemented**" in normalized + assert "apply intent control is **partially implemented**" in normalized + assert "forward ui remains **planned**" in normalized + + +def test_v1_contract_does_not_classify_plan_retrieval_as_planned() -> None: + """Keep the implemented immutable-plan read surface out of planned scope.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + normalized_contract = " ".join(contract.split()) + + assert "**Planned:** plan retrieval" not in normalized_contract + assert ( + "| `GET /api/migration-plans/{migration_plan_uuid}` | none | " + "current `MigrationPlanOut`, `200` | member | Implemented |" + in normalized_contract + ) + + +def test_v1_contract_keeps_blocked_statements_as_review_only_proposals() -> None: + """Keep blocked SQL visible for review but unavailable for execution.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + normalized_contract = " ".join(contract.split()) + + assert "proposed_statements" in contract + assert ( + "When `blockers` is non-empty, `statements` is empty" in normalized_contract + ) + assert "`proposed_statements` solely for complete review" in normalized_contract + + +def test_v1_contract_keeps_current_concurrency_and_identifier_authority_explicit() -> None: + """Retain concurrency, snapshot, and identifier authority in the contract.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + + for required_term in ( + "strong `ETag`", + "revision UUID", + "snapshot_contract_version", + "read-only repeatable-read transaction", + "`object_ref` and `dependency_refs` are authoritative", + "display-only", + "recomputes the canonical plan digest", + ): + assert required_term in contract + + normalized_contract = " ".join(contract.split()) + assert ( + "The immutable preview exposes project, model-revision, connection, " + "base-snapshot, snapshot-contract, PostgreSQL-major, creator, and creation-time " + "bindings" in normalized_contract + ) + + +def test_docs_track_the_persisted_migration_run_foundation_without_overclaim() -> None: + """Track the consumer contract without claiming startup or execution.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + trd = _read(Path("docs/TRD.md")) + data_model = _read(Path("docs/DATA_MODEL.md")) + adr = _read(Path("docs/adr/ADR-0004-durable-runs-and-recovery.md")) + + assert "These symbols and tables do not exist" not in contract + assert "**Partially implemented:** durable run/event persistence" in contract + assert "### Partially implemented foundation" in trd + assert "## Physical run foundation — Implemented" in data_model + assert "**Implementation status:** Partially implemented" in adr + normalized_contract = " ".join(contract.lower().split()) + assert "public apply intent creation is **implemented**" in normalized_contract + assert "for update" in normalized_contract + assert "stale_revision" in normalized_contract + assert ( + "post /api/migration-plans/{migration_plan_uuid}/dry-runs" + in normalized_contract + ) + assert ( + "post /api/migration-runs/{migration_run_uuid}/cancel" + in normalized_contract + ) + assert ( + "post /api/migration-plans/{migration_plan_uuid}/apply-runs" + in normalized_contract + ) + assert "creates no dispatch" in normalized_contract + assert ( + "fe-ac-006 | apply cannot queue without editor-authored revision, " + "deployer role, exact passed dry run, exact digest, typed target, and " + "destructive acknowledgement when required. | role/tamper/race/api " + "tests | implemented control-plane boundary; live apply remains planned" + in normalized_contract + ) + assert "0012_apply_intent_confirmation" in data_model + assert "0013_migration_run_cancellation" in data_model + assert "stable sanitized run-action error envelope" in normalized_contract + assert "migration_run_dispatch" in normalized_contract + assert "identifier-only transactional outbox" in normalized_contract + assert "migration_run_dispatch" in trd + assert "migration_run_dispatch" in data_model + assert "identifier-only transactional outbox" in adr.lower() + assert "lock-scoped due-order outbox claiming" in normalized_contract + assert "bounded one-attempt publisher is **implemented**" in normalized_contract + assert "every persisted plan precondition" in normalized_contract + assert "missing, extra, duplicate, or kind-mismatched checks" in normalized_contract + assert "scheduled relay lifecycle is **implemented**" in normalized_contract + assert "dedicated valkey sorted-set key" in normalized_contract + assert ( + "execution-neutral consumer contract is **implemented**" + in normalized_contract + ) + assert ( + "application startup wiring and deployed worker execution remain **planned**" + in normalized_contract + ) + assert "whole-stage deadlines request cancellation" in normalized_contract + assert "does not forcibly terminate" in normalized_contract + assert "Startup fails closed when Valkey is unavailable" not in adr + assert "Startup rejects an unconfigured Valkey backend" in adr + + +def test_terminal_cancellation_maturity_is_canonical() -> None: + """Keep metadata acknowledgement distinct from deployed interruption.""" + + documents = ( + _read(Path("ARCHITECTURE.md")), + _read(Path("docs/PRD.md")), + _read(Path("docs/TRD.md")), + _read(Path("docs/contracts/forward-engineering-v1.md")), + _read(Path("docs/adr/ADR-0004-durable-runs-and-recovery.md")), + ) + normalized = tuple( + " ".join(document.lower().split()) for document in documents + ) + + assert all( + "terminal" in document and "cancelled" in document + for document in normalized + ) + assert all( + "in-flight" in document and "planned" in document + for document in normalized + ) + + +def test_dry_run_cancellation_transitions_are_complete_in_uml() -> None: + """Keep the UML aligned with every implemented cancellable dry-run state.""" + + uml = _read(Path("docs/UML.md")) + + for source_state in ( + "queued", + "sandbox_running", + "live_preflight_running", + ): + assert f"{source_state} --> cancelled: cancellation wins" in uml + + +def test_dispatch_relay_has_explicit_deployment_and_lifecycle_contract() -> None: + """Keep the opt-in relay wired without implying execution authority.""" + + environment = _read(Path(".env.example")) + main = _read(Path("backend/app/main.py")) + runbook = _read(Path("docs/runbooks/forward-engineering.md")) + + assert "MIGRATION_DISPATCH_RELAY_ENABLED=false" in environment + assert "MIGRATION_DISPATCH_RELAY_POLL_INTERVAL_SECONDS=1.0" in environment + assert "run_migration_dispatch_relay_forever" in main + assert "migration-dispatch-relay" in main + assert "MIGRATION_DISPATCH_RELAY_ENABLED" in runbook + assert "does not start a queue consumer" in " ".join(runbook.split()) + + +def test_ci_runs_real_supported_postgresql_migration_acceptance() -> None: + """Keep PostgreSQL 14-18 acceptance explicit and image-digest pinned.""" + + workflow = _read(Path(".github/workflows/ci.yml")) + strategy = _read(Path("docs/TEST_STRATEGY.md")) + + for major in range(14, 19): + assert f'major: "{major}"' in workflow + assert workflow.count("postgres@sha256:") == 5 + assert "test_postgres_migration_run_integration.py" in workflow + assert "CREATE DATABASE pg_erd_cloud_sandbox" in workflow + assert "POSTGRES_SANDBOX_INTEGRATION_URL" in workflow + assert "CREATE DATABASE pg_erd_cloud_target" in workflow + assert "POSTGRES_TARGET_INTEGRATION_URL" in workflow + assert "CREATE ROLE cwl_erd_preflight" in workflow + assert "CREATE ROLE pg_" not in workflow + assert "POSTGRES_PREFLIGHT_INTEGRATION_URL" in workflow + assert "VALKEY_INTEGRATION_URL: redis://127.0.0.1:6379/0" in workflow + assert "Verify PostgreSQL and Valkey dual-lease recovery" in workflow + postgres_job = workflow.split(" postgres-integration:", 1)[1].split( + " valkey-integration:", 1 + )[0] + assert "services:" in postgres_job + assert "valkey/valkey@sha256:" in postgres_job + integration_test = _read( + Path("backend/tests/test_postgres_migration_run_integration.py") + ) + assert ( + "test_real_postgres_and_valkey_recover_failure_and_crash" + in integration_test + ) + assert 'os.getenv("POSTGRES_SANDBOX_INTEGRATION_URL")' in integration_test + assert 'os.getenv("POSTGRES_TARGET_INTEGRATION_URL")' in integration_test + assert ( + 'os.getenv("POSTGRES_PREFLIGHT_INTEGRATION_URL")' in integration_test + ) + assert "_sandbox_asyncpg_url()" in integration_test + assert "_target_asyncpg_url()" in integration_test + assert "_preflight_asyncpg_url()" in integration_test + assert "PostgreSQL 14\u201318" in strategy + assert "migration-run/outbox" in strategy + + +def test_ci_waits_for_final_postgresql_server_after_image_initialization() -> None: + """Do not accept the temporary init server as integration readiness.""" + + workflow = _read(Path(".github/workflows/ci.yml")) + init_complete = "PostgreSQL init process complete; ready for start up." + + assert init_complete in workflow + assert workflow.index(init_complete) < workflow.index("pg_isready") + + +def test_bound_live_preflight_maturity_is_canonical() -> None: + """Keep same-snapshot capture binding distinct from worker authority.""" + + implementation = _read(Path("backend/app/forward/live_preflight.py")) + durable_implementation = _read(Path("backend/app/forward/migration_run.py")) + required_documents = ( + Path("ARCHITECTURE.md"), + Path("CHANGELOG.md"), + Path("docs/TRD.md"), + Path("docs/DATA_MODEL.md"), + Path("docs/DOCUMENTATION_AUDIT.md"), + Path("docs/UML.md"), + Path("docs/contracts/forward-engineering-v1.md"), + Path("docs/adr/ADR-0002-isolated-dry-run-and-preflight.md"), + Path("docs/TEST_STRATEGY.md"), + Path("docs/runbooks/forward-engineering.md"), + ) + + assert "execute_bound_live_preflight" in implementation + assert "complete_isolated_dry_run" in durable_implementation + assert "complete_live_preflight" in durable_implementation + integration_test = _read( + Path("backend/tests/test_postgres_migration_run_integration.py") + ) + strategy = _read(Path("docs/TEST_STRATEGY.md")) + changelog = _read(Path("CHANGELOG.md")) + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + standards = _read(Path("docs/STANDARDS.md")) + assert "LOCK TABLE {qualified} IN ACCESS EXCLUSIVE MODE" in integration_test + assert "connection.is_in_transaction() is False" in integration_test + assert "denied_table_name" in integration_test + assert "pg_catalog.pg_stat_clear_snapshot()" in integration_test + assert integration_test.index("pg_catalog.pg_stat_clear_snapshot()") < ( + integration_test.index("FROM pg_catalog.pg_stat_activity") + ) + assert "pg_catalog.pg_stat_activity" in integration_test + assert "wait_event_type = 'Lock'" in integration_test + assert "pg_catalog.pg_terminate_backend" in integration_test + assert "connection.is_closed() is True" in integration_test + assert "real relation-lock wait" in strategy + assert "ungranted-table SELECT failure" in strategy + assert "terminates the backend" in strategy + assert "ACCESS EXCLUSIVE" in changelog + assert "pg_terminate_backend" in changelog + assert "relation-lock wait" in contract + assert "SELECT denial" in contract + assert "terminates the restricted backend" in contract + assert ( + "`table_is_empty` precondition primitive and completion CAS are " + "Implemented" in contract + ) + assert ( + "`no_null_values` precondition primitive and completion CAS are " + "Implemented" in contract + ) + assert ( + "`castable_values` precondition primitive and completion CAS are " + "Implemented" in contract + ) + assert ( + "bounded live-preflight execution and completion CAS are Implemented" + in standards + ) + assert ( + "bounded all-transactional isolated executor core is Implemented" + in standards + ) + for path in required_documents: + document = _read(path) + assert "complete_isolated_dry_run" in document + assert "execute_bound_live_preflight" in document + assert "complete_live_preflight" in document + assert "caller-owned" in document + + +def test_ci_generates_ephemeral_integration_credentials() -> None: + """Keep test credentials ephemeral and checkout credentials unavailable.""" + + workflow = _read(Path(".github/workflows/ci.yml")) + + assert "POSTGRES_PASSWORD: postgres" not in workflow + assert "postgres:postgres" not in workflow + assert "integration-only-app-secret" not in workflow + assert "openssl rand -hex" in workflow + step_blocks = re.findall( + r"(?ms)^ - (?P.*?)(?=^ - |\Z)", workflow + ) + checkout_steps = [ + step for step in step_blocks if "uses: actions/checkout@" in step + ] + assert checkout_steps + assert all("persist-credentials: false" in step for step in checkout_steps) + + +def test_dispatch_relay_documentation_separates_implemented_and_planned_scope() -> None: + """Keep scheduler maturity distinct from consumer/worker maturity.""" + + data_model = _read(Path("docs/DATA_MODEL.md")) + audit = _read(Path("docs/DOCUMENTATION_AUDIT.md")) + + assert "Additional **Implemented and Planned** invariants:" in data_model + assert "- **Implemented — scheduled relay lifecycle:**" in data_model + assert ( + "- **Implemented — execution-neutral queue consumer contract:**" + in data_model + ) + assert ( + "- **Implemented — scheduled relay lifecycle and UUID-only publication:**" + in audit + ) + assert "- **Implemented — execution-neutral queue consumer contract:**" in audit + assert ( + "- **Planned — application startup wiring, worker execution, " + "failover, and retention:**" in audit + ) + assert "Relay loop/queue delivery" not in audit + + +def test_signal_lease_documentation_keeps_execution_boundary_explicit() -> None: + """Track exact lease ownership without claiming a worker exists.""" + + contract = " ".join( + _read(Path("docs/contracts/forward-engineering-v1.md")).lower().split() + ) + trd = " ".join(_read(Path("docs/TRD.md")).lower().split()) + runbook = " ".join( + _read(Path("docs/runbooks/forward-engineering.md")).lower().split() + ) + + for document in (contract, trd, runbook): + assert "exact lease-token" in document + assert "exact signal claim" in document + assert "exact lease renewal" in document + assert "expired signal owner cannot renew" in document + assert "automatic heartbeat is **implemented**" in document + assert "execution-neutral consumer contract is **implemented**" in document + assert "application startup wiring" in document + assert "worker execution remain **planned**" in document + + assert "exact lease-token claim/renew/ack/release primitives" in trd + + consumer = _read(Path("backend/app/jobs/migration_run_consumer.py")) + queue = _read(Path("backend/app/jobs/valkey_queue.py")) + main = _read(Path("backend/app/main.py")) + assert "process_one_migration_run_signal" in consumer + assert "run_migration_run_consumer_forever" in consumer + assert "renew_migration_run_signal" in queue + assert "run_migration_run_consumer_forever" not in main + + +def test_durable_attempt_documentation_is_implemented_without_authority_claim() -> None: + """Keep durable ownership distinct from consumer, credentials, and execution.""" + + documents = { + path: " ".join(_read(Path(path)).lower().split()) + for path in ( + "ARCHITECTURE.md", + "docs/PRD.md", + "docs/TRD.md", + "docs/DATA_MODEL.md", + "docs/DOCUMENTATION_AUDIT.md", + "docs/adr/ADR-0004-durable-runs-and-recovery.md", + "docs/contracts/forward-engineering-v1.md", + "docs/security/forward-engineering-threat-model.md", + "docs/runbooks/forward-engineering.md", + "docs/TEST_STRATEGY.md", + ) + } + for document in documents.values(): + assert "attempt" in document + assert "hash" in document + assert "planned" in document + + assert "0011_migration_run_attempt" in documents["docs/DATA_MODEL.md"] + assert "at most one" in documents["docs/DATA_MODEL.md"] + contract = documents["docs/contracts/forward-engineering-v1.md"] + assert "exact-token cas" in contract + assert "complete an expired attempt" in contract + assert "consumer-to-attempt binding" in documents["docs/DOCUMENTATION_AUDIT.md"] + assert "application startup wiring" in documents["docs/DOCUMENTATION_AUDIT.md"] + assert "partial foundation" in documents["docs/PRD.md"] + + +def test_consumer_attempt_binding_is_documented_without_startup_or_sql_authority() -> None: + """Track the exact dual-lease adapter while keeping deployment Planned.""" + + documents = [ + " ".join(_read(Path(path)).lower().split()) + for path in ( + "ARCHITECTURE.md", + "docs/PRD.md", + "docs/TRD.md", + "docs/contracts/forward-engineering-v1.md", + "docs/runbooks/forward-engineering.md", + "docs/TEST_STRATEGY.md", + ) + ] + for document in documents: + assert "consumer-to-attempt binding is **implemented**" in document + assert "application startup wiring" in document + assert "worker execution" in document + assert "**planned**" in document + + consumer = _read(Path("backend/app/jobs/migration_run_consumer.py")) + main = _read(Path("backend/app/main.py")) + assert "make_attempt_bound_migration_run_handler" in consumer + assert "run_migration_run_consumer_forever" not in main + + +def test_ci_runs_real_valkey_signal_acceptance() -> None: + """Keep UUID-only queue separation tested against a pinned real service.""" + + workflow = _read(Path(".github/workflows/ci.yml")) + strategy = _read(Path("docs/TEST_STRATEGY.md")) + + assert "valkey-integration:" in workflow + assert "valkey/valkey@sha256:" in workflow + assert "test_valkey_queue_integration.py" in workflow + assert "VALKEY_INTEGRATION_URL" in workflow + assert "real Valkey" in strategy + + +def test_superseded_adr_is_not_restored() -> None: + """Prevent a superseded ADR from reappearing beside canonical decisions.""" + + superseded = REPOSITORY_ROOT / "docs/adr/0001-server-authoritative-migration-plans.md" + + assert not superseded.exists() + + +def test_readme_links_the_core_forward_engineering_documents() -> None: + """Keep the repository entry point linked to canonical product memory.""" + + readme = _read(Path("README.md")) + missing_links = [target for target in README_CORE_LINKS if target not in readme] + + assert missing_links == [] + + +def test_claude_guidance_tracks_partial_forward_engineering_authority() -> None: + """Keep coding-agent guidance aligned without claiming live apply readiness.""" + + guidance = _read(Path("CLAUDE.md")) + normalized = " ".join(guidance.split()) + + for route_group in ( + "schema_models", + "migration_plans", + "migration_runs", + ): + assert route_group in guidance + assert "UUID-only migration dispatch relay" in normalized + assert "provider-neutral dry-run/preflight orchestration" in normalized + assert "concrete sandbox and target credential providers" in normalized + assert "It is not a production apply executor" in normalized + assert ( + "Never describe this partial control plane as production apply readiness" + in normalized + ) + + +def test_forward_contract_tracks_concurrent_apply_intent_evidence() -> None: + """Keep FE-AC-007 aligned with exact real-PostgreSQL concurrency evidence.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + acceptance_rows = [ + line for line in contract.splitlines() if "| FE-AC-007 |" in line + ] + assert acceptance_rows, "forward contract is missing FE-AC-007" + acceptance_row = acceptance_rows[0] + + assert "PostgreSQL 14–18" in acceptance_row + assert "same-key apply" in acceptance_row + assert "Partially implemented" in acceptance_row + assert "live apply" in acceptance_row + + +def test_forward_contract_tracks_real_durable_worker_postgres_evidence() -> None: + """Keep worker evidence distinct from deployed provider readiness.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + acceptance_rows = [ + line for line in contract.splitlines() if "| FE-AC-003 |" in line + ] + assert acceptance_rows, "forward contract is missing FE-AC-003" + acceptance_row = acceptance_rows[0] + + assert ( + "test_real_postgres_durable_worker_recovers_without_sandbox_replay" + in acceptance_row + ) + integration = _read( + Path("backend/tests/test_postgres_migration_run_integration.py") + ) + assert ( + "test_real_postgres_durable_worker_recovers_without_sandbox_replay" + in integration + ) + assert "concrete stored-target provider" in acceptance_row + assert "test-only loopback seam" in acceptance_row + assert "unmodified guarded-route integration" in acceptance_row + assert "deployed provisioning" in acceptance_row + assert "Planned" in acceptance_row + + +def test_apply_lock_compiler_is_documented_without_apply_authority() -> None: + """Keep the lock-plan compiler distinct from target lock acquisition.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + architecture = _read(Path("ARCHITECTURE.md")) + runbook = _read(Path("docs/runbooks/forward-engineering.md")) + strategy = _read(Path("docs/TEST_STRATEGY.md")) + implementation = _read(Path("backend/app/forward/apply_lock_plan.py")) + + assert "deterministic pre-apply lock-target compilation" in contract + assert "acquires no lock" in contract + assert "lock-plan compilation" in architecture + assert "missing/unknown compiler versions" in runbook + assert "parses no rendered SQL" in runbook + assert "real target lock acquisition" in strategy + assert "does not connect to PostgreSQL" in implementation + assert "acquire locks" in implementation + + +def test_pre_apply_revalidation_manifest_has_no_target_authority() -> None: + """Keep manifest compilation distinct from holding locks or target reads.""" + + contract = _read(Path("docs/contracts/forward-engineering-v1.md")) + architecture = _read(Path("ARCHITECTURE.md")) + prd = _read(Path("docs/PRD.md")) + runbook = _read(Path("docs/runbooks/forward-engineering.md")) + strategy = _read(Path("docs/TEST_STRATEGY.md")) + implementation = _read(Path("backend/app/forward/pre_apply_revalidation.py")) + integration = _read( + Path("backend/tests/test_postgres_migration_run_integration.py") + ) + + assert "target-free pre-apply revalidation-manifest" in contract + assert "database `CREATE`/schema `CREATE`/table `OWNER`" in contract + assert "manifest-bound observation assessment" in contract + assert "cannot prove observation freshness or lock ownership" in contract + assert "parameterized privilege-probe compilation" in contract + assert "re-derives" in contract + assert "exact signed plan" in contract + invariant = next( + line for line in contract.splitlines() if "| FE-INV-007 |" in line + ) + assert "signed-plan manifest" in invariant + assert "same-connection" in invariant + segment_invariant = next( + line for line in contract.splitlines() if "| FE-INV-008 |" in line + ) + assert "exactly one ordered all-transactional segment" in segment_invariant + assert "rollback proof" in segment_invariant + assert "revalidation manifest" in architecture + assert "observation assessment" in architecture + assert "target-free manifest" in prd + assert "does not acquire a target connection" in runbook + assert "a no-op plan has no segment" in runbook + assert "start that transaction" in runbook + assert "observe a target role's privileges" in runbook + assert "capture owns no target credential" in architecture + assert "privilege-label drift" in prd + assert "privilege observation occurred" in strategy + assert "complete positional observation" in strategy + assert "ApplyPrivilegeRequirement" in implementation + assert "ApplyPrivilegeQuery" in implementation + assert "does not execute the probes" in implementation + assert "re-derives the manifest from the" in implementation + assert "exact signed plan rather than trusting" in implementation + assert "test-only PostgreSQL 14–18 acceptance" in strategy + assert "Production target connection/lock orchestration" in strategy + assert "Same-connection capture tests" in strategy + assert ( + "test_real_postgres_manifest_lock_covers_bound_precondition" + in integration + ) + assert "open no target connection" in implementation + assert "acquires no advisory/object lock" in implementation + assert "cannot establish those facts or grant apply authority" in implementation + assert "capture_pre_apply_revalidation_observation" in implementation + assert "read-only repeatable-read transaction" in implementation + assert "caller-owned same-connection" in architecture + assert "caller-owned connection" in contract diff --git a/backend/tests/test_durable_dry_run_worker_documentation.py b/backend/tests/test_durable_dry_run_worker_documentation.py new file mode 100644 index 000000000..82255ab4d --- /dev/null +++ b/backend/tests/test_durable_dry_run_worker_documentation.py @@ -0,0 +1,165 @@ +"""Guard the partial durable dry-run worker documentation contract.""" + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +WORKER_CONTRACT = Path("docs/contracts/durable-dry-run-worker-v1.md") +WORKER_ADR = Path("docs/adr/ADR-0002-isolated-dry-run-and-preflight.md") + + +def _read(path: Path) -> str: + return (REPOSITORY_ROOT / path).read_text(encoding="utf-8") + + +def test_durable_dry_run_worker_contract_is_canonical_and_linked() -> None: + """Keep the versioned worker contract reachable from its accepted ADR.""" + + assert (REPOSITORY_ROOT / WORKER_CONTRACT).is_file() + contract = _read(WORKER_CONTRACT) + adr = _read(WORKER_ADR) + + assert "durable-dry-run-worker/v1" in contract + assert "**Capability status:** Partial" in contract + assert "../contracts/durable-dry-run-worker-v1.md" in adr + + +def test_worker_contract_preserves_authority_and_maturity_boundaries() -> None: + """Prevent orchestration from being documented as provider or apply authority.""" + + contract = _read(WORKER_CONTRACT) + normalized = " ".join(contract.split()) + required = ( + "make_durable_dry_run_attempt_handler", + "MigrationRunAttemptClaim", + "IsolatedSandboxRequest", + "LivePreflightRequest", + "guard_live_preflight_handoff", + "load_guarded_live_preflight_target", + "make_stored_postgres_live_preflight_factory", + "make_stored_postgres_durable_dry_run_attempt_handler", + "same session factory", + "guarded DNS/SSRF/TLS connection", + "same acquired connection", + "post-connect revalidation", + "before any target read", + "encrypted DSN ciphertext and nonce", + "base_schema_snapshot_uuid", + "schema_filter", + "exact succeeded base snapshot", + "execute_isolated_dry_run", + "execute_bound_live_preflight", + "complete_isolated_dry_run", + "complete_live_preflight", + "exact expected run state version", + "one fresh database statement", + "PostgreSQL 14–18 matrix stores", # noqa: RUF001 - contract uses en dash + "does not eliminate the gap", + "does not implement or prove", + "Application startup wiring and deployed credential/network isolation remain Planned", + "live apply", + "production readiness", + ) + + assert [term for term in required if term not in normalized] == [] + + +def test_worker_contract_names_are_present_in_production_source() -> None: + """Bind published contract names to the implementation modules.""" + + implementation = _read(Path("backend/app/jobs/migration_dry_run_worker.py")) + provider = _read(Path("backend/app/jobs/live_preflight_provider.py")) + authority = _read( + Path("backend/app/jobs/migration_dry_run_worker_contract.py") + ) + + assert "make_durable_dry_run_attempt_handler" in implementation + assert "guard_live_preflight_handoff" in implementation + assert "load_guarded_live_preflight_target" in implementation + assert "class GuardedLivePreflightTarget" in implementation + assert "base_schema_snapshot_uuid" in implementation + assert "schema_filter" in implementation + assert "make_stored_postgres_live_preflight_factory" in provider + assert ( + "make_stored_postgres_durable_dry_run_attempt_handler" in provider + ) + assert "connect_guarded_postgres" in provider + assert "capture_postgres_snapshot" in provider + assert "_refresh_live_stage" in implementation + assert "class IsolatedSandboxRequest" in authority + assert "class LivePreflightRequest" in authority + + +def test_postgres_matrix_composes_the_stored_target_provider() -> None: + """Require version acceptance to exercise stored metadata and decryption.""" + + integration = _read( + Path("backend/tests/test_postgres_migration_run_integration.py") + ) + + assert "make_stored_postgres_live_preflight_factory" in integration + assert ( + "make_stored_postgres_durable_dry_run_attempt_handler" in integration + ) + assert "encrypt_text(_preflight_asyncpg_url())" in integration + assert "provider_factory(request)" in integration + assert "test-only loopback connector" in integration + + +def test_uml_marks_durable_dry_run_sequence_partial_without_deployment_claims() -> None: + """Keep the sequence maturity aligned with composition and deployment gaps.""" + + uml = _read(Path("docs/UML.md")) + dry_run_section = uml.split("## Target dry-run sequence", 1)[1].split( + "## Target apply and verification sequence", 1 + )[0] + normalized = " ".join(dry_run_section.split()) + + assert "**Status: Partially implemented.**" in normalized + assert "provider-neutral durable worker orchestration" in normalized + assert "sandbox provisioning" in normalized + assert "stored-target live-preflight provider factory" in normalized + assert "DNS/SSRF/TLS guard" in normalized + assert "test-only loopback connector" in normalized + assert "unmodified guarded-route composition" in normalized + assert "application startup wiring" in normalized + assert "**Planned**" in normalized + + +def test_worker_contract_bounds_whole_capability_stages() -> None: + """Keep cancellation deadlines distinct from proven provider termination.""" + + contract = " ".join(_read(WORKER_CONTRACT).split()).lower() + assert "whole-stage sandbox and preflight cancellation deadlines" in contract + assert "timeout cancellation and capability cleanup" in contract + assert "cooperative cancellation" in contract + assert "does not prove a hard wall-clock termination bound" in contract + + +def test_doctoring_evidence_paths_are_repository_root_relative() -> None: + """Keep published test evidence clickable from the repository root.""" + + documents = ( + _read(Path("docs/doctoring/dbml-identifier-ddl-boundary.md")), + _read(Path("docs/doctoring/multiline-sql-request-controls.md")), + ) + expected_paths = ( + Path("backend/tests/test_dbml_import.py"), + Path("backend/tests/test_api_dbml.py"), + Path("backend/tests/test_fuzz_properties.py"), + Path("backend/tests/test_postgres_migration_run_integration.py"), + Path("backend/tests/test_schema_validation.py"), + Path("backend/tests/test_request_validation.py"), + Path("backend/tests/test_api_apply_sql.py"), + ) + + assert all("`tests/" not in document for document in documents) + assert all((REPOSITORY_ROOT / path).is_file() for path in expected_paths) + + +def test_uml_does_not_mark_implemented_attempt_binding_as_planned() -> None: + """Keep the component and sequence maturity statements consistent.""" + + uml = _read(Path("docs/UML.md")) + + assert "worker/attempt binding" not in uml diff --git a/backend/tests/test_forward_apply_lock_plan.py b/backend/tests/test_forward_apply_lock_plan.py new file mode 100644 index 000000000..a21a00093 --- /dev/null +++ b/backend/tests/test_forward_apply_lock_plan.py @@ -0,0 +1,186 @@ +"""Deterministic pre-apply table-lock planning contract tests.""" + +from __future__ import annotations + +import pytest + +from app.forward.apply_lock_plan import ( + ApplyLockPlanContractError, + compile_apply_lock_targets, +) +from app.forward.migration_plan import COMPILER_VERSION + + +def _statement( + kind: str, + schema_name: object, + table_name: object, + *, + lock_mode: object = "ACCESS EXCLUSIVE", + transactional: object = True, +) -> dict[str, object]: + """Build one structured statement without depending on rendered SQL.""" + + return { + "kind": kind, + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + }, + "risk": {"lock_mode": lock_mode}, + "transactional": transactional, + } + + +def _plan(*statements: dict[str, object]) -> dict[str, object]: + """Build one executable single-segment plan.""" + + return { + "compiler_version": COMPILER_VERSION, + "blockers": [], + "can_dry_run": True, + "statements": list(statements), + } + + +def test_compiles_sorted_unique_existing_table_locks_without_sql_reparsing() -> None: + """Lock targets come only from structured refs and use deterministic order.""" + + plan = _plan( + _statement("add_column", "zeta", "orders"), + _statement("drop_column", "alpha", 'Order "Item"'), + _statement("set_not_null", "zeta", "orders"), + _statement("create_table", "alpha", "new_table"), + { + "kind": "create_schema", + "object_ref": {"schema_name": "new_schema"}, + "risk": {"lock_mode": "none"}, + "transactional": True, + }, + ) + + targets = compile_apply_lock_targets(plan) + + assert [ + (target.schema_name, target.table_name, target.sql) for target in targets + ] == [ + ( + "alpha", + 'Order "Item"', + 'LOCK TABLE "alpha"."Order ""Item""" IN ACCESS EXCLUSIVE MODE', + ), + ( + "zeta", + "orders", + 'LOCK TABLE "zeta"."orders" IN ACCESS EXCLUSIVE MODE', + ), + ] + + +def test_preserves_mixed_case_and_unicode_identifiers() -> None: + """PostgreSQL delimited identifiers retain exact reviewed spelling.""" + + (target,) = compile_apply_lock_targets( + _plan(_statement("drop_table", "영업 Schema", "MixedCase")) + ) + + assert target.sql == ( + 'LOCK TABLE "영업 Schema"."MixedCase" IN ACCESS EXCLUSIVE MODE' + ) + + +@pytest.mark.parametrize( + ("plan", "message"), + [ + ( + { + "compiler_version": "future", + "blockers": [], + "can_dry_run": True, + "statements": [], + }, + "compiler is unsupported", + ), + ( + { + "blockers": [], + "can_dry_run": True, + "statements": [], + }, + "compiler is unsupported", + ), + ( + { + "compiler_version": COMPILER_VERSION, + "blockers": [{"code": "blocked"}], + "can_dry_run": False, + "statements": [], + }, + "cannot enter apply lock planning", + ), + ( + _plan(_statement("create_index_concurrently", "public", "orders")), + "unsupported apply statement kind", + ), + ( + _plan(_statement("add_column", "public", "orders", transactional=False)), + "must be transactional", + ), + ( + _plan(_statement("add_column", "public", "orders", lock_mode="SHARE")), + "lock mode is invalid", + ), + ( + _plan(_statement("drop_table", "bad\x00schema", "orders")), + "identifier is invalid", + ), + ( + _plan(_statement("drop_table", "public", "x" * 64)), + "identifier is too large", + ), + ( + {**_plan(), "statements": "not-a-list"}, + "statements must be a list", + ), + ( + {**_plan(), "statements": ["not-an-object"]}, + "statement must be an object", + ), + ( + { + **_plan(), + "statements": [ + { + **_statement("drop_table", "public", "orders"), + "object_ref": "not-an-object", + } + ], + }, + "object reference is invalid", + ), + ], +) +def test_fails_closed_for_non_executable_or_tampered_lock_inputs( + plan: dict[str, object], message: str +) -> None: + """Malformed or unsupported plan metadata never produces lock SQL.""" + + with pytest.raises(ApplyLockPlanContractError, match=message): + compile_apply_lock_targets(plan) + + +def test_rejects_more_than_the_bounded_statement_count() -> None: + """A tampered oversized plan cannot bypass lock planning with new objects.""" + + statements = [ + { + "kind": "create_schema", + "object_ref": {"schema_name": f"schema_{index}"}, + "risk": {"lock_mode": "none"}, + "transactional": True, + } + for index in range(1001) + ] + + with pytest.raises(ApplyLockPlanContractError, match="too many statements"): + compile_apply_lock_targets(_plan(*statements)) diff --git a/backend/tests/test_forward_isolated_dry_run.py b/backend/tests/test_forward_isolated_dry_run.py new file mode 100644 index 000000000..d3e679252 --- /dev/null +++ b/backend/tests/test_forward_isolated_dry_run.py @@ -0,0 +1,768 @@ +"""Isolated PostgreSQL dry-run contract tests.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from collections.abc import Mapping +from typing import Any + +import pytest + +from app.forward.isolated_dry_run import ( + IsolatedDryRunContractError, + execute_isolated_dry_run, +) +from app.forward.migration_plan import compile_migration_plan + + +def _models() -> tuple[dict[str, Any], dict[str, Any]]: + base = {"format_version": 1, "postgresql_major": 18, "schemas": []} + target = { + "format_version": 1, + "postgresql_major": 18, + "schemas": [ + { + "schema_name": "Sales Data", + "tables": [ + { + "table_name": 'Order "Item"', + "columns": [ + { + "column_name": "Item ID", + "data_type": "bigint", + "nullable": True, + "ordinal_position": 1, + } + ], + } + ], + } + ], + } + return base, target + + +def _snapshots() -> tuple[dict[str, Any], dict[str, Any]]: + empty = { + "snapshot_contract_version": 1, + "server_version_num": 180002, + "schemas": [], + "relations": [], + "columns": [], + "pk_columns": [], + "constraints": [], + "fk_edges": [], + "indexes": [], + } + target = { + **empty, + "schemas": [{"schema_oid": 11, "schema_name": "Sales Data"}], + "relations": [ + { + "relation_oid": 42, + "schema_name": "Sales Data", + "relation_name": 'Order "Item"', + "relation_kind": "r", + } + ], + "columns": [ + { + "relation_oid": 42, + "column_name": "Item ID", + "data_type": "bigint", + "is_not_null": False, + "column_position": 1, + } + ], + } + return empty, target + + +def _resign(plan: Mapping[str, Any]) -> dict[str, Any]: + signed = dict(plan) + signed.pop("plan_digest", None) + digest = hashlib.sha256( + json.dumps( + signed, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + signed["plan_digest"] = digest + return signed + + +class _PreparedStatement: + def __init__(self, connection: "_Connection", sql: str) -> None: + self.connection = connection + self.sql = sql + + async def fetch(self, *, timeout: float) -> list[object]: + self.connection.calls.append(("prepared_fetch", self.sql, timeout)) + if self.connection.fail_statement == self.sql: + raise RuntimeError("driver detail containing a secret") + return [] + + +class _Transaction: + def __init__(self, connection: "_Connection") -> None: + self.connection = connection + + async def start(self) -> None: + self.connection.calls.append(("start",)) + self.connection.transaction_started = True + + async def commit(self) -> None: + self.connection.calls.append(("commit",)) + self.connection.transaction_started = False + + async def rollback(self) -> None: + self.connection.calls.append(("rollback",)) + self.connection.transaction_started = False + + +class _Connection: + def __init__(self, *, major: int = 18) -> None: + self.major = major + self.calls: list[tuple[object, ...]] = [] + self.fail_statement: str | None = None + self.transaction_started = False + + async def fetchval(self, query: str) -> int: + self.calls.append(("fetchval", query)) + return self.major * 10_000 + + def transaction(self) -> _Transaction: + self.calls.append(("transaction",)) + return _Transaction(self) + + async def execute(self, query: str, value: str) -> str: + self.calls.append(("execute", query, value)) + return "SELECT 1" + + async def prepare(self, sql: str) -> _PreparedStatement: + self.calls.append(("prepare", sql)) + return _PreparedStatement(self, sql) + + +@pytest.mark.asyncio +async def test_executes_exact_signed_plan_and_requires_semantic_convergence() -> None: + """Verify executes exact signed plan and requires semantic convergence.""" + base, target = _models() + plan = compile_migration_plan(base, target) + connection = _Connection() + snapshots = iter(_snapshots()) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return next(snapshots) + + evidence = await execute_isolated_dry_run( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + lock_timeout_ms=750, + statement_timeout_ms=2_500, + ) + + assert evidence == { + "postgresql_major": 18, + "statement_count": 2, + "base_digest": plan["base_digest"], + "target_digest": plan["target_digest"], + "converged": True, + } + assert connection.calls == [ + ( + "fetchval", + "SELECT pg_catalog.current_setting('server_version_num')::integer", + ), + ("transaction",), + ("start",), + ( + "execute", + "SELECT pg_catalog.set_config('lock_timeout', $1, true)", + "750", + ), + ( + "execute", + "SELECT pg_catalog.set_config('statement_timeout', $1, true)", + "2500", + ), + ("prepare", plan["statements"][0]["sql"]), + ("prepared_fetch", plan["statements"][0]["sql"], 3.5), + ("prepare", plan["statements"][1]["sql"]), + ("prepared_fetch", plan["statements"][1]["sql"], 3.5), + ("commit",), + ] + + +@pytest.mark.asyncio +async def test_rejects_wrong_server_or_tampered_plan_before_transaction() -> None: + """Verify rejects wrong server or tampered plan before transaction.""" + base, target = _models() + plan = compile_migration_plan(base, target) + base_snapshot, _target_snapshot = _snapshots() + wrong_server = _Connection(major=17) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError, match="major version mismatch"): + await execute_isolated_dry_run( + wrong_server, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert all(call[0] != "transaction" for call in wrong_server.calls) + + tampered = {**plan, "target_digest": "f" * 64} + untouched = _Connection() + with pytest.raises(IsolatedDryRunContractError, match="digest is invalid"): + await execute_isolated_dry_run( + untouched, + tampered, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert untouched.calls == [] + + +@pytest.mark.asyncio +async def test_rolls_back_and_masks_statement_failure() -> None: + """Verify rolls back and masks statement failure.""" + base, target = _models() + plan = compile_migration_plan(base, target) + base_snapshot, _target_snapshot = _snapshots() + connection = _Connection() + connection.fail_statement = plan["statements"][1]["sql"] + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError) as captured: + await execute_isolated_dry_run( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + + assert str(captured.value) == "isolated dry-run statement failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + assert ("rollback",) in connection.calls + assert ("commit",) not in connection.calls + + +@pytest.mark.asyncio +async def test_propagates_cancellation_after_rollback() -> None: + """Verify propagates cancellation after rollback.""" + base, target = _models() + plan = compile_migration_plan(base, target) + base_snapshot, _target_snapshot = _snapshots() + connection = _Connection() + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + async def cancelled_fetch(*, timeout: float) -> list[object]: + assert timeout == 31.0 + raise asyncio.CancelledError + + statement = await connection.prepare(plan["statements"][0]["sql"]) + statement.fetch = cancelled_fetch # type: ignore[method-assign] + + async def prepare(_sql: str) -> _PreparedStatement: + return statement + + connection.prepare = prepare # type: ignore[method-assign] + with pytest.raises(asyncio.CancelledError): + await execute_isolated_dry_run( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert ("rollback",) in connection.calls + + +@pytest.mark.asyncio +async def test_rejects_non_transactional_or_nonconvergent_plan() -> None: + """Verify rejects non transactional or nonconvergent plan.""" + base, target = _models() + plan = compile_migration_plan(base, target) + base_snapshot, _target_snapshot = _snapshots() + + async def base_capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + non_transactional = _resign({ + **plan, + "statements": [{**plan["statements"][0], "transactional": False}], + }) + with pytest.raises(IsolatedDryRunContractError, match="non-transactional"): + await execute_isolated_dry_run( + _Connection(), + non_transactional, + expected_plan_digest=non_transactional["plan_digest"], + capture_snapshot=base_capture, + ) + + snapshots = iter((base_snapshot, base_snapshot)) + + async def unchanged(_connection: _Connection) -> Mapping[str, Any]: + return next(snapshots) + + with pytest.raises(IsolatedDryRunContractError) as captured: + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=unchanged, + ) + assert str(captured.value) == "isolated dry run did not converge" + assert captured.value.__cause__ is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ({"compiler_version": "future"}, "compiler is unsupported"), + ({"can_dry_run": False}, "not dry-runnable"), + ({"blockers": [{"code": "blocked"}]}, "not dry-runnable"), + ({"postgresql_major": True}, "major is invalid"), + ({"postgresql_major": 13}, "major is invalid"), + ({"postgresql_major": 19}, "major is invalid"), + ({"base_digest": "A" * 64}, "base digest is invalid"), + ({"target_digest": None}, "target digest is invalid"), + ({"statements": None}, "statements are invalid"), + ({"statements": ["invalid"]}, "statement is invalid"), + ], +) +async def test_rejects_resigned_invalid_plan_shapes( + mutation: Mapping[str, Any], message: str +) -> None: + """Verify rejects resigned invalid plan shapes.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + invalid = _resign({**compile_migration_plan(base, target), **mutation}) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError, match=message): + await execute_isolated_dry_run( + _Connection(), + invalid, + expected_plan_digest=invalid["plan_digest"], + capture_snapshot=capture, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mutation", + [ + {"unexpected": "authority drift"}, + {"statements": []}, + {"proposed_statements": [{"kind": "create_schema"}]}, + ], +) +async def test_rejects_resigned_noncanonical_executable_plan( + mutation: Mapping[str, Any], +) -> None: + """Verify rejects resigned noncanonical executable plan.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + invalid = _resign({**compile_migration_plan(base, target), **mutation}) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError, match="plan contract"): + await execute_isolated_dry_run( + _Connection(), + invalid, + expected_plan_digest=invalid["plan_digest"], + capture_snapshot=capture, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("statement_mutation", "message"), + [ + ({"kind": "future_operation"}, "kind is unsupported"), + ({"transactional": False}, "non-transactional"), + ({"sql": None}, "SQL is invalid"), + ({"sql": ""}, "SQL is invalid"), + ({"sql": "x" * 262_145}, "SQL is invalid"), + ], +) +async def test_rejects_resigned_invalid_statement_shapes( + statement_mutation: Mapping[str, Any], message: str +) -> None: + """Verify rejects resigned invalid statement shapes.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + invalid = _resign( + { + **plan, + "statements": [{**plan["statements"][0], **statement_mutation}], + } + ) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError, match=message): + await execute_isolated_dry_run( + _Connection(), + invalid, + expected_plan_digest=invalid["plan_digest"], + capture_snapshot=capture, + ) + + +@pytest.mark.asyncio +async def test_rejects_resigned_statement_with_unknown_field() -> None: + """Verify rejects resigned statement with unknown field.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + invalid = _resign( + { + **plan, + "statements": [{**plan["statements"][0], "raw_sql": "hidden"}], + } + ) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError, match="statement contract"): + await execute_isolated_dry_run( + _Connection(), + invalid, + expected_plan_digest=invalid["plan_digest"], + capture_snapshot=capture, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("lock_timeout", "statement_timeout"), + [(True, 1), (0, 1), (60_001, 1), (1, False), (1, 0), (1, 300_001)], +) +async def test_rejects_timeout_bounds( + lock_timeout: int, statement_timeout: int +) -> None: + """Verify rejects timeout bounds.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError, match="timeout"): + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + lock_timeout_ms=lock_timeout, + statement_timeout_ms=statement_timeout, + ) + + +@pytest.mark.asyncio +async def test_rejects_invalid_expected_digest_and_oversized_statement_list() -> None: + """Verify rejects invalid expected digest and oversized statement list.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + with pytest.raises(IsolatedDryRunContractError, match="expected plan digest"): + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest="A" * 64, + capture_snapshot=capture, + ) + + oversized = _resign({**plan, "statements": [plan["statements"][0]] * 1_001}) + with pytest.raises(IsolatedDryRunContractError, match="statements are invalid"): + await execute_isolated_dry_run( + _Connection(), + oversized, + expected_plan_digest=oversized["plan_digest"], + capture_snapshot=capture, + ) + + +@pytest.mark.asyncio +async def test_rejects_capture_failures_invalid_snapshots_and_wrong_base() -> None: + """Verify rejects capture failures invalid snapshots and wrong base.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + + async def failed(_connection: _Connection) -> Mapping[str, Any]: + raise RuntimeError("secret driver detail") + + with pytest.raises(IsolatedDryRunContractError) as captured: + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=failed, + ) + assert str(captured.value) == "isolated sandbox snapshot capture failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + async def not_mapping(_connection: _Connection) -> Any: + return [] + + with pytest.raises( + IsolatedDryRunContractError, match="snapshot is invalid" + ) as captured: + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=not_mapping, + ) + + invalid_snapshot = {**base_snapshot, "snapshot_contract_version": 999} + + async def invalid(_connection: _Connection) -> Mapping[str, Any]: + return invalid_snapshot + + with pytest.raises( + IsolatedDryRunContractError, match="snapshot is invalid" + ) as captured: + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=invalid, + ) + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + _empty, wrong_target_snapshot = _snapshots() + + async def wrong_base(_connection: _Connection) -> Mapping[str, Any]: + return wrong_target_snapshot + + with pytest.raises(IsolatedDryRunContractError, match="planned base"): + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=wrong_base, + ) + + +@pytest.mark.asyncio +async def test_masks_version_and_transaction_start_failures() -> None: + """Verify masks version and transaction start failures.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + class VersionFailure(_Connection): + async def fetchval(self, query: str) -> int: + raise RuntimeError("secret version failure") + + with pytest.raises(IsolatedDryRunContractError) as captured: + await execute_isolated_dry_run( + VersionFailure(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert str(captured.value) == "isolated PostgreSQL version check failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + class StartFailureTransaction(_Transaction): + async def start(self) -> None: + raise RuntimeError("secret start failure") + + class StartFailure(_Connection): + def transaction(self) -> _Transaction: + return StartFailureTransaction(self) + + start_failure = StartFailure() + with pytest.raises( + IsolatedDryRunContractError, match="statement failed" + ) as captured: + await execute_isolated_dry_run( + start_failure, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + assert ("rollback",) not in start_failure.calls + + class CommitFailureTransaction(_Transaction): + async def commit(self) -> None: + raise RuntimeError("secret commit failure") + + class CommitFailure(_Connection): + def transaction(self) -> _Transaction: + return CommitFailureTransaction(self) + + commit_failure = CommitFailure() + with pytest.raises( + IsolatedDryRunContractError, match="statement failed" + ) as captured: + await execute_isolated_dry_run( + commit_failure, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + assert ("rollback",) in commit_failure.calls + + +@pytest.mark.asyncio +async def test_preserves_cancellation_during_capture_and_version_check() -> None: + """Verify preserves cancellation during capture and version check.""" + base, target = _models() + plan = compile_migration_plan(base, target) + + async def cancelled_capture(_connection: _Connection) -> Mapping[str, Any]: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await execute_isolated_dry_run( + _Connection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=cancelled_capture, + ) + + class CancelledVersion(_Connection): + async def fetchval(self, query: str) -> int: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await execute_isolated_dry_run( + CancelledVersion(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=cancelled_capture, + ) + + +@pytest.mark.asyncio +async def test_masks_rollback_cleanup_failure_without_hiding_primary_failure() -> None: + """Verify masks rollback cleanup failure without hiding primary failure.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + class RollbackFailureTransaction(_Transaction): + async def rollback(self) -> None: + raise RuntimeError("secret rollback failure") + + class RollbackFailureConnection(_Connection): + def transaction(self) -> _Transaction: + return RollbackFailureTransaction(self) + + connection = RollbackFailureConnection() + connection.fail_statement = plan["statements"][0]["sql"] + with pytest.raises(IsolatedDryRunContractError) as captured: + await execute_isolated_dry_run( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert str(captured.value) == "isolated dry-run statement failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + +@pytest.mark.asyncio +async def test_preserves_cancellation_when_rollback_cleanup_fails() -> None: + """Verify preserves cancellation when rollback cleanup fails.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + class CancelledPreparedStatement(_PreparedStatement): + async def fetch(self, *, timeout: float) -> list[object]: + raise asyncio.CancelledError + + class RollbackFailureTransaction(_Transaction): + async def rollback(self) -> None: + raise RuntimeError("secret rollback failure") + + class CancelledRollbackFailureConnection(_Connection): + def transaction(self) -> _Transaction: + return RollbackFailureTransaction(self) + + async def prepare(self, sql: str) -> _PreparedStatement: + return CancelledPreparedStatement(self, sql) + + with pytest.raises(asyncio.CancelledError): + await execute_isolated_dry_run( + CancelledRollbackFailureConnection(), + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + + +@pytest.mark.asyncio +async def test_preserves_cancellation_before_transaction_start() -> None: + """Verify preserves cancellation before transaction start.""" + base, target = _models() + base_snapshot, _target_snapshot = _snapshots() + plan = compile_migration_plan(base, target) + + async def capture(_connection: _Connection) -> Mapping[str, Any]: + return base_snapshot + + class CancelledStartTransaction(_Transaction): + async def start(self) -> None: + raise asyncio.CancelledError + + class CancelledStartConnection(_Connection): + def transaction(self) -> _Transaction: + return CancelledStartTransaction(self) + + connection = CancelledStartConnection() + with pytest.raises(asyncio.CancelledError): + await execute_isolated_dry_run( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, + ) + assert ("rollback",) not in connection.calls diff --git a/backend/tests/test_forward_live_preflight.py b/backend/tests/test_forward_live_preflight.py new file mode 100644 index 000000000..279c827d2 --- /dev/null +++ b/backend/tests/test_forward_live_preflight.py @@ -0,0 +1,848 @@ +"""Read-only live PostgreSQL preflight contract tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +import pytest + +from app.forward import live_preflight as live_preflight_module +from app.forward.live_preflight import ( + LivePreflightContractError, + compare_live_preflight_snapshot, + compile_live_preflight_queries, + execute_bound_live_preflight, + execute_live_preflight, +) +from app.forward.schema_model import schema_model_digest +from app.forward.snapshot_adapter import snapshot_to_schema_model + + +def _plan(*preconditions: Mapping[str, object]) -> dict[str, object]: + """Build a canonical executable plan for live-preflight tests.""" + + return { + "can_dry_run": True, + "blockers": [], + "statements": [ + { + "kind": "alter_column_type", + "preconditions": [dict(item) for item in preconditions], + } + ], + } + + +def _snapshot() -> dict[str, Any]: + """Build a strict canonical target snapshot for digest checks.""" + + return { + "snapshot_contract_version": 1, + "server_version_num": 180002, + "schemas": [{"schema_oid": 11, "schema_name": "Sales Data"}], + "relations": [ + { + "relation_oid": 42, + "schema_name": "Sales Data", + "relation_name": 'Order "Item"', + "relation_kind": "r", + } + ], + "columns": [ + { + "relation_oid": 42, + "column_name": "Item ID", + "data_type": "bigint", + "is_not_null": True, + "column_position": 1, + } + ], + "pk_columns": [], + "constraints": [], + "fk_edges": [], + "indexes": [], + } + + +def test_compares_strict_snapshot_digest_without_execution_authority() -> None: + """Snapshot comparison reports exact base match or drift without writing.""" + snapshot = _snapshot() + observed_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + plan = _plan() + plan["base_digest"] = observed_digest + + assert compare_live_preflight_snapshot(plan, snapshot) == { + "observed_base_digest": observed_digest, + "matches_plan_base": True, + } + + snapshot["relations"][0]["relation_name"] = "Changed" + drifted_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + assert compare_live_preflight_snapshot(plan, snapshot) == { + "observed_base_digest": drifted_digest, + "matches_plan_base": False, + } + + +@pytest.mark.parametrize("base_digest", [None, True, "A" * 64, "a" * 63]) +def test_rejects_invalid_planned_base_digest(base_digest: object) -> None: + """Malformed planned base digests fail closed before snapshot comparison.""" + plan = _plan() + plan["base_digest"] = base_digest + + with pytest.raises(LivePreflightContractError, match="base digest is invalid"): + compare_live_preflight_snapshot(plan, _snapshot()) + + +def test_snapshot_comparison_fails_closed_for_unsupported_target_semantics() -> None: + """Unsupported relation semantics cannot be accepted as matching evidence.""" + snapshot = _snapshot() + snapshot["relations"][0]["relation_kind"] = "v" + plan = _plan() + plan["base_digest"] = "a" * 64 + + with pytest.raises(LivePreflightContractError, match="relation kind"): + compare_live_preflight_snapshot(plan, snapshot) + + +def test_compiles_bounded_preconditions_with_postgresql_identifier_quoting() -> None: + """Known preconditions compile to bounded reads with exact quoted identifiers.""" + queries = compile_live_preflight_queries( + _plan( + { + "kind": "table_is_empty", + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + }, + { + "kind": "no_null_values", + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + "column_name": "Item ID", + }, + { + "kind": "castable_values", + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + "column_name": "Item ID", + "target_data_type": "numeric(12,2)", + }, + ) + ) + + assert [query.kind for query in queries] == [ + "table_is_empty", + "no_null_values", + "castable_values", + ] + assert queries[0].sql == ( + 'SELECT NOT EXISTS (SELECT 1 FROM "Sales Data"."Order ""Item""" LIMIT 1)' + ) + assert queries[1].sql == ( + 'SELECT NOT EXISTS (SELECT 1 FROM "Sales Data"."Order ""Item""" ' + 'WHERE "Item ID" IS NULL LIMIT 1)' + ) + assert queries[2].sql == ( + 'SELECT COALESCE(bool_and(("Item ID")::numeric(12,2) IS NOT NULL), TRUE) ' + 'FROM "Sales Data"."Order ""Item""" WHERE "Item ID" IS NOT NULL' + ) + + +@pytest.mark.parametrize( + "precondition, message", + [ + ( + { + "kind": "row_count_below", + "schema_name": "public", + "table_name": "orders", + }, + "unsupported live preflight precondition", + ), + ( + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + "unexpected": "field", + }, + "unrecognized field", + ), + ( + { + "kind": "castable_values", + "schema_name": "public", + "table_name": "orders", + "column_name": "amount", + "target_data_type": "integer); DROP TABLE orders; --", + }, + "unsupported data type", + ), + ], +) +def test_rejects_unknown_or_tampered_preconditions( + precondition: Mapping[str, object], message: str +) -> None: + """Verify rejects unknown or tampered preconditions.""" + with pytest.raises(LivePreflightContractError, match=message): + compile_live_preflight_queries(_plan(precondition)) + + +@pytest.mark.parametrize( + "schema_name, message", + [ + (42, "identifier must be text"), + ("", "identifier is invalid"), + ("bad\x00name", "identifier is invalid"), + ("a" * 64, "identifier is too large"), + ], +) +def test_rejects_invalid_postgresql_identifiers( + schema_name: object, message: str +) -> None: + """Verify rejects invalid postgresql identifiers.""" + with pytest.raises(LivePreflightContractError, match=message): + compile_live_preflight_queries( + _plan( + { + "kind": "table_is_empty", + "schema_name": schema_name, + "table_name": "orders", + } + ) + ) + + +@pytest.mark.parametrize( + "precondition, message", + [ + ({"kind": "table_is_empty", "schema_name": "public"}, "missing field"), + ( + {"kind": 7, "schema_name": "public", "table_name": "orders"}, + "kind is invalid", + ), + ], +) +def test_rejects_missing_fields_and_non_text_kinds( + precondition: Mapping[str, object], message: str +) -> None: + """Verify rejects missing fields and non text kinds.""" + with pytest.raises(LivePreflightContractError, match=message): + compile_live_preflight_queries(_plan(precondition)) + + +@pytest.mark.parametrize( + "plan, message", + [ + ( + {"can_dry_run": False, "blockers": [], "statements": []}, + "cannot enter", + ), + ( + {"can_dry_run": True, "blockers": [{"code": "blocked"}], "statements": []}, + "cannot enter", + ), + ( + {"can_dry_run": True, "blockers": [], "statements": "invalid"}, + "statements must be a list", + ), + ( + {"can_dry_run": True, "blockers": [], "statements": ["invalid"]}, + "statement must be an object", + ), + ( + { + "can_dry_run": True, + "blockers": [], + "statements": [{"preconditions": "invalid"}], + }, + "preconditions must be a list", + ), + ( + { + "can_dry_run": True, + "blockers": [], + "statements": [{"preconditions": ["invalid"]}], + }, + "precondition must be an object", + ), + ], +) +def test_rejects_non_executable_or_malformed_plan_shapes( + plan: Mapping[str, object], message: str +) -> None: + """Verify rejects non executable or malformed plan shapes.""" + with pytest.raises(LivePreflightContractError, match=message): + compile_live_preflight_queries(plan) + + +def test_rejects_more_than_the_bounded_query_count() -> None: + """Verify rejects more than the bounded query count.""" + precondition = { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + } + plan = { + "can_dry_run": True, + "blockers": [], + "statements": [ + {"kind": "add_column", "preconditions": [precondition]} + for _ in range(1001) + ], + } + + with pytest.raises(LivePreflightContractError, match="too many queries"): + compile_live_preflight_queries(plan) + + +class _FakeTransaction: + def __init__(self, connection: "_FakeConnection") -> None: + self.connection = connection + + async def start(self) -> None: + self.connection.started = True + + async def commit(self) -> None: + self.connection.committed = True + + async def rollback(self) -> None: + self.connection.rolled_back = True + + +class _FakePreparedStatement: + def __init__(self, connection: "_FakeConnection", sql: str) -> None: + self.connection = connection + self.sql = sql + + async def fetchval(self, *, timeout: float | None = None) -> object: + return await self.connection.fetch_prepared(self.sql, timeout=timeout) + + +class _FakeConnection: + def __init__(self, results: list[object]) -> None: + self.results = iter(results) + self.transaction_options: dict[str, object] | None = None + self.started = False + self.committed = False + self.rolled_back = False + self.executed: list[tuple[str, tuple[object, ...]]] = [] + self.prepared: list[str] = [] + self.queries: list[tuple[str, float | None]] = [] + + def transaction(self, **kwargs: object) -> _FakeTransaction: + self.transaction_options = kwargs + return _FakeTransaction(self) + + async def execute(self, sql: str, *args: object) -> None: + self.executed.append((sql, args)) + + async def prepare(self, sql: str) -> _FakePreparedStatement: + self.prepared.append(sql) + return _FakePreparedStatement(self, sql) + + async def fetch_prepared( + self, sql: str, *, timeout: float | None = None + ) -> object: + self.queries.append((sql, timeout)) + return next(self.results) + + +class _FailingConnection(_FakeConnection): + async def fetch_prepared( + self, sql: str, *, timeout: float | None = None + ) -> object: + self.queries.append((sql, timeout)) + raise RuntimeError("postgresql://user:secret@db.example.com/app row=private") + + +class _CancelledConnection(_FakeConnection): + async def fetch_prepared( + self, sql: str, *, timeout: float | None = None + ) -> object: + self.queries.append((sql, timeout)) + raise asyncio.CancelledError + + +class _TransactionCreationFailingConnection(_FakeConnection): + def transaction(self, **kwargs: object) -> _FakeTransaction: + self.transaction_options = kwargs + raise RuntimeError("postgresql://user:secret@db.example.com/app") + + +class _TransactionStartFailingTransaction(_FakeTransaction): + async def start(self) -> None: + raise RuntimeError("postgresql://user:secret@db.example.com/app") + + +class _TransactionStartFailingConnection(_FakeConnection): + def transaction(self, **kwargs: object) -> _FakeTransaction: + self.transaction_options = kwargs + return _TransactionStartFailingTransaction(self) + + +class _TransactionStartCancelledTransaction(_FakeTransaction): + async def start(self) -> None: + raise asyncio.CancelledError + + +class _TransactionStartCancelledConnection(_FakeConnection): + def transaction(self, **kwargs: object) -> _FakeTransaction: + self.transaction_options = kwargs + return _TransactionStartCancelledTransaction(self) + + +class _TransactionCommitFailingTransaction(_FakeTransaction): + async def commit(self) -> None: + raise RuntimeError("postgresql://user:secret@db.example.com/app") + + +class _TransactionCommitFailingConnection(_FakeConnection): + def transaction(self, **kwargs: object) -> _FakeTransaction: + self.transaction_options = kwargs + return _TransactionCommitFailingTransaction(self) + + +class _TransactionRollbackFailingTransaction(_FakeTransaction): + async def rollback(self) -> None: + raise RuntimeError("postgresql://user:secret@db.example.com/app") + + +class _TransactionRollbackFailingConnection(_FailingConnection): + def transaction(self, **kwargs: object) -> _FakeTransaction: + self.transaction_options = kwargs + return _TransactionRollbackFailingTransaction(self) + + +class _CancelledRollbackFailingConnection(_CancelledConnection): + def transaction(self, **kwargs: object) -> _FakeTransaction: + self.transaction_options = kwargs + return _TransactionRollbackFailingTransaction(self) + + +@pytest.mark.asyncio +async def test_binds_fresh_snapshot_and_checks_to_one_read_only_transaction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Capture and preconditions must observe one authorized DB snapshot.""" + + original_wait_for = asyncio.wait_for + client_timeouts: list[float] = [] + + async def record_wait_for(awaitable: Any, *, timeout: float) -> object: + client_timeouts.append(timeout) + return await original_wait_for(awaitable, timeout=timeout) + + monkeypatch.setattr( + live_preflight_module.asyncio, "wait_for", record_wait_for + ) + connection = _FakeConnection([True]) + snapshot = _snapshot() + plan = _plan( + { + "kind": "table_is_empty", + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + } + ) + observed_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + plan["base_digest"] = observed_digest + capture_calls: list[_FakeConnection] = [] + + async def capture(owned_connection: _FakeConnection) -> Mapping[str, Any]: + assert owned_connection.started is True + assert owned_connection.committed is False + capture_calls.append(owned_connection) + return snapshot + + evidence = await execute_bound_live_preflight( + connection, + plan, + capture_snapshot=capture, # type: ignore[arg-type] + statement_timeout_ms=2500, + ) + + assert capture_calls == [connection] + assert connection.transaction_options == { + "isolation": "repeatable_read", + "readonly": True, + } + assert connection.committed is True + assert connection.rolled_back is False + assert client_timeouts == [3.5] * 5 + assert evidence == { + "preconditions_passed": True, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + } + ], + "observed_base_digest": observed_digest, + "matches_plan_base": True, + } + + +@pytest.mark.asyncio +async def test_bound_capture_returns_drift_without_discarding_check_evidence() -> None: + """Verify bound capture returns drift without discarding check evidence.""" + connection = _FakeConnection([True]) + planned_snapshot = _snapshot() + plan = _plan( + { + "kind": "table_is_empty", + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + } + ) + plan["base_digest"] = schema_model_digest( + snapshot_to_schema_model(planned_snapshot) + ) + observed_snapshot = _snapshot() + observed_snapshot["relations"][0]["relation_name"] = "Changed" + observed_digest = schema_model_digest( + snapshot_to_schema_model(observed_snapshot) + ) + + async def capture(_: _FakeConnection) -> Mapping[str, Any]: + return observed_snapshot + + evidence = await execute_bound_live_preflight( + connection, + plan, + capture_snapshot=capture, # type: ignore[arg-type] + ) + + assert evidence["preconditions_passed"] is True + assert evidence["observed_base_digest"] == observed_digest + assert evidence["matches_plan_base"] is False + assert connection.committed is True + + +@pytest.mark.parametrize("capture_result", [None, "private row"]) +@pytest.mark.asyncio +async def test_bound_capture_rejects_non_snapshot_results( + capture_result: object, +) -> None: + """Verify bound capture rejects non snapshot results.""" + connection = _FakeConnection([]) + plan = _plan() + plan["base_digest"] = "a" * 64 + + async def capture(_: _FakeConnection) -> object: + return capture_result + + with pytest.raises( + LivePreflightContractError, match="snapshot capture is invalid" + ): + await execute_bound_live_preflight( + connection, + plan, + capture_snapshot=capture, # type: ignore[arg-type] + ) + + assert connection.rolled_back is True + + +@pytest.mark.parametrize( + "failure", + [ + RuntimeError("postgresql://user:secret@target.example/app"), + LivePreflightContractError("password=secret"), + ], +) +@pytest.mark.asyncio +async def test_bound_capture_sanitizes_failures_without_driver_detail( + failure: Exception, +) -> None: + """Verify bound capture sanitizes failures without driver detail.""" + connection = _FakeConnection([]) + plan = _plan() + plan["base_digest"] = "a" * 64 + + async def capture(_: _FakeConnection) -> Mapping[str, Any]: + raise failure + + with pytest.raises(LivePreflightContractError) as captured: + await execute_bound_live_preflight( + connection, + plan, + capture_snapshot=capture, # type: ignore[arg-type] + ) + + assert str(captured.value) == "live preflight query failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + assert connection.rolled_back is True + + +@pytest.mark.asyncio +async def test_bound_capture_requires_a_callable() -> None: + """Verify bound capture requires a callable.""" + connection = _FakeConnection([]) + + with pytest.raises( + LivePreflightContractError, match="snapshot capture is invalid" + ): + await execute_bound_live_preflight( + connection, + _plan(), + capture_snapshot=None, # type: ignore[arg-type] + ) + + assert connection.transaction_options is None + + +@pytest.mark.asyncio +async def test_bound_capture_preserves_cancellation_after_rollback() -> None: + """Verify bound capture preserves cancellation after rollback.""" + connection = _FakeConnection([]) + plan = _plan() + plan["base_digest"] = "a" * 64 + + async def capture(_: _FakeConnection) -> Mapping[str, Any]: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await execute_bound_live_preflight( + connection, + plan, + capture_snapshot=capture, # type: ignore[arg-type] + ) + + assert connection.rolled_back is True + + +@pytest.mark.asyncio +async def test_executes_only_bounded_reads_in_one_read_only_transaction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify executes only bounded reads in one read only transaction.""" + original_wait_for = asyncio.wait_for + client_timeouts: list[float] = [] + + async def record_wait_for( + awaitable: Any, *, timeout: float + ) -> object: + client_timeouts.append(timeout) + return await original_wait_for(awaitable, timeout=timeout) + + monkeypatch.setattr( + live_preflight_module.asyncio, "wait_for", record_wait_for + ) + connection = _FakeConnection([True, False]) + plan = _plan( + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + }, + { + "kind": "no_null_values", + "schema_name": "public", + "table_name": "orders", + "column_name": "customer_id", + }, + ) + + evidence = await execute_live_preflight( + connection, plan, statement_timeout_ms=2500 + ) + + assert connection.transaction_options == { + "isolation": "repeatable_read", + "readonly": True, + } + assert connection.started is True + assert connection.committed is True + assert connection.rolled_back is False + assert connection.executed == [ + ( + "SELECT pg_catalog.set_config('statement_timeout', $1, true)", + ("2500",), + ) + ] + assert connection.prepared == [sql for sql, _ in connection.queries] + assert [timeout for _, timeout in connection.queries] == [3.5, 3.5] + assert client_timeouts == [3.5] * 5 + assert evidence == { + "passed": False, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + }, + { + "statement_index": 0, + "precondition_index": 1, + "kind": "no_null_values", + "passed": False, + }, + ], + } + + +@pytest.mark.asyncio +async def test_rejects_non_boolean_database_evidence_without_row_values() -> None: + """Verify rejects non boolean database evidence without row values.""" + connection = _FakeConnection(["secret row value"]) + + with pytest.raises( + LivePreflightContractError, match="database result is not boolean" + ): + await execute_live_preflight( + connection, + _plan( + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + } + ), + ) + + assert connection.rolled_back is True + + +@pytest.mark.asyncio +async def test_replaces_database_failures_with_a_fixed_non_secret_error() -> None: + """Verify replaces database failures with a fixed non secret error.""" + connection = _FailingConnection([]) + + with pytest.raises(LivePreflightContractError) as captured: + await execute_live_preflight( + connection, + _plan( + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + } + ), + ) + + assert str(captured.value) == "live preflight query failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + assert connection.rolled_back is True + + +@pytest.mark.parametrize( + "connection_class", + [ + _TransactionCreationFailingConnection, + _TransactionStartFailingConnection, + ], +) +@pytest.mark.asyncio +async def test_sanitizes_transaction_initialization_failures_without_rollback( + connection_class: type[_FakeConnection], +) -> None: + """Verify sanitizes transaction initialization failures without rollback.""" + connection = connection_class([]) + with pytest.raises(LivePreflightContractError) as captured: + await execute_live_preflight(connection, _plan()) + + assert str(captured.value) == "live preflight query failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + assert connection.committed is False + assert connection.rolled_back is False + + +@pytest.mark.parametrize( + ("connection_class", "results"), + [ + (_TransactionCommitFailingConnection, (True,)), + (_TransactionRollbackFailingConnection, ()), + ], +) +@pytest.mark.asyncio +async def test_sanitizes_transaction_finalization_failures( + connection_class: type[_FakeConnection], + results: tuple[object, ...], +) -> None: + """Verify sanitizes transaction finalization failures.""" + connection = connection_class(list(results)) + with pytest.raises(LivePreflightContractError) as captured: + await execute_live_preflight( + connection, + _plan( + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + } + ), + ) + + assert str(captured.value) == "live preflight query failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + +@pytest.mark.parametrize("timeout", ["5000", True, 0, 60_001]) +@pytest.mark.asyncio +async def test_rejects_invalid_statement_timeouts(timeout: object) -> None: + """Verify rejects invalid statement timeouts.""" + with pytest.raises(LivePreflightContractError, match="timeout is invalid"): + await execute_live_preflight( # type: ignore[arg-type] + _FakeConnection([]), + _plan(), + statement_timeout_ms=timeout, + ) + + +@pytest.mark.asyncio +async def test_propagates_cancellation_after_rolling_back() -> None: + """Verify propagates cancellation after rolling back.""" + connection = _CancelledConnection([]) + + with pytest.raises(asyncio.CancelledError): + await execute_live_preflight( + connection, + _plan( + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + } + ), + ) + + assert connection.rolled_back is True + + +@pytest.mark.asyncio +async def test_preserves_cancellation_when_rollback_cleanup_fails() -> None: + """Verify preserves cancellation when rollback cleanup fails.""" + connection = _CancelledRollbackFailingConnection([]) + + with pytest.raises(asyncio.CancelledError): + await execute_live_preflight( + connection, + _plan( + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + } + ), + ) + + +@pytest.mark.asyncio +async def test_preserves_cancellation_before_transaction_start() -> None: + """Verify preserves cancellation before transaction start.""" + connection = _TransactionStartCancelledConnection([]) + + with pytest.raises(asyncio.CancelledError): + await execute_live_preflight(connection, _plan()) + + assert connection.rolled_back is False diff --git a/backend/tests/test_forward_live_preflight_castability.py b/backend/tests/test_forward_live_preflight_castability.py new file mode 100644 index 000000000..aecba86e1 --- /dev/null +++ b/backend/tests/test_forward_live_preflight_castability.py @@ -0,0 +1,229 @@ +"""Snapshot-bound castability savepoint regression tests.""" + +from __future__ import annotations + +from typing import Any + +import asyncpg +import pytest + +from app.forward.live_preflight import ( + LivePreflightContractError, + execute_bound_live_preflight, + execute_live_preflight, +) +from app.forward.schema_model import schema_model_digest +from app.forward.snapshot_adapter import snapshot_to_schema_model + + +def _snapshot() -> dict[str, Any]: + """Build one strict snapshot for the bound fingerprint evidence.""" + + return { + "snapshot_contract_version": 1, + "server_version_num": 180002, + "schemas": [{"schema_oid": 11, "schema_name": "public"}], + "relations": [ + { + "relation_oid": 42, + "schema_name": "public", + "relation_name": "orders", + "relation_kind": "r", + } + ], + "columns": [ + { + "relation_oid": 42, + "column_name": "good_amount", + "data_type": "text", + "is_not_null": False, + "column_position": 1, + }, + { + "relation_oid": 42, + "column_name": "bad_amount", + "data_type": "text", + "is_not_null": False, + "column_position": 2, + }, + ], + "pk_columns": [], + "constraints": [], + "fk_edges": [], + "indexes": [], + } + + +def _plan(base_digest: str) -> dict[str, object]: + """Build two casts and one ordinary read in deterministic order.""" + + return { + "base_digest": base_digest, + "can_dry_run": True, + "blockers": [], + "statements": [ + { + "preconditions": [ + { + "kind": "castable_values", + "schema_name": "public", + "table_name": "orders", + "column_name": "good_amount", + "target_data_type": "integer", + }, + { + "kind": "castable_values", + "schema_name": "public", + "table_name": "orders", + "column_name": "bad_amount", + "target_data_type": "integer", + }, + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "orders", + }, + ] + } + ], + } + + +class _UncastableValue(asyncpg.DataError): + """Represent one PostgreSQL class-22 cast failure with private detail.""" + + +class _FakeTransaction: + """Track one outer transaction or nested savepoint lifecycle.""" + + def __init__(self, connection: "_FakeConnection", *, nested: bool) -> None: + self.connection = connection + self.nested = nested + + async def start(self) -> None: + if self.nested: + self.connection.savepoint_started += 1 + else: + self.connection.outer_started = True + + async def commit(self) -> None: + if self.nested: + self.connection.savepoint_committed += 1 + else: + self.connection.outer_committed = True + + async def rollback(self) -> None: + if self.nested: + self.connection.savepoint_rolled_back += 1 + else: + self.connection.outer_rolled_back = True + + +class _FakePreparedStatement: + """Return booleans except for one deliberately uncastable column.""" + + def __init__(self, sql: str) -> None: + self.sql = sql + + async def fetchval(self, *, timeout: float) -> bool: + assert timeout > 0 + if '"bad_amount"' in self.sql: + raise _UncastableValue( + "invalid input syntax contains private target data" + ) + return True + + +class _FakeConnection: + """Expose the asyncpg surface used by the live-preflight primitive.""" + + def __init__(self) -> None: + self.outer_started = False + self.outer_committed = False + self.outer_rolled_back = False + self.savepoint_started = 0 + self.savepoint_committed = 0 + self.savepoint_rolled_back = 0 + self.prepared: list[str] = [] + + def transaction(self, **options: object) -> _FakeTransaction: + return _FakeTransaction(self, nested=not bool(options)) + + async def execute(self, sql: str, *args: object) -> None: + assert "statement_timeout" in sql + assert len(args) == 1 + + async def prepare(self, sql: str) -> _FakePreparedStatement: + self.prepared.append(sql) + return _FakePreparedStatement(sql) + + +@pytest.mark.asyncio +async def test_bound_cast_data_error_becomes_false_without_aborting_snapshot() -> None: + """Keep class-22 data failure as bounded evidence and continue later checks.""" + + snapshot = _snapshot() + base_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + connection = _FakeConnection() + + async def capture_snapshot(_: _FakeConnection) -> dict[str, Any]: + return snapshot + + evidence = await execute_bound_live_preflight( + connection, # type: ignore[arg-type] + _plan(base_digest), + capture_snapshot=capture_snapshot, # type: ignore[arg-type] + ) + + assert evidence == { + "preconditions_passed": False, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "castable_values", + "passed": True, + }, + { + "statement_index": 0, + "precondition_index": 1, + "kind": "castable_values", + "passed": False, + }, + { + "statement_index": 0, + "precondition_index": 2, + "kind": "table_is_empty", + "passed": True, + }, + ], + "observed_base_digest": base_digest, + "matches_plan_base": True, + } + assert connection.outer_started is True + assert connection.outer_committed is True + assert connection.outer_rolled_back is False + assert connection.savepoint_started == 2 + assert connection.savepoint_committed == 1 + assert connection.savepoint_rolled_back == 1 + assert len(connection.prepared) == 3 + + +@pytest.mark.asyncio +async def test_unbound_cast_data_error_remains_a_sanitized_non_success() -> None: + """Do not promote an unbound cast result into authoritative data evidence.""" + + connection = _FakeConnection() + + with pytest.raises(LivePreflightContractError) as captured: + await execute_live_preflight( + connection, # type: ignore[arg-type] + _plan("a" * 64), + ) + + assert str(captured.value) == "live preflight query failed" + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + assert connection.outer_committed is False + assert connection.outer_rolled_back is True + assert connection.savepoint_started == 0 diff --git a/backend/tests/test_forward_migration_plan.py b/backend/tests/test_forward_migration_plan.py new file mode 100644 index 000000000..606708df1 --- /dev/null +++ b/backend/tests/test_forward_migration_plan.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import copy + +import pytest + +from app.forward.migration_plan import compile_migration_plan + + +def _empty_model() -> dict: + return {"format_version": 1, "postgresql_major": 18, "schemas": []} + + +def _table_model(*, nullable: bool = False, data_type: str = "bigint") -> dict: + return { + "format_version": 1, + "postgresql_major": 18, + "schemas": [ + { + "schema_name": "Sales Data", + "tables": [ + { + "table_name": 'Order "Item"', + "comment": None, + "columns": [ + { + "column_name": "Item ID", + "data_type": data_type, + "nullable": nullable, + "ordinal_position": 1, + } + ], + "primary_key": { + "constraint_name": 'Order "Item" pkey', + "columns": ["Item ID"], + "deferrable": False, + "initially_deferred": False, + }, + "unique_constraints": [], + "foreign_keys": [], + "indexes": [], + "unsupported_features": [], + } + ], + } + ], + } + + +def test_plan_is_structured_deterministic_and_preserves_quoted_identifiers() -> None: + first = compile_migration_plan(_empty_model(), _table_model()) + second = compile_migration_plan(_empty_model(), copy.deepcopy(_table_model())) + + assert first == second + assert first["compiler_version"] == "pg-erd-forward/v1" + assert first["snapshot_contract_version"] == 1 + assert first["can_dry_run"] is True + assert len(first["plan_digest"]) == 64 + assert [statement["kind"] for statement in first["statements"]] == [ + "create_schema", + "create_table", + ] + create_table = first["statements"][1] + assert create_table["sql"] == ( + 'CREATE TABLE "Sales Data"."Order ""Item""" ' + '("Item ID" bigint NOT NULL, CONSTRAINT "Order ""Item"" pkey" ' + 'PRIMARY KEY ("Item ID"));' + ) + assert create_table["transactional"] is True + assert create_table["required_privileges"] == ["CREATE"] + assert create_table["dependencies"] == ["schema:Sales Data"] + + +def test_destructive_drop_has_explicit_risk_and_recovery_boundary() -> None: + target = _table_model() + target["schemas"][0]["tables"][0]["columns"].append( + { + "column_name": "Legacy Value", + "data_type": "text", + "nullable": True, + "ordinal_position": 2, + } + ) + plan = compile_migration_plan(target, _table_model()) + + drop = next(item for item in plan["statements"] if item["kind"] == "drop_column") + assert drop["risk"]["severity"] == "destructive" + assert drop["risk"]["data_loss"] is True + assert drop["risk"]["lock_mode"] == "ACCESS EXCLUSIVE" + assert drop["reversible"] is False + assert plan["risk_summary"]["destructive"] == 1 + assert plan["requires_destructive_confirmation"] is True + + +def test_type_and_not_null_changes_expose_preconditions_and_rewrite_warning() -> None: + base = _table_model(nullable=True, data_type="integer") + target = _table_model(nullable=False, data_type="bigint") + base["schemas"][0]["tables"][0]["primary_key"] = None + target["schemas"][0]["tables"][0]["primary_key"] = None + + plan = compile_migration_plan(base, target) + + type_change = next( + item for item in plan["statements"] if item["kind"] == "alter_column_type" + ) + not_null = next( + item for item in plan["statements"] if item["kind"] == "set_not_null" + ) + assert type_change["risk"]["possible_rewrite"] is True + assert type_change["preconditions"][0]["kind"] == "castable_values" + assert not_null["preconditions"] == [ + { + "kind": "no_null_values", + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + "column_name": "Item ID", + } + ] + + +def test_existing_primary_key_change_is_a_blocker_not_silent_sql() -> None: + target = _table_model() + target["schemas"][0]["tables"][0]["primary_key"] = None + + plan = compile_migration_plan(_table_model(), target) + + assert plan["can_dry_run"] is False + assert plan["statements"] == [] + assert plan["blockers"] == [ + { + "code": "primary_key_change_unsupported", + "object": 'Sales Data.Order "Item"', + "object_ref": { + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + }, + "detail": "Changing an existing primary key is not supported by compiler v1.", + } + ] + + +def test_no_changes_produces_empty_executable_plan() -> None: + model = _table_model() + + plan = compile_migration_plan(model, copy.deepcopy(model)) + + assert plan["statements"] == [] + assert plan["blockers"] == [] + assert plan["can_dry_run"] is True + assert plan["risk_summary"] == { + "safe": 0, + "warning": 0, + "destructive": 0, + } + + +def test_add_column_and_drop_table_paths_are_structured() -> None: + base = _table_model() + target = copy.deepcopy(base) + target["schemas"][0]["tables"][0]["columns"].append( + { + "column_name": "Required Value", + "data_type": "text", + "nullable": False, + "ordinal_position": 2, + } + ) + add_plan = compile_migration_plan(base, target) + added = next(item for item in add_plan["statements"] if item["kind"] == "add_column") + assert added["risk"]["severity"] == "warning" + assert added["preconditions"][0]["kind"] == "table_is_empty" + + empty_schema = _empty_model() + empty_schema["schemas"] = [{"schema_name": "Sales Data", "tables": []}] + drop_plan = compile_migration_plan(base, empty_schema) + assert drop_plan["statements"][0]["kind"] == "drop_table" + assert drop_plan["requires_destructive_confirmation"] is True + + +def test_default_expressions_fail_closed_before_sql_rendering() -> None: + target = _table_model() + target["schemas"][0]["tables"][0]["columns"][0]["default"] = "now()" + + with pytest.raises(ValueError, match="default expressions"): + compile_migration_plan(_empty_model(), target) + + +def test_nullable_add_and_drop_not_null_are_safe_paths() -> None: + base = _table_model(nullable=False) + base["schemas"][0]["tables"][0]["primary_key"] = None + target = copy.deepcopy(base) + target["schemas"][0]["tables"][0]["columns"][0]["nullable"] = True + target["schemas"][0]["tables"][0]["columns"].append( + { + "column_name": "Optional Value", + "data_type": "text", + "nullable": True, + "ordinal_position": 2, + } + ) + + plan = compile_migration_plan(base, target) + + assert {item["kind"] for item in plan["statements"]} == { + "add_column", + "drop_not_null", + } + assert all(item["risk"]["severity"] == "safe" for item in plan["statements"]) + + +def test_deferrable_primary_key_and_version_mismatch_paths() -> None: + target = _table_model() + target["schemas"][0]["tables"][0]["primary_key"].update( + {"deferrable": True, "initially_deferred": True} + ) + plan = compile_migration_plan(_empty_model(), target) + assert "DEFERRABLE INITIALLY DEFERRED" in plan["statements"][1]["sql"] + + base = _empty_model() + base["postgresql_major"] = 17 + blocked = compile_migration_plan(base, _empty_model()) + assert blocked["statements"] == [] + assert blocked["blockers"][0]["code"] == "postgresql_version_mismatch" + + +def test_create_table_without_pk_and_immediately_deferred_false_branch() -> None: + without_pk = _table_model() + without_pk["schemas"][0]["tables"][0]["primary_key"] = None + no_pk_plan = compile_migration_plan(_empty_model(), without_pk) + assert "PRIMARY KEY" not in no_pk_plan["statements"][1]["sql"] + + deferrable = _table_model() + deferrable["schemas"][0]["tables"][0]["primary_key"].update( + {"deferrable": True, "initially_deferred": False} + ) + plan = compile_migration_plan(_empty_model(), deferrable) + assert " DEFERRABLE" in plan["statements"][1]["sql"] + assert "INITIALLY DEFERRED" not in plan["statements"][1]["sql"] + + +def test_comment_changes_are_blockers_until_comment_sql_is_supported() -> None: + base = _table_model() + target = copy.deepcopy(base) + target["schemas"][0]["tables"][0]["comment"] = "new table comment" + target["schemas"][0]["tables"][0]["columns"][0]["comment"] = "new column comment" + + plan = compile_migration_plan(base, target) + + assert plan["can_dry_run"] is False + assert plan["statements"] == [] + assert {blocker["code"] for blocker in plan["blockers"]} == { + "table_comment_change_unsupported", + "column_comment_change_unsupported", + } + + +def test_new_table_with_comments_is_blocked_instead_of_losing_semantics() -> None: + target = _table_model() + target["schemas"][0]["tables"][0]["comment"] = "must survive apply" + + plan = compile_migration_plan(_empty_model(), target) + + assert plan["can_dry_run"] is False + assert plan["statements"] == [] + assert plan["blockers"][0]["code"] == "table_comment_change_unsupported" + + +def test_existing_column_reordering_is_an_explicit_blocker() -> None: + base = _table_model() + base_table = base["schemas"][0]["tables"][0] + base_table["columns"].append( + { + "column_name": "Second Column", + "data_type": "text", + "nullable": True, + "ordinal_position": 2, + } + ) + target = copy.deepcopy(base) + target_columns = target["schemas"][0]["tables"][0]["columns"] + target_columns[0]["ordinal_position"] = 2 + target_columns[1]["ordinal_position"] = 1 + + plan = compile_migration_plan(base, target) + + assert plan["can_dry_run"] is False + assert plan["statements"] == [] + assert plan["blockers"][0]["code"] == "column_order_change_unsupported" + + +def test_schema_removal_is_blocked_instead_of_leaving_an_empty_schema() -> None: + plan = compile_migration_plan(_table_model(), _empty_model()) + + assert plan["can_dry_run"] is False + assert plan["statements"] == [] + assert plan["blockers"] == [ + { + "code": "schema_removal_unsupported", + "object": "Sales Data", + "object_ref": {"schema_name": "Sales Data"}, + "detail": "Removing an existing schema is not supported by compiler v1.", + } + ] + + +def test_new_column_must_be_appended_to_preserve_physical_order() -> None: + base = _table_model() + target = copy.deepcopy(base) + target_columns = target["schemas"][0]["tables"][0]["columns"] + target_columns[0]["ordinal_position"] = 2 + target_columns.append( + { + "column_name": "Inserted First", + "data_type": "text", + "nullable": True, + "ordinal_position": 1, + } + ) + + plan = compile_migration_plan(base, target) + + assert plan["can_dry_run"] is False + assert plan["statements"] == [] + assert plan["blockers"][0]["code"] == "column_order_change_unsupported" + + +def test_appended_columns_emit_in_target_ordinal_order_not_name_order() -> None: + base = _table_model() + target = copy.deepcopy(base) + target["schemas"][0]["tables"][0]["columns"].extend( + [ + { + "column_name": "Zulu", + "data_type": "text", + "nullable": True, + "ordinal_position": 2, + }, + { + "column_name": "Alpha", + "data_type": "text", + "nullable": True, + "ordinal_position": 3, + }, + ] + ) + + plan = compile_migration_plan(base, target) + + assert [item["target"] for item in plan["statements"]] == [ + 'Sales Data.Order "Item".Zulu', + 'Sales Data.Order "Item".Alpha', + ] + + +def test_appended_column_ordinal_gap_is_blocked() -> None: + base = _table_model() + target = copy.deepcopy(base) + target["schemas"][0]["tables"][0]["columns"].append( + { + "column_name": "Gap", + "data_type": "text", + "nullable": True, + "ordinal_position": 3, + } + ) + + plan = compile_migration_plan(base, target) + + assert plan["statements"] == [] + assert plan["blockers"][0]["code"] == "column_order_change_unsupported" + + +def test_appended_column_after_historical_attnum_gap_is_allowed() -> None: + base = _table_model() + base_columns = base["schemas"][0]["tables"][0]["columns"] + base_columns.append( + { + "column_name": "After Dropped Slot", + "data_type": "text", + "nullable": True, + "ordinal_position": 3, + } + ) + target = copy.deepcopy(base) + target["schemas"][0]["tables"][0]["columns"].append( + { + "column_name": "Appended", + "data_type": "text", + "nullable": True, + "ordinal_position": 4, + } + ) + + plan = compile_migration_plan(base, target) + + assert plan["blockers"] == [] + assert plan["statements"][0]["target"].endswith(".Appended") + + +def test_blocked_plan_retains_supported_deltas_as_review_only_proposals() -> None: + base = _table_model(nullable=True, data_type="integer") + base["schemas"][0]["tables"][0]["primary_key"] = None + target = copy.deepcopy(base) + target_table = target["schemas"][0]["tables"][0] + target_table["comment"] = "blocked comment" + target_table["columns"][0]["data_type"] = "bigint" + target_table["columns"][0]["nullable"] = False + + plan = compile_migration_plan(base, target) + + assert plan["statements"] == [] + assert [item["kind"] for item in plan["proposed_statements"]] == [ + "alter_column_type", + "set_not_null", + ] + assert plan["risk_summary"] == {"safe": 0, "warning": 1, "destructive": 1} + assert plan["requires_destructive_confirmation"] is True + + +def test_primary_key_blocker_does_not_hide_an_independent_supported_delta() -> None: + base = _table_model(nullable=False, data_type="integer") + target = copy.deepcopy(base) + target["schemas"][0]["tables"][0]["primary_key"] = None + target["schemas"][0]["tables"][0]["columns"][0]["data_type"] = "bigint" + + plan = compile_migration_plan(base, target) + + assert plan["statements"] == [] + assert plan["blockers"][0]["code"] == "primary_key_change_unsupported" + assert [item["kind"] for item in plan["proposed_statements"]] == [ + "alter_column_type" + ] + + +def test_structured_object_refs_disambiguate_delimiter_bearing_identifiers() -> None: + target = _empty_model() + first_table = copy.deepcopy(_table_model()["schemas"][0]["tables"][0]) + first_table["table_name"] = "b.c" + second_table = copy.deepcopy(first_table) + second_table["table_name"] = "c" + target["schemas"] = [ + {"schema_name": "a", "tables": [first_table]}, + {"schema_name": "a.b", "tables": [second_table]}, + ] + + plan = compile_migration_plan(_empty_model(), target) + + create_tables = [ + statement + for statement in plan["statements"] + if statement["kind"] == "create_table" + ] + assert [statement["target"] for statement in create_tables] == ["a.b.c", "a.b.c"] + assert [statement["object_ref"] for statement in create_tables] == [ + {"schema_name": "a", "table_name": "b.c"}, + {"schema_name": "a.b", "table_name": "c"}, + ] + assert [statement["dependency_refs"] for statement in create_tables] == [ + [{"schema_name": "a"}], + [{"schema_name": "a.b"}], + ] diff --git a/backend/tests/test_forward_migration_run.py b/backend/tests/test_forward_migration_run.py new file mode 100644 index 000000000..9558abfef --- /dev/null +++ b/backend/tests/test_forward_migration_run.py @@ -0,0 +1,2997 @@ +from __future__ import annotations + +import copy +import uuid +from datetime import datetime, timedelta, timezone +from math import nan +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import ANY, AsyncMock, Mock, patch + +import pytest +from sqlalchemy import CheckConstraint, UniqueConstraint +from sqlalchemy.dialects import postgresql + +from app.forward.migration_plan import compile_migration_plan +from app.forward.migration_run import ( + _expected_live_preflight_checks, + MigrationDispatchClaim, + MigrationRunAttemptClaim, + MigrationRunContractError, + acquire_migration_run_attempt, + canonicalize_run_evidence, + claim_one_migration_dispatch, + complete_isolated_dry_run, + complete_live_preflight, + create_migration_run, + digest_run_event, + digest_run_request, + hash_idempotency_key, + mark_migration_dispatch_published, + finish_migration_run_attempt, + request_migration_run_cancellation, + renew_migration_run_attempt, + transition_migration_run, + validate_run_transition, +) +from app.models import ( + DbConnection, + MigrationPlan, + MigrationRun, + MigrationRunAttempt, + MigrationRunDispatch, + MigrationRunEvent, + SchemaModel, + SchemaModelRevision, +) + + +def _queued_migration_run(*, now: datetime) -> MigrationRun: + """Return one active dry-run row suitable for worker-attempt tests.""" + + return MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="queued", + state_version=1, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + created_at=now, + updated_at=now, + ) + + +def _migration_plan( + *, expires_at: datetime | None = None, blocked: bool = False +) -> MigrationPlan: + now = datetime(2026, 8, 10, tzinfo=timezone.utc) + plan_json = compile_migration_plan( + {"format_version": 1, "postgresql_major": 18, "schemas": []}, + { + "format_version": 1, + "postgresql_major": 17 if blocked else 18, + "schemas": [], + }, + ) + return MigrationPlan( + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + schema_model_revision_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + compiler_version=plan_json["compiler_version"], + base_digest=plan_json["base_digest"], + target_digest=plan_json["target_digest"], + statement_digest=plan_json["plan_digest"], + plan_json=plan_json, + created_by_user_uuid=uuid.uuid4(), + expires_at=expires_at or datetime(2026, 8, 11, tzinfo=timezone.utc), + created_at=now, + ) + + +def _migration_plan_with_preconditions() -> MigrationPlan: + """Return one valid plan with a required table-emptiness precondition.""" + + base_model = { + "format_version": 1, + "postgresql_major": 18, + "schemas": [ + { + "schema_name": "public", + "tables": [ + { + "table_name": "accounts", + "comment": None, + "columns": [ + { + "column_name": "id", + "data_type": "bigint", + "nullable": False, + "ordinal_position": 1, + } + ], + "primary_key": None, + "unique_constraints": [], + "foreign_keys": [], + "indexes": [], + "unsupported_features": [], + } + ], + } + ], + } + target_model = copy.deepcopy(base_model) + target_model["schemas"][0]["tables"][0]["columns"].append( + { + "column_name": "tenant_id", + "data_type": "bigint", + "nullable": False, + "ordinal_position": 2, + } + ) + plan_json = compile_migration_plan(base_model, target_model) + return MigrationPlan( + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + schema_model_revision_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + compiler_version=plan_json["compiler_version"], + base_digest=plan_json["base_digest"], + target_digest=plan_json["target_digest"], + statement_digest=plan_json["plan_digest"], + plan_json=plan_json, + created_by_user_uuid=uuid.uuid4(), + expires_at=datetime(2026, 8, 11, tzinfo=timezone.utc), + created_at=datetime(2026, 8, 10, tzinfo=timezone.utc), + ) + + +def _current_revision( + plan: MigrationPlan, *, actor_uuid: uuid.UUID +) -> tuple[SchemaModelRevision, SchemaModel]: + """Return one current model/revision binding for apply-intent tests.""" + + model = SchemaModel( + schema_model_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + model_name="reviewed model", + current_revision_number=3, + created_by_user_uuid=actor_uuid, + ) + revision = SchemaModelRevision( + schema_model_revision_uuid=plan.schema_model_revision_uuid, + schema_model_uuid=model.schema_model_uuid, + revision_number=model.current_revision_number, + revision_digest=plan.target_digest, + model_json={}, + created_by_user_uuid=actor_uuid, + ) + return revision, model + + +def test_run_state_machine_separates_dry_run_and_apply_authority() -> None: + """Dry-run and apply states never cross their separate authority graphs.""" + + validate_run_transition("dry_run", "queued", "sandbox_running") + validate_run_transition("dry_run", "live_preflight_running", "passed") + validate_run_transition("apply", "queued", "applying") + validate_run_transition("apply", "reconciling", "outcome_unknown") + + with pytest.raises(MigrationRunContractError, match="invalid transition"): + validate_run_transition("dry_run", "queued", "applying") + with pytest.raises(MigrationRunContractError, match="invalid transition"): + validate_run_transition("apply", "applying", "queued") + with pytest.raises(MigrationRunContractError, match="unknown run kind"): + validate_run_transition("preview", "queued", "passed") + + +def test_run_state_machine_allows_only_pre_execution_cancellation_acknowledgement() -> None: + """Cancellation becomes terminal only before live apply authority begins.""" + + for dry_run_state in ("queued", "sandbox_running", "live_preflight_running"): + validate_run_transition("dry_run", dry_run_state, "cancelled") + validate_run_transition("apply", "queued", "cancelled") + + for apply_state in ("applying", "reconciling", "verifying"): + with pytest.raises(MigrationRunContractError, match="invalid transition"): + validate_run_transition("apply", apply_state, "cancelled") + with pytest.raises(MigrationRunContractError, match="invalid transition"): + validate_run_transition("dry_run", "cancelled", "failed") + + +def test_cancelled_state_is_enforced_by_run_and_event_storage_contracts() -> None: + """The durable checks admit terminal cancellation for either run kind.""" + + run_checks = { + constraint.name: str(constraint.sqltext) + for constraint in MigrationRun.__table__.constraints + if isinstance(constraint, CheckConstraint) + } + event_checks = { + constraint.name: str(constraint.sqltext) + for constraint in MigrationRunEvent.__table__.constraints + if isinstance(constraint, CheckConstraint) + } + + assert "'cancelled'" in run_checks["ck_migration_run__state"] + assert run_checks["ck_migration_run__kind_state"].count("'cancelled'") == 2 + assert "'cancelled'" in event_checks["ck_migration_run_event__state_before"] + assert "'cancelled'" in event_checks["ck_migration_run_event__state_after"] + + +@pytest.mark.asyncio +async def test_cancellation_acknowledgement_requires_a_persisted_intent() -> None: + """A worker cannot invent terminal cancellation without the user's intent.""" + + now = datetime(2026, 8, 10, 4, tzinfo=timezone.utc) + run = _queued_migration_run(now=now) + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + + with pytest.raises(MigrationRunContractError, match="cancellation intent"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=1, + next_state="cancelled", + event_type="cancellation_acknowledged", + evidence={}, + actor_user_uuid=None, + now=now, + ) + + session.execute.assert_not_awaited() + session.add.assert_not_called() + + +@pytest.mark.asyncio +async def test_queued_cancellation_finishes_without_claiming_execution_started() -> None: + """Acknowledging a queued intent records no worker start timestamp.""" + + now = datetime(2026, 8, 10, 4, tzinfo=timezone.utc) + run = _queued_migration_run(now=now) + run.state_version = 2 + run.cancellation_requested = True + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + + transition = await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=2, + next_state="cancelled", + event_type="cancellation_acknowledged", + evidence={}, + actor_user_uuid=None, + now=now, + ) + + event = session.add.call_args.args[0] + assert transition.state == "cancelled" + assert transition.started_at is None + assert transition.finished_at == now + assert run.started_at is None + assert event.event_type == "cancellation_acknowledged" + assert event.state_before == "queued" + assert event.state_after == "cancelled" + assert event.evidence_json == {} + + +def test_idempotency_key_is_bounded_and_stored_only_as_a_digest() -> None: + """Opaque retry keys are bounded before only their SHA-256 digest persists.""" + + assert hash_idempotency_key("retry-한글-1") == hash_idempotency_key( + "retry-한글-1" + ) + assert len(hash_idempotency_key("retry-한글-1")) == 64 + + for value in ("", "contains\nnewline", "x" * 256): + with pytest.raises(MigrationRunContractError): + hash_idempotency_key(value) + + +def test_run_request_digest_binds_exact_actor_plan_and_intent() -> None: + """The request digest changes with every execution-authority binding.""" + + project_uuid = uuid.uuid4() + plan_uuid = uuid.uuid4() + actor_uuid = uuid.uuid4() + kwargs = { + "project_space_uuid": project_uuid, + "migration_plan_uuid": plan_uuid, + "run_kind": "dry_run", + "plan_digest": "a" * 64, + "requested_by_user_uuid": actor_uuid, + } + + first = digest_run_request(**kwargs) + assert first == digest_run_request(**kwargs) + assert len(first) == 64 + + for field, value in ( + ("project_space_uuid", uuid.uuid4()), + ("migration_plan_uuid", uuid.uuid4()), + ("plan_digest", "b" * 64), + ("requested_by_user_uuid", uuid.uuid4()), + ): + changed = dict(kwargs) + changed[field] = value + assert digest_run_request(**changed) != first + + passed_dry_run_uuid = uuid.uuid4() + apply_digest = digest_run_request( + **{**kwargs, "run_kind": "apply"}, + passed_dry_run_uuid=passed_dry_run_uuid, + confirmation_digest="b" * 64, + ) + assert apply_digest != first + assert digest_run_request( + **{**kwargs, "run_kind": "apply"}, + passed_dry_run_uuid=uuid.uuid4(), + confirmation_digest="b" * 64, + ) != apply_digest + assert digest_run_request( + **{**kwargs, "run_kind": "apply"}, + passed_dry_run_uuid=passed_dry_run_uuid, + confirmation_digest="c" * 64, + ) != apply_digest + with pytest.raises(MigrationRunContractError, match="passed dry run"): + digest_run_request( + **{**kwargs, "run_kind": "apply"}, + confirmation_digest="b" * 64, + ) + with pytest.raises(MigrationRunContractError, match="apply confirmation"): + digest_run_request( + **{**kwargs, "run_kind": "apply"}, + passed_dry_run_uuid=passed_dry_run_uuid, + confirmation_digest="not-a-digest", + ) + + with pytest.raises(MigrationRunContractError, match="run kind"): + digest_run_request(**{**kwargs, "run_kind": "preview"}) + with pytest.raises(MigrationRunContractError, match="plan digest"): + digest_run_request(**{**kwargs, "plan_digest": "not-a-digest"}) + + +def test_run_event_digest_binds_order_state_evidence_actor_and_time() -> None: + """Every durable event field and predecessor participates in its digest.""" + + run_uuid = uuid.uuid4() + actor_uuid = uuid.uuid4() + created_at = datetime(2026, 8, 10, 2, 3, 4, 5, tzinfo=timezone.utc) + kwargs = { + "migration_run_uuid": run_uuid, + "sequence_number": 2, + "event_type": "sandbox_started", + "state_before": "queued", + "state_after": "sandbox_running", + "evidence": {"attempt": 1, "sandbox_version": "postgresql-18"}, + "actor_user_uuid": actor_uuid, + "created_at": created_at, + "previous_event_digest": "a" * 64, + } + + first = digest_run_event(**kwargs) + assert first == digest_run_event(**kwargs) + assert len(first) == 64 + + for field, value in ( + ("sequence_number", 3), + ("event_type", "sandbox_retried"), + ("state_before", "sandbox_running"), + ("state_after", "failed"), + ("evidence", {"attempt": 2}), + ("actor_user_uuid", uuid.uuid4()), + ("created_at", created_at.replace(microsecond=6)), + ("previous_event_digest", "b" * 64), + ): + changed = dict(kwargs) + changed[field] = value + assert digest_run_event(**changed) != first + + genesis = {**kwargs, "sequence_number": 1, "previous_event_digest": None} + assert len(digest_run_event(**genesis)) == 64 + for invalid in ( + {**kwargs, "sequence_number": 1}, + {**kwargs, "previous_event_digest": None}, + {**kwargs, "previous_event_digest": "not-a-digest"}, + {**kwargs, "created_at": created_at.replace(tzinfo=None)}, + {**kwargs, "migration_run_uuid": "not-a-uuid"}, + {**kwargs, "sequence_number": True}, + {**kwargs, "sequence_number": "2"}, + {**kwargs, "sequence_number": 0}, + {**kwargs, "event_type": "invalid event"}, + {**kwargs, "state_after": ""}, + {**kwargs, "state_after": 1}, + {**kwargs, "state_before": ""}, + {**kwargs, "state_before": 1}, + {**kwargs, "actor_user_uuid": "not-a-uuid"}, + ): + with pytest.raises(MigrationRunContractError): + digest_run_event(**invalid) + + +def test_run_evidence_rejects_secret_and_sql_bearing_fields_recursively() -> None: + """Durable evidence rejects nested SQL, secrets, and connection strings.""" + + evidence = canonicalize_run_evidence( + { + "statement_count": 2, + "duration_ms": 25, + "findings": [{"code": "lock_warning", "object_count": 1}], + } + ) + assert evidence["statement_count"] == 2 + + for payload in ( + {"dsn": "postgresql://secret"}, + {"nested": {"raw_sql": "DROP TABLE customer_record"}}, + {"events": [{"access_token": "secret"}]}, + {"nested": {"rawSql": "DROP TABLE customer_record"}}, + {"databaseDsn": "postgresql://secret"}, + {"events": [{"accessToken": "secret"}]}, + ): + with pytest.raises(MigrationRunContractError, match="forbidden evidence field"): + canonicalize_run_evidence(payload) + + for payload in ( + {"detail": "postgresql://worker:password@db.example/app"}, + {"events": [{"endpoint": "POSTGRES://worker@db.example/app"}]}, + ): + with pytest.raises(MigrationRunContractError, match="connection string"): + canonicalize_run_evidence(payload) + + with pytest.raises(MigrationRunContractError, match="too large"): + canonicalize_run_evidence({"detail": "x" * 16_385}) + + +def test_run_evidence_enforces_every_json_shape_and_resource_bound() -> None: + """Evidence accepts finite JSON and rejects hostile shapes before storage.""" + + assert canonicalize_run_evidence( + {"finite": 1.25, "empty": None, "flags": [True, 1, "ok"]} + ) == {"empty": None, "finite": 1.25, "flags": [True, 1, "ok"]} + + nested: dict[str, object] = {} + cursor = nested + for _ in range(10): + child: dict[str, object] = {} + cursor["child"] = child + cursor = child + + invalid_cases = ( + ({"value": nan}, "finite"), + ({str(index): index for index in range(257)}, "too many fields"), + ({"items": list(range(257))}, "too many items"), + ({"opaque": b"bytes"}, "unsupported"), + (nested, "too deep"), + ({str(index): "x" * 100 for index in range(200)}, "too large"), + ) + for payload, message in invalid_cases: + with pytest.raises(MigrationRunContractError, match=message): + canonicalize_run_evidence(payload) + + with pytest.raises(MigrationRunContractError, match="field name must be text"): + canonicalize_run_evidence({1: "value"}) # type: ignore[dict-item] + + +def test_migration_run_persistence_enforces_idempotent_identity_and_state() -> None: + """ORM constraints preserve run identity, state, and evidence boundaries.""" + + assert MigrationRun.__tablename__ == "migration_run" + assert MigrationRunDispatch.__tablename__ == "migration_run_dispatch" + assert MigrationRunEvent.__tablename__ == "migration_run_event" + + unique_run_columns = { + tuple(column.name for column in constraint.columns) + for constraint in MigrationRun.__table__.constraints + if isinstance(constraint, UniqueConstraint) + } + assert ( + "project_space_uuid", + "run_kind", + "idempotency_key_hash", + ) in unique_run_columns + assert { + constraint.name + for constraint in MigrationRun.__table__.constraints + if isinstance(constraint, CheckConstraint) + } == { + "ck_migration_run__run_kind", + "ck_migration_run__state", + "ck_migration_run__kind_state", + "ck_migration_run__state_version", + "ck_migration_run__latest_event_digest", + "ck_migration_run__idempotency_key_hash", + "ck_migration_run__plan_digest", + "ck_migration_run__request_digest", + "ck_migration_run__observed_base_digest", + "ck_migration_run__confirmation_digest", + "ck_migration_run__apply_confirmation", + } + + unique_event_columns = { + tuple(column.name for column in constraint.columns) + for constraint in MigrationRunEvent.__table__.constraints + if isinstance(constraint, UniqueConstraint) + } + assert ("migration_run_uuid", "sequence_number") in unique_event_columns + assert { + constraint.name + for constraint in MigrationRunEvent.__table__.constraints + if isinstance(constraint, CheckConstraint) + } == { + "ck_migration_run_event__sequence_number", + "ck_migration_run_event__previous_digest", + "ck_migration_run_event__previous_digest_format", + "ck_migration_run_event__event_digest", + "ck_migration_run_event__event_type", + "ck_migration_run_event__state_before", + "ck_migration_run_event__state_after", + } + assert "latest_event_digest" in MigrationRun.__table__.columns + assert "previous_event_digest" in MigrationRunEvent.__table__.columns + assert "event_digest" in MigrationRunEvent.__table__.columns + assert {index.name for index in MigrationRun.__table__.indexes} == { + "ix_migration_run__migration_plan_uuid", + "ix_migration_run__passed_dry_run_uuid", + "ix_migration_run__project_state", + } + assert {index.name for index in MigrationRunEvent.__table__.indexes} == set() + + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="queued", + state_version=1, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + assert run.state == "queued" + assert "dsn" not in MigrationRun.__table__.columns + assert "sql" not in MigrationRunEvent.__table__.columns + assert { + column.name for column in MigrationRunDispatch.__table__.columns + } == { + "migration_run_dispatch_uuid", + "migration_run_uuid", + "dispatch_kind", + "status", + "attempt_count", + "not_before", + "created_at", + "published_at", + } + assert { + constraint.name + for constraint in MigrationRunDispatch.__table__.constraints + if isinstance(constraint, CheckConstraint) + } == { + "ck_migration_run_dispatch__dispatch_kind", + "ck_migration_run_dispatch__status", + "ck_migration_run_dispatch__attempt_count", + "ck_migration_run_dispatch__published_at", + } + assert { + tuple(column.name for column in constraint.columns) + for constraint in MigrationRunDispatch.__table__.constraints + if isinstance(constraint, UniqueConstraint) + } == {("migration_run_uuid",)} + assert {index.name for index in MigrationRunDispatch.__table__.indexes} == { + "ix_migration_run_dispatch__status_not_before", + } + + +def test_migration_run_attempt_persistence_is_lease_bound_and_secret_free() -> None: + """Attempt history stores only hashes and permits one active owner per run.""" + + assert MigrationRunAttempt.__tablename__ == "migration_run_attempt" + assert { + column.name for column in MigrationRunAttempt.__table__.columns + } == { + "migration_run_attempt_uuid", + "migration_run_uuid", + "attempt_number", + "acquired_state_version", + "status", + "worker_identity_hash", + "signal_lease_token_hash", + "lease_expires_at", + "acquired_at", + "last_heartbeat_at", + "finished_at", + } + assert { + constraint.name + for constraint in MigrationRunAttempt.__table__.constraints + if isinstance(constraint, CheckConstraint) + } == { + "ck_migration_run_attempt__attempt_number", + "ck_migration_run_attempt__acquired_state_version", + "ck_migration_run_attempt__status", + "ck_migration_run_attempt__worker_identity_hash", + "ck_migration_run_attempt__signal_lease_token_hash", + "ck_migration_run_attempt__timestamps", + } + assert { + tuple(column.name for column in constraint.columns) + for constraint in MigrationRunAttempt.__table__.constraints + if isinstance(constraint, UniqueConstraint) + } == {("migration_run_uuid", "attempt_number")} + assert {index.name for index in MigrationRunAttempt.__table__.indexes} == { + "ix_migration_run_attempt__active_run", + "ix_migration_run_attempt__lease_expiry", + } + assert { + column.name for column in MigrationRunAttempt.__table__.columns + }.isdisjoint( + {"worker_identity", "signal_lease_token", "dsn", "sql", "plan_json"} + ) + + +@pytest.mark.asyncio +async def test_attempt_acquire_locks_run_and_creates_first_hashed_claim() -> None: + """Acquisition serializes on the run and persists hashes, never raw identity.""" + + now = datetime(2026, 8, 12, 2, tzinfo=timezone.utc) + run = _queued_migration_run(now=now) + session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, None, None]), + add=Mock(), + ) + token = uuid.uuid4() + + claim = await acquire_migration_run_attempt( + session, + migration_run_uuid=run.migration_run_uuid, + worker_identity="worker-a", + signal_lease_token=token, + lease_seconds=60, + now=now, + ) + + assert claim.migration_run_uuid == run.migration_run_uuid + assert claim.attempt_number == 1 + assert claim.acquired_state_version == 1 + assert claim.lease_expires_at == now + timedelta(seconds=60) + assert session.scalar.await_count == 3 + run_statement = session.scalar.await_args_list[0].args[0] + assert "FOR UPDATE" in str( + run_statement.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + attempt = session.add.call_args.args[0] + assert isinstance(attempt, MigrationRunAttempt) + assert attempt.worker_identity_hash != "worker-a" + assert attempt.signal_lease_token_hash != str(token) + assert len(attempt.worker_identity_hash) == 64 + assert len(attempt.signal_lease_token_hash) == 64 + assert attempt.status == "active" + assert attempt.finished_at is None + + +@pytest.mark.asyncio +async def test_attempt_acquire_reclaims_only_an_expired_owner() -> None: + """An unexpired owner blocks takeover; expiry is durably abandoned first.""" + + now = datetime(2026, 8, 12, 2, tzinfo=timezone.utc) + run = _queued_migration_run(now=now) + active = MigrationRunAttempt( + migration_run_attempt_uuid=uuid.uuid4(), + migration_run_uuid=run.migration_run_uuid, + attempt_number=1, + acquired_state_version=1, + status="active", + worker_identity_hash="a" * 64, + signal_lease_token_hash="b" * 64, + lease_expires_at=now + timedelta(seconds=1), + acquired_at=now - timedelta(seconds=10), + last_heartbeat_at=now - timedelta(seconds=10), + finished_at=None, + ) + token = uuid.uuid4() + blocked_session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, active]), add=Mock() + ) + with pytest.raises(MigrationRunContractError, match="already active"): + await acquire_migration_run_attempt( + blocked_session, + migration_run_uuid=run.migration_run_uuid, + worker_identity="worker-b", + signal_lease_token=token, + lease_seconds=60, + now=now, + ) + blocked_session.add.assert_not_called() + + active.lease_expires_at = now + reclaim_session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, active, 1]), add=Mock() + ) + claim = await acquire_migration_run_attempt( + reclaim_session, + migration_run_uuid=run.migration_run_uuid, + worker_identity="worker-b", + signal_lease_token=token, + lease_seconds=60, + now=now, + ) + assert active.status == "abandoned" + assert active.finished_at == now + assert claim.attempt_number == 2 + + +@pytest.mark.asyncio +async def test_attempt_renewal_and_finish_are_exact_claim_cas() -> None: + """Heartbeat and finish require the exact active attempt and hashed owner.""" + + now = datetime(2026, 8, 12, 2, tzinfo=timezone.utc) + claim = MigrationRunAttemptClaim( + migration_run_attempt_uuid=uuid.uuid4(), + migration_run_uuid=uuid.uuid4(), + attempt_number=2, + acquired_state_version=3, + lease_expires_at=now + timedelta(seconds=60), + ) + token = uuid.uuid4() + session = SimpleNamespace( + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)) + ) + + assert await renew_migration_run_attempt( + session, + claim=claim, + worker_identity="worker-a", + signal_lease_token=token, + lease_seconds=60, + now=now, + ) is True + renewal = str( + session.execute.await_args.args[0].compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + assert "migration_run_attempt.status = 'active'" in renewal + assert "migration_run_attempt.lease_expires_at >" in renewal + assert "migration_run.cancellation_requested IS false" in renewal + assert "greatest(" in renewal.lower() + assert "worker-a" not in renewal + assert str(token) not in renewal + + session.execute.return_value = SimpleNamespace(rowcount=1) + assert await finish_migration_run_attempt( + session, + claim=claim, + worker_identity="worker-a", + signal_lease_token=token, + succeeded=True, + now=now, + ) is True + finish = str( + session.execute.await_args.args[0].compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + assert "migration_run_attempt.status = 'active'" in finish + assert "migration_run_attempt.lease_expires_at >" in finish + assert "status='completed'" in finish.replace(" ", "") + assert "finished_at=" in finish + + session.execute.return_value = SimpleNamespace(rowcount=0) + assert await renew_migration_run_attempt( + session, + claim=claim, + worker_identity="worker-a", + signal_lease_token=uuid.uuid4(), + lease_seconds=60, + now=now, + ) is False + assert await finish_migration_run_attempt( + session, + claim=claim, + worker_identity="worker-a", + signal_lease_token=uuid.uuid4(), + succeeded=False, + now=now, + ) is False + with pytest.raises(MigrationRunContractError, match="outcome"): + await finish_migration_run_attempt( + session, + claim=claim, + worker_identity="worker-a", + signal_lease_token=token, + succeeded=1, # type: ignore[arg-type] + now=now, + ) + + +@pytest.mark.asyncio +async def test_attempt_contract_rejects_inactive_runs_and_unsafe_inputs() -> None: + """Terminal/cancelled runs, naive clocks, and unbounded leases fail closed.""" + + now = datetime(2026, 8, 12, 2, tzinfo=timezone.utc) + run = _queued_migration_run(now=now) + run.cancellation_requested = True + session = SimpleNamespace(scalar=AsyncMock(return_value=run), add=Mock()) + with pytest.raises(MigrationRunContractError, match="not executable"): + await acquire_migration_run_attempt( + session, + migration_run_uuid=run.migration_run_uuid, + worker_identity="worker-a", + signal_lease_token=uuid.uuid4(), + lease_seconds=60, + now=now, + ) + + for worker_identity, lease_seconds, expected in ( + ("", 60, "worker identity"), + ("worker a", 60, "worker identity"), + ("worker-a", 0, "lease"), + ("worker-a", 301, "lease"), + ): + with pytest.raises(MigrationRunContractError, match=expected): + await acquire_migration_run_attempt( + SimpleNamespace(scalar=AsyncMock(), add=Mock()), + migration_run_uuid=uuid.uuid4(), + worker_identity=worker_identity, + signal_lease_token=uuid.uuid4(), + lease_seconds=lease_seconds, + now=now, + ) + + with pytest.raises(MigrationRunContractError, match="worker identity"): + await acquire_migration_run_attempt( + SimpleNamespace(scalar=AsyncMock(), add=Mock()), + migration_run_uuid=uuid.uuid4(), + worker_identity=None, # type: ignore[arg-type] + signal_lease_token=uuid.uuid4(), + lease_seconds=60, + now=now, + ) + + with pytest.raises(MigrationRunContractError, match="signal lease token"): + await acquire_migration_run_attempt( + SimpleNamespace(scalar=AsyncMock(), add=Mock()), + migration_run_uuid=uuid.uuid4(), + worker_identity="worker-a", + signal_lease_token=object(), # type: ignore[arg-type] + lease_seconds=60, + now=now, + ) + + with pytest.raises(MigrationRunContractError, match="timezone"): + await acquire_migration_run_attempt( + SimpleNamespace(scalar=AsyncMock(), add=Mock()), + migration_run_uuid=uuid.uuid4(), + worker_identity="worker-a", + signal_lease_token=uuid.uuid4(), + lease_seconds=60, + now=datetime(2026, 8, 12, 2), + ) + + +@pytest.mark.asyncio +async def test_dispatch_claim_uses_due_order_and_skip_locked() -> None: + """A relay claims one due row without blocking a concurrent relay.""" + + now = datetime(2026, 8, 11, 4, tzinfo=timezone.utc) + dispatch = MigrationRunDispatch( + migration_run_dispatch_uuid=uuid.uuid4(), + migration_run_uuid=uuid.uuid4(), + dispatch_kind="isolated_dry_run", + status="pending", + attempt_count=0, + not_before=now, + created_at=now, + published_at=None, + ) + session = SimpleNamespace(scalar=AsyncMock(return_value=dispatch)) + + claim = await claim_one_migration_dispatch(session, now=now) + + assert claim == MigrationDispatchClaim( + migration_run_dispatch_uuid=dispatch.migration_run_dispatch_uuid, + migration_run_uuid=dispatch.migration_run_uuid, + dispatch_kind="isolated_dry_run", + attempt_count=1, + ) + assert dispatch.attempt_count == 1 + statement = session.scalar.await_args.args[0] + compiled = str( + statement.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + assert "migration_run_dispatch.status = 'pending'" in compiled + assert "migration_run_dispatch.not_before <=" in compiled + assert "ORDER BY migration_run_dispatch.not_before" in compiled + assert "FOR UPDATE SKIP LOCKED" in compiled + + +@pytest.mark.asyncio +async def test_dispatch_claim_returns_none_without_mutating_transaction() -> None: + """An empty due queue remains a no-op owned by the caller transaction.""" + + session = SimpleNamespace(scalar=AsyncMock(return_value=None)) + assert await claim_one_migration_dispatch(session) is None + + +@pytest.mark.asyncio +async def test_dispatch_publish_is_attempt_bound_and_caller_owned() -> None: + """Publication succeeds only for the exact in-transaction claim attempt.""" + + now = datetime(2026, 8, 11, 4, tzinfo=timezone.utc) + claim = MigrationDispatchClaim( + migration_run_dispatch_uuid=uuid.uuid4(), + migration_run_uuid=uuid.uuid4(), + dispatch_kind="isolated_dry_run", + attempt_count=2, + ) + result = SimpleNamespace(rowcount=1) + session = SimpleNamespace(execute=AsyncMock(return_value=result)) + + await mark_migration_dispatch_published(session, claim=claim, now=now) + + statement = session.execute.await_args.args[0] + compiled = str( + statement.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + assert "migration_run_dispatch.status = 'pending'" in compiled + assert "migration_run_dispatch.attempt_count = 2" in compiled + assert "status='published'" in compiled.replace(" ", "") + assert "published_at=" in compiled + + session.execute.return_value = SimpleNamespace(rowcount=0) + with pytest.raises(MigrationRunContractError, match="claim is stale"): + await mark_migration_dispatch_published(session, claim=claim, now=now) + + +@pytest.mark.asyncio +async def test_dispatch_claim_and_publish_require_timezone_aware_time() -> None: + """Naive clocks cannot enter durable outbox ordering or evidence.""" + + session = SimpleNamespace(scalar=AsyncMock(), execute=AsyncMock()) + naive = datetime(2026, 8, 11, 4) + with pytest.raises(MigrationRunContractError, match="timezone"): + await claim_one_migration_dispatch(session, now=naive) + with pytest.raises(MigrationRunContractError, match="timezone"): + await mark_migration_dispatch_published( + session, + claim=MigrationDispatchClaim( + migration_run_dispatch_uuid=uuid.uuid4(), + migration_run_uuid=uuid.uuid4(), + dispatch_kind="isolated_dry_run", + attempt_count=1, + ), + now=naive, + ) + session.scalar.assert_not_awaited() + session.execute.assert_not_awaited() + + with pytest.raises(MigrationRunContractError, match="attempt is invalid"): + await mark_migration_dispatch_published( + session, + claim=MigrationDispatchClaim( + migration_run_dispatch_uuid=uuid.uuid4(), + migration_run_uuid=uuid.uuid4(), + dispatch_kind="isolated_dry_run", + attempt_count=0, + ), + now=datetime(2026, 8, 11, 4, tzinfo=timezone.utc), + ) + session.execute.assert_not_awaited() + + +def test_migration_run_alembic_revision_matches_model_contract() -> None: + """Alembic creates the same durable-run constraints as the ORM contract.""" + + migration = ( + Path(__file__).resolve().parents[1] + / "alembic/versions/0010_migration_run.py" + ).read_text(encoding="utf-8") + + for required in ( + 'down_revision = "0009_migration_plan"', + '"migration_run"', + '"migration_run_event"', + '"migration_run_dispatch"', + '"uq_migration_run_dispatch__migration_run_uuid"', + '"ck_migration_run_dispatch__dispatch_kind"', + '"ck_migration_run_dispatch__status"', + '"ck_migration_run_dispatch__attempt_count"', + '"ck_migration_run_dispatch__published_at"', + '"ix_migration_run_dispatch__status_not_before"', + '"uq_migration_run__idempotent_action"', + '"request_digest"', + '"latest_event_digest"', + '"previous_event_digest"', + '"event_digest"', + '"uq_migration_run_event__run_sequence"', + '"ck_migration_run__state_version"', + '"ck_migration_run__latest_event_digest"', + '"ck_migration_run__idempotency_key_hash"', + '"ck_migration_run__plan_digest"', + '"ck_migration_run__request_digest"', + '"ck_migration_run__observed_base_digest"', + '"ck_migration_run_event__previous_digest_format"', + '"ck_migration_run_event__event_digest"', + '"ck_migration_run_event__event_type"', + '"ck_migration_run_event__state_before"', + '"ck_migration_run_event__state_after"', + '"ck_migration_run__kind_state"', + 'ondelete="RESTRICT"', + 'ondelete="CASCADE"', + ): + assert required in migration + + attempt_migration = ( + Path(__file__).resolve().parents[1] + / "alembic/versions/0011_migration_run_attempt.py" + ).read_text(encoding="utf-8") + for required in ( + 'down_revision = "0010_migration_run"', + '"migration_run_attempt"', + '"uq_migration_run_attempt__run_number"', + '"ck_migration_run_attempt__attempt_number"', + '"ck_migration_run_attempt__acquired_state_version"', + '"ck_migration_run_attempt__status"', + '"ck_migration_run_attempt__worker_identity_hash"', + '"ck_migration_run_attempt__signal_lease_token_hash"', + '"ck_migration_run_attempt__timestamps"', + '"ix_migration_run_attempt__active_run"', + '"ix_migration_run_attempt__lease_expiry"', + 'postgresql_where=sa.text("status = \'active\'")', + 'ondelete="CASCADE"', + ): + assert required in attempt_migration + + +@pytest.mark.asyncio +async def test_transition_uses_optimistic_cas_and_appends_sanitized_event() -> None: + """A successful transition updates one exact version and appends its event.""" + + run_uuid = uuid.uuid4() + actor_uuid = uuid.uuid4() + run = MigrationRun( + migration_run_uuid=run_uuid, + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="queued", + state_version=1, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=actor_uuid, + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + now = datetime(2026, 8, 10, tzinfo=timezone.utc) + + result = await transition_migration_run( + session, + migration_run_uuid=run_uuid, + expected_state_version=1, + next_state="sandbox_running", + event_type="sandbox_started", + evidence={"sandbox_version": "postgresql-18", "attempt": 1}, + actor_user_uuid=actor_uuid, + now=now, + ) + + statement = session.execute.await_args.args[0] + compiled = statement.compile() + assert { + "migration_run_uuid_1", + "state_version_1", + "state_1", + }.issubset(compiled.params) + assert compiled.params["migration_run_uuid_1"] == run_uuid + assert compiled.params["state_version_1"] == 1 + assert compiled.params["state_1"] == "queued" + event = session.add.call_args.args[0] + assert isinstance(event, MigrationRunEvent) + assert event.sequence_number == 2 + assert event.state_before == "queued" + assert event.state_after == "sandbox_running" + assert event.evidence_json == { + "attempt": 1, + "sandbox_version": "postgresql-18", + } + assert event.previous_event_digest == "d" * 64 + assert event.event_digest == digest_run_event( + migration_run_uuid=run_uuid, + sequence_number=2, + event_type="sandbox_started", + state_before="queued", + state_after="sandbox_running", + evidence=event.evidence_json, + actor_user_uuid=actor_uuid, + created_at=now, + previous_event_digest="d" * 64, + ) + assert result.state == "sandbox_running" + assert result.state_version == 2 + assert result.started_at == now + assert result.finished_at is None + assert run.state == "sandbox_running" + assert run.state_version == 2 + assert run.evidence_json == event.evidence_json + assert run.latest_event_digest == event.event_digest + assert run.updated_at == now + assert run.started_at == now + assert run.finished_at is None + + +@pytest.mark.asyncio +async def test_transition_fails_closed_when_compare_and_swap_loses_race() -> None: + """A stale worker cannot append evidence after losing the state-version CAS.""" + + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="queued", + state_version=2, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=0)), + add=Mock(), + ) + + with pytest.raises(MigrationRunContractError, match="state version conflict"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=2, + next_state="sandbox_running", + event_type="sandbox_started", + evidence={}, + actor_user_uuid=None, + ) + + session.add.assert_not_called() + + +@pytest.mark.asyncio +async def test_preflight_terminal_transition_binds_observed_base_digest() -> None: + """Passed evidence persists only the exact base observed by preflight.""" + + plan = _migration_plan() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, plan]), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + + result = await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + next_state="passed", + event_type="preflight_passed", + evidence={"finding_count": 0}, + observed_base_digest=plan.base_digest, + actor_user_uuid=None, + ) + + assert result.state == "passed" + assert run.observed_base_digest == plan.base_digest + event = session.add.call_args.args[0] + assert event.evidence_json == { + "finding_count": 0, + "observed_base_digest": plan.base_digest, + } + + +@pytest.mark.asyncio +async def test_preflight_drift_transition_persists_mismatched_base_digest() -> None: + """Drift evidence persists only a canonical digest unequal to the plan base.""" + + plan = _migration_plan() + observed_base_digest = "0" * 64 + assert observed_base_digest != plan.base_digest + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, plan]), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + + result = await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + next_state="drifted", + event_type="preflight_drifted", + evidence={}, + observed_base_digest=observed_base_digest, + actor_user_uuid=None, + ) + + assert result.state == "drifted" + assert run.observed_base_digest == observed_base_digest + assert session.add.call_args.args[0].evidence_json == { + "observed_base_digest": observed_base_digest + } + + +@pytest.mark.asyncio +async def test_complete_isolated_dry_run_binds_exact_plan_result_to_cas() -> None: + """A verified executor result selects one server-authored next state.""" + + plan = _migration_plan() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="sandbox_running", + state_version=2, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + executor_result = { + "postgresql_major": 18, + "statement_count": 0, + "base_digest": plan.base_digest, + "target_digest": plan.target_digest, + "converged": True, + } + session = SimpleNamespace(scalar=AsyncMock(side_effect=[run, plan])) + transition = AsyncMock( + return_value=SimpleNamespace(state="live_preflight_running") + ) + completed_at = datetime(2026, 8, 10, 1, tzinfo=timezone.utc) + + with patch( + "app.forward.migration_run.transition_migration_run", new=transition + ): + completed = await complete_isolated_dry_run( + session, # type: ignore[arg-type] + migration_run_uuid=run.migration_run_uuid, + expected_state_version=2, + result=executor_result, + actor_user_uuid=None, + now=completed_at, + ) + + assert completed.state == "live_preflight_running" + transition.assert_awaited_once_with( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=2, + next_state="live_preflight_running", + event_type="isolated_dry_run_succeeded", + evidence={ + "postgresql_major": 18, + "statement_count": 0, + "converged": True, + }, + actor_user_uuid=None, + now=completed_at, + ) + + +@pytest.mark.parametrize( + "result", + [ + None, + [], + {}, + { + "postgresql_major": True, + "statement_count": 0, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "converged": True, + }, + { + "postgresql_major": 18, + "statement_count": -1, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "converged": True, + }, + { + "postgresql_major": 18, + "statement_count": 1001, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "converged": True, + }, + { + "postgresql_major": 18, + "statement_count": 0, + "base_digest": "A" * 64, + "target_digest": "b" * 64, + "converged": True, + }, + { + "postgresql_major": 18, + "statement_count": 0, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "converged": False, + }, + { + "postgresql_major": 18, + "statement_count": 0, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "converged": True, + "next_state": "live_preflight_running", + }, + ], +) +@pytest.mark.asyncio +async def test_complete_isolated_dry_run_rejects_forged_results( + result: object, +) -> None: + """Malformed or caller-extended executor results fail before durable I/O.""" + + session = SimpleNamespace(scalar=AsyncMock()) + transition = AsyncMock() + with patch( + "app.forward.migration_run.transition_migration_run", new=transition + ), pytest.raises(MigrationRunContractError, match="isolated dry-run result"): + await complete_isolated_dry_run( + session, # type: ignore[arg-type] + migration_run_uuid=uuid.uuid4(), + expected_state_version=2, + result=result, # type: ignore[arg-type] + actor_user_uuid=None, + ) + + session.scalar.assert_not_awaited() + transition.assert_not_awaited() + + +@pytest.mark.parametrize( + "mutation", + [ + {"postgresql_major": 17}, + {"statement_count": 1}, + {"base_digest": "e" * 64}, + {"target_digest": "f" * 64}, + ], +) +@pytest.mark.asyncio +async def test_complete_isolated_dry_run_rejects_result_plan_mismatch( + mutation: dict[str, object], +) -> None: + """Executor output cannot be rebound to a different stored plan.""" + + plan = _migration_plan() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="sandbox_running", + state_version=2, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + result: dict[str, object] = { + "postgresql_major": 18, + "statement_count": 0, + "base_digest": plan.base_digest, + "target_digest": plan.target_digest, + "converged": True, + **mutation, + } + session = SimpleNamespace(scalar=AsyncMock(side_effect=[run, plan])) + transition = AsyncMock() + completed_at = datetime(2026, 8, 10, 1, tzinfo=timezone.utc) + + with patch( + "app.forward.migration_run.transition_migration_run", new=transition + ), pytest.raises(MigrationRunContractError, match="does not match"): + await complete_isolated_dry_run( + session, # type: ignore[arg-type] + migration_run_uuid=run.migration_run_uuid, + expected_state_version=2, + result=result, + actor_user_uuid=None, + now=completed_at, + ) + + transition.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_complete_isolated_dry_run_rejects_unbound_durable_context() -> None: + """Naive time, absent run, or absent plan cannot produce success evidence.""" + + result = { + "postgresql_major": 18, + "statement_count": 0, + "base_digest": "a" * 64, + "target_digest": "a" * 64, + "converged": True, + } + migration_run_uuid = uuid.uuid4() + no_io = SimpleNamespace(scalar=AsyncMock()) + with pytest.raises(MigrationRunContractError, match="include a timezone"): + await complete_isolated_dry_run( + no_io, # type: ignore[arg-type] + migration_run_uuid=migration_run_uuid, + expected_state_version=2, + result=result, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1), + ) + no_io.scalar.assert_not_awaited() + + missing_run = SimpleNamespace(scalar=AsyncMock(return_value=None)) + with pytest.raises(MigrationRunContractError, match="state version conflict"): + await complete_isolated_dry_run( + missing_run, # type: ignore[arg-type] + migration_run_uuid=migration_run_uuid, + expected_state_version=2, + result=result, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1, tzinfo=timezone.utc), + ) + + plan = _migration_plan() + run = MigrationRun( + migration_run_uuid=migration_run_uuid, + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="sandbox_running", + state_version=2, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + missing_plan = SimpleNamespace(scalar=AsyncMock(side_effect=[run, None])) + with pytest.raises(MigrationRunContractError, match="plan integrity"): + await complete_isolated_dry_run( + missing_plan, # type: ignore[arg-type] + migration_run_uuid=migration_run_uuid, + expected_state_version=2, + result={ + **result, + "base_digest": plan.base_digest, + "target_digest": plan.target_digest, + }, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1, tzinfo=timezone.utc), + ) + + +@pytest.mark.parametrize( + "matches_plan_base, check_results, expected_state, expected_digest", + [ + (True, [True], "passed", "planned"), + (False, [True], "drifted", "b" * 64), + (True, [False], "failed", None), + ], +) +@pytest.mark.asyncio +async def test_complete_live_preflight_derives_the_only_valid_terminal_state( + matches_plan_base: bool, + check_results: list[bool], + expected_state: str, + expected_digest: str | None, +) -> None: + """Worker evidence cannot choose its terminal classification or digest.""" + + plan = _migration_plan_with_preconditions() + run_uuid = uuid.uuid4() + run = MigrationRun( + migration_run_uuid=run_uuid, + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + observed_digest = plan.base_digest if matches_plan_base else "b" * 64 + if expected_digest == "planned": + expected_digest = plan.base_digest + result = { + "preconditions_passed": all(check_results), + "checks": [ + { + "statement_index": 0, + "precondition_index": index, + "kind": "table_is_empty", + "passed": passed, + } + for index, passed in enumerate(check_results) + ], + "observed_base_digest": observed_digest, + "matches_plan_base": matches_plan_base, + } + transition = AsyncMock(return_value=SimpleNamespace(state=expected_state)) + session = SimpleNamespace(scalar=AsyncMock(side_effect=[run, plan])) + + with patch( + "app.forward.migration_run.transition_migration_run", new=transition + ): + completed = await complete_live_preflight( + session, # type: ignore[arg-type] + migration_run_uuid=run_uuid, + expected_state_version=3, + result=result, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1, tzinfo=timezone.utc), + ) + + assert completed.state == expected_state + transition.assert_awaited_once_with( + ANY, + migration_run_uuid=run_uuid, + expected_state_version=3, + next_state=expected_state, + event_type=f"live_preflight_{expected_state}", + evidence={ + "check_count": len(check_results), + "failed_check_count": check_results.count(False), + }, + observed_base_digest=expected_digest, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1, tzinfo=timezone.utc), + ) + + +@pytest.mark.parametrize( + "checks", + [ + [], + [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "no_null_values", + "passed": True, + } + ], + [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + }, + { + "statement_index": 0, + "precondition_index": 1, + "kind": "table_is_empty", + "passed": True, + }, + ], + ], +) +@pytest.mark.asyncio +async def test_complete_live_preflight_requires_exact_plan_preconditions( + checks: list[dict[str, object]], +) -> None: + """Missing, extra, or kind-mismatched check evidence cannot pass.""" + + plan = _migration_plan_with_preconditions() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + transition = AsyncMock() + session = SimpleNamespace(scalar=AsyncMock(side_effect=[run, plan])) + with patch( + "app.forward.migration_run.transition_migration_run", new=transition + ), pytest.raises(MigrationRunContractError, match="does not match migration plan"): + await complete_live_preflight( + session, # type: ignore[arg-type] + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + result={ + "preconditions_passed": True, + "checks": checks, + "observed_base_digest": plan.base_digest, + "matches_plan_base": True, + }, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1, tzinfo=timezone.utc), + ) + + transition.assert_not_awaited() + + +@pytest.mark.parametrize( + "plan_json", + [ + {}, + {"statements": [None]}, + {"statements": [{}]}, + {"statements": [{"preconditions": [None]}]}, + {"statements": [{"preconditions": [{"kind": None}]}]}, + {"statements": [{"preconditions": [{"kind": "unknown"}]}]}, + ], +) +def test_expected_live_preflight_checks_rejects_malformed_plan_structure( + plan_json: dict[str, object], +) -> None: + """Malformed persisted statement/precondition structure fails closed.""" + + with pytest.raises(MigrationRunContractError, match="plan integrity"): + _expected_live_preflight_checks(plan_json) + + +@pytest.mark.parametrize( + "invalid_run", + ["missing", "kind", "state", "version", "cancelled"], +) +@pytest.mark.asyncio +async def test_complete_live_preflight_rejects_invalid_run_authority( + invalid_run: str, +) -> None: + """Only the exact active, uncancelled dry-run state may complete.""" + + plan = _migration_plan_with_preconditions() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + if invalid_run == "kind": + run.run_kind = "apply" + elif invalid_run == "state": + run.state = "sandbox_running" + elif invalid_run == "version": + run.state_version = 2 + elif invalid_run == "cancelled": + run.cancellation_requested = True + stored_run = None if invalid_run == "missing" else run + session = SimpleNamespace(scalar=AsyncMock(return_value=stored_run)) + + with pytest.raises(MigrationRunContractError, match="state version conflict"): + await complete_live_preflight( + session, # type: ignore[arg-type] + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + result={ + "preconditions_passed": True, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + } + ], + "observed_base_digest": plan.base_digest, + "matches_plan_base": True, + }, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1, tzinfo=timezone.utc), + ) + + +@pytest.mark.parametrize( + "invalid_plan", + [ + "missing", + "project", + "run_digest", + "expired", + "content_digest", + "compiler", + "base_digest", + "target_digest", + ], +) +@pytest.mark.asyncio +async def test_complete_live_preflight_rejects_invalid_plan_authority( + invalid_plan: str, +) -> None: + """Every stored-plan authority binding is rechecked before completion.""" + + plan = _migration_plan_with_preconditions() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + if invalid_plan == "project": + plan.project_space_uuid = uuid.uuid4() + elif invalid_plan == "run_digest": + run.plan_digest = "0" * 64 + elif invalid_plan == "expired": + plan.expires_at = datetime(2026, 8, 10, tzinfo=timezone.utc) + elif invalid_plan == "content_digest": + plan.plan_json = {**plan.plan_json, "plan_digest": "0" * 64} + elif invalid_plan == "compiler": + plan.compiler_version = "unknown" + elif invalid_plan == "base_digest": + plan.base_digest = "0" * 64 + elif invalid_plan == "target_digest": + plan.target_digest = "0" * 64 + stored_plan = None if invalid_plan == "missing" else plan + session = SimpleNamespace(scalar=AsyncMock(side_effect=[run, stored_plan])) + + with pytest.raises(MigrationRunContractError, match="plan integrity"): + await complete_live_preflight( + session, # type: ignore[arg-type] + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + result={ + "preconditions_passed": True, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + } + ], + "observed_base_digest": plan.base_digest, + "matches_plan_base": True, + }, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1, tzinfo=timezone.utc), + ) + + +@pytest.mark.asyncio +async def test_complete_live_preflight_rejects_naive_transition_time() -> None: + """A timezone-free completion clock fails before durable state access.""" + + plan = _migration_plan_with_preconditions() + session = SimpleNamespace(scalar=AsyncMock()) + with pytest.raises(MigrationRunContractError, match="include a timezone"): + await complete_live_preflight( + session, # type: ignore[arg-type] + migration_run_uuid=uuid.uuid4(), + expected_state_version=3, + result={ + "preconditions_passed": True, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + } + ], + "observed_base_digest": plan.base_digest, + "matches_plan_base": True, + }, + actor_user_uuid=None, + now=datetime(2026, 8, 10, 1), + ) + + session.scalar.assert_not_awaited() + + +@pytest.mark.parametrize( + "result", + [ + None, + [], + {}, + { + "preconditions_passed": True, + "checks": [], + "observed_base_digest": "A" * 64, + "matches_plan_base": True, + }, + { + "preconditions_passed": False, + "checks": [], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + }, + { + "preconditions_passed": True, + "checks": [{"passed": True}], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + }, + { + "preconditions_passed": True, + "checks": [ + { + "statement_index": -1, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + } + ], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + }, + { + "preconditions_passed": True, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": [], + "passed": True, + } + ], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + }, + { + "preconditions_passed": True, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + }, + { + "statement_index": 0, + "precondition_index": 0, + "kind": "no_null_values", + "passed": True, + }, + ], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + }, + { + "preconditions_passed": True, + "checks": [], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + "next_state": "passed", + }, + ], +) +@pytest.mark.asyncio +async def test_complete_live_preflight_rejects_incomplete_or_forged_results( + result: object, +) -> None: + """Only the exact bounded executor result shape may reach durable CAS.""" + + transition = AsyncMock() + with patch( + "app.forward.migration_run.transition_migration_run", new=transition + ), pytest.raises(MigrationRunContractError, match="preflight result"): + await complete_live_preflight( + SimpleNamespace(), # type: ignore[arg-type] + migration_run_uuid=uuid.uuid4(), + expected_state_version=3, + result=result, # type: ignore[arg-type] + actor_user_uuid=None, + ) + + transition.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_complete_live_preflight_enforces_the_check_count_ceiling() -> None: + """Oversized worker results fail before durable state access.""" + + result = { + "preconditions_passed": True, + "checks": [ + { + "statement_index": index, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + } + for index in range(1001) + ], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + } + transition = AsyncMock() + with patch( + "app.forward.migration_run.transition_migration_run", new=transition + ), pytest.raises(MigrationRunContractError, match="preflight result"): + await complete_live_preflight( + SimpleNamespace(), # type: ignore[arg-type] + migration_run_uuid=uuid.uuid4(), + expected_state_version=3, + result=result, + actor_user_uuid=None, + ) + + transition.assert_not_awaited() + + +@pytest.mark.parametrize( + "evidence", + [ + {"observed_base_digest": "0" * 64}, + {"observedBaseDigest": "0" * 64}, + {"nested": {"observed-base-digest": "0" * 64}}, + {"nested": [{"observed.base.digest": "0" * 64}]}, + ], +) +@pytest.mark.asyncio +async def test_preflight_terminal_transition_rejects_worker_supplied_digest_evidence( + evidence: dict[str, object], +) -> None: + """Only the server argument may author the observed digest audit field.""" + + plan = _migration_plan() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, plan]), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + + with pytest.raises(MigrationRunContractError, match="server-authoritative"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + next_state="passed", + event_type="preflight_passed", + evidence=evidence, + observed_base_digest=plan.base_digest, + actor_user_uuid=None, + ) + + session.execute.assert_not_awaited() + session.add.assert_not_called() + + +@pytest.mark.parametrize( + "next_state, observed_base_digest", + [ + ("passed", None), + ("passed", "A" * 64), + ("passed", "0" * 64), + ("drifted", "planned"), + ], +) +@pytest.mark.asyncio +async def test_preflight_terminal_transition_rejects_missing_or_conflicting_digest( + next_state: str, observed_base_digest: str | None +) -> None: + """A worker cannot misclassify the observed base as passed or drifted.""" + + plan = _migration_plan() + if observed_base_digest == "planned": + observed_base_digest = plan.base_digest + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, plan]), + execute=AsyncMock(), + add=Mock(), + ) + + with pytest.raises(MigrationRunContractError, match="observed base digest"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + next_state=next_state, + event_type=f"preflight_{next_state}", + evidence={}, + observed_base_digest=observed_base_digest, + actor_user_uuid=None, + ) + + session.execute.assert_not_awaited() + session.add.assert_not_called() + + +@pytest.mark.asyncio +async def test_preflight_terminal_transition_rejects_missing_plan_authority() -> None: + """Terminal evidence cannot bind when its immutable plan is unavailable.""" + + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="live_preflight_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(side_effect=[run, None]), + execute=AsyncMock(), + add=Mock(), + ) + + with pytest.raises(MigrationRunContractError, match="plan integrity"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + next_state="passed", + event_type="preflight_passed", + evidence={}, + observed_base_digest="0" * 64, + actor_user_uuid=None, + ) + + session.execute.assert_not_awaited() + session.add.assert_not_called() + + +@pytest.mark.asyncio +async def test_transition_marks_terminal_state_without_restarting_run() -> None: + """A terminal transition preserves start time and records one finish time.""" + + started_at = datetime(2026, 8, 10, 1, tzinfo=timezone.utc) + finished_at = datetime(2026, 8, 10, 2, tzinfo=timezone.utc) + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="sandbox_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + started_at=started_at, + ) + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + + result = await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + next_state="failed", + event_type="sandbox_failed", + evidence={"finding_count": 0}, + actor_user_uuid=None, + now=finished_at, + ) + + assert result.started_at == started_at + assert result.finished_at == finished_at + event = session.add.call_args.args[0] + assert event.sequence_number == 4 + assert event.state_before == "sandbox_running" + assert event.state_after == "failed" + assert run.state == "failed" + assert run.state_version == 4 + assert run.evidence_json == {"finding_count": 0} + assert run.latest_event_digest == event.event_digest + assert run.updated_at == finished_at + assert run.started_at == started_at + assert run.finished_at == finished_at + + +@pytest.mark.asyncio +async def test_non_preflight_transition_rejects_observed_base_digest() -> None: + """Other state changes cannot inject a target fingerprint into evidence.""" + + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="queued", + state_version=1, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(), + add=Mock(), + ) + + with pytest.raises(MigrationRunContractError, match="not allowed"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=1, + next_state="sandbox_running", + event_type="sandbox_started", + evidence={}, + observed_base_digest="0" * 64, + actor_user_uuid=None, + ) + + session.execute.assert_not_awaited() + session.add.assert_not_called() + + +@pytest.mark.asyncio +async def test_transition_validates_event_metadata_before_database_access() -> None: + """Invalid event metadata cannot reach persistence or durable evidence.""" + + session = SimpleNamespace( + scalar=AsyncMock(), + execute=AsyncMock(), + add=Mock(), + ) + for expected_version, event_type, evidence, now in ( + (0, "sandbox_started", {}, None), + (True, "sandbox_started", {}, None), + (1, "contains whitespace", {}, None), + (1, "sandbox_started", {"rawSql": "DROP TABLE customer_record"}, None), + (1, "sandbox_started", {}, datetime(2026, 8, 10)), + ): + with pytest.raises(MigrationRunContractError): + await transition_migration_run( + session, + migration_run_uuid=uuid.uuid4(), + expected_state_version=expected_version, + next_state="sandbox_running", + event_type=event_type, + evidence=evidence, + actor_user_uuid=None, + now=now, + ) + + session.scalar.assert_not_awaited() + session.execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_transition_masks_missing_stale_and_invalid_state_before_update() -> None: + """Missing, stale, or graph-invalid runs never execute a durable update.""" + + session = SimpleNamespace( + scalar=AsyncMock(return_value=None), + execute=AsyncMock(), + add=Mock(), + ) + with pytest.raises(MigrationRunContractError, match="state version conflict"): + await transition_migration_run( + session, + migration_run_uuid=uuid.uuid4(), + expected_state_version=1, + next_state="sandbox_running", + event_type="sandbox_started", + evidence={}, + actor_user_uuid=None, + ) + + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="queued", + state_version=2, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + evidence_json={}, + ) + session.scalar.return_value = run + with pytest.raises(MigrationRunContractError, match="state version conflict"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=1, + next_state="sandbox_running", + event_type="sandbox_started", + evidence={}, + actor_user_uuid=None, + ) + + run.state_version = 1 + with pytest.raises(MigrationRunContractError, match="invalid transition"): + await transition_migration_run( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=1, + next_state="applying", + event_type="apply_started", + evidence={}, + actor_user_uuid=None, + ) + + session.execute.assert_not_awaited() + session.add.assert_not_called() + + +@pytest.mark.asyncio +async def test_create_dry_run_uses_database_conflict_winner_and_initial_event() -> None: + """Creation is one PostgreSQL idempotency insert plus sequence-one evidence.""" + + now = datetime(2026, 8, 10, 3, tzinfo=timezone.utc) + plan = _migration_plan() + actor_uuid = uuid.uuid4() + run_uuid = uuid.uuid4() + insert_result = SimpleNamespace( + scalar_one_or_none=Mock(return_value=run_uuid) + ) + session = SimpleNamespace( + execute=AsyncMock(return_value=insert_result), + scalar=AsyncMock(), + add=Mock(), + ) + + created = await create_migration_run( + session, + plan=plan, + run_kind="dry_run", + idempotency_key="browser-request-한글-1", + requested_by_user_uuid=actor_uuid, + evidence={"request_source": "review_ui"}, + now=now, + ) + + statement = session.execute.await_args.args[0] + compiled = str(statement.compile(dialect=postgresql.dialect())) + assert "ON CONFLICT ON CONSTRAINT uq_migration_run__idempotent_action DO NOTHING" in compiled + assert "RETURNING migration_run.migration_run_uuid" in compiled + added = [call.args[0] for call in session.add.call_args_list] + event = next(item for item in added if isinstance(item, MigrationRunEvent)) + dispatch = next( + item for item in added if isinstance(item, MigrationRunDispatch) + ) + assert isinstance(event, MigrationRunEvent) + assert event.migration_run_uuid == run_uuid + assert event.sequence_number == 1 + assert event.state_before is None + assert event.state_after == "queued" + assert event.evidence_json == {"request_source": "review_ui"} + assert event.previous_event_digest is None + assert len(event.event_digest) == 64 + assert dispatch.migration_run_uuid == run_uuid + assert dispatch.dispatch_kind == "isolated_dry_run" + assert dispatch.status == "pending" + assert dispatch.attempt_count == 0 + assert dispatch.not_before == now + assert dispatch.created_at == now + assert dispatch.published_at is None + assert created.migration_run_uuid == run_uuid + assert created.reused is False + session.scalar.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_apply_intent_binds_passed_run_and_exact_confirmation() -> None: + """Apply creation persists reviewed evidence but creates no executor dispatch.""" + + now = datetime(2026, 8, 10, 3, tzinfo=timezone.utc) + plan = _migration_plan() + actor_uuid = uuid.uuid4() + passed_run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="passed", + state_version=4, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=actor_uuid, + cancellation_requested=False, + observed_base_digest=plan.base_digest, + evidence_json={}, + ) + connection = DbConnection( + db_connection_uuid=plan.db_connection_uuid, + project_space_uuid=plan.project_space_uuid, + conn_name='Production "Primary"', + dsn_ciphertext=b"ciphertext", + dsn_nonce=b"nonce", + ) + revision, model = _current_revision(plan, actor_uuid=actor_uuid) + run_uuid = uuid.uuid4() + session = SimpleNamespace( + execute=AsyncMock( + return_value=SimpleNamespace( + scalar_one_or_none=Mock(return_value=run_uuid) + ) + ), + scalar=AsyncMock(), + add=Mock(), + ) + + created = await create_migration_run( + session, + plan=plan, + run_kind="apply", + idempotency_key="apply-request-1", + requested_by_user_uuid=actor_uuid, + evidence={"request_source": "review_ui"}, + passed_dry_run=passed_run, + connection=connection, + typed_connection_name='Production "Primary"', + destructive_acknowledged=False, + model_revision=revision, + schema_model=model, + now=now, + ) + + statement = session.execute.await_args.args[0] + params = statement.compile(dialect=postgresql.dialect()).params + assert params["run_kind"] == "apply" + assert params["passed_dry_run_uuid"] == passed_run.migration_run_uuid + assert params["destructive_confirmation"] is False + assert len(params["confirmation_digest"]) == 64 + added = [call.args[0] for call in session.add.call_args_list] + event = next(item for item in added if isinstance(item, MigrationRunEvent)) + assert event.evidence_json == { + "destructive_acknowledged": False, + "passed_dry_run_uuid": str(passed_run.migration_run_uuid), + "request_source": "review_ui", + "target_connection_confirmed": True, + } + assert not any(isinstance(item, MigrationRunDispatch) for item in added) + assert created.migration_run_uuid == run_uuid + assert created.reused is False + + +@pytest.mark.asyncio +async def test_create_apply_intent_rejects_unbounded_internal_connection_name() -> None: + """Non-HTTP callers cannot bypass the typed target-name input bound.""" + + plan = _migration_plan() + actor_uuid = uuid.uuid4() + passed_run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="passed", + state_version=4, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=actor_uuid, + cancellation_requested=False, + observed_base_digest=plan.base_digest, + evidence_json={}, + ) + connection = DbConnection( + db_connection_uuid=plan.db_connection_uuid, + project_space_uuid=plan.project_space_uuid, + conn_name="x" * 129, + dsn_ciphertext=b"ciphertext", + dsn_nonce=b"nonce", + ) + revision, model = _current_revision(plan, actor_uuid=actor_uuid) + session = SimpleNamespace(execute=AsyncMock(), scalar=AsyncMock(), add=Mock()) + + with pytest.raises(MigrationRunContractError, match="confirmation"): + await create_migration_run( + session, + plan=plan, + run_kind="apply", + idempotency_key="apply-request-oversized", + requested_by_user_uuid=actor_uuid, + evidence={}, + passed_dry_run=passed_run, + connection=connection, + typed_connection_name="x" * 129, + destructive_acknowledged=False, + model_revision=revision, + schema_model=model, + now=datetime(2026, 8, 10, 3, tzinfo=timezone.utc), + ) + + session.execute.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("connection_project", "target connection confirmation mismatch"), + ("passed_plan", "passed dry run is invalid"), + ("passed_cancelled", "passed dry run is invalid"), + ("passed_base", "passed dry run is invalid"), + ("destructive", "destructive confirmation mismatch"), + ("reserved_evidence", "apply evidence is invalid"), + ("stale_revision", "migration model revision is stale"), + ], +) +async def test_create_apply_intent_rejects_every_cross_authority_binding( + mutation: str, message: str +) -> None: + """No mismatched target, evidence, or confirmation can reach insertion.""" + + plan = _migration_plan() + actor_uuid = uuid.uuid4() + passed_run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="passed", + state_version=4, + idempotency_key_hash="a" * 64, + plan_digest=plan.statement_digest, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=actor_uuid, + cancellation_requested=False, + observed_base_digest=plan.base_digest, + evidence_json={}, + ) + connection = DbConnection( + db_connection_uuid=plan.db_connection_uuid, + project_space_uuid=plan.project_space_uuid, + conn_name="Production Primary", + dsn_ciphertext=b"ciphertext", + dsn_nonce=b"nonce", + ) + revision, model = _current_revision(plan, actor_uuid=actor_uuid) + destructive_acknowledged = False + evidence: dict[str, object] = {} + if mutation == "connection_project": + connection.project_space_uuid = uuid.uuid4() + elif mutation == "passed_plan": + passed_run.migration_plan_uuid = uuid.uuid4() + elif mutation == "passed_cancelled": + passed_run.cancellation_requested = True + elif mutation == "passed_base": + passed_run.observed_base_digest = "e" * 64 + elif mutation == "destructive": + destructive_acknowledged = True + elif mutation == "stale_revision": + model.current_revision_number += 1 + else: + evidence = {"targetConnectionConfirmed": True} + session = SimpleNamespace(execute=AsyncMock(), scalar=AsyncMock(), add=Mock()) + + with pytest.raises(MigrationRunContractError, match=message): + await create_migration_run( + session, + plan=plan, + run_kind="apply", + idempotency_key=f"apply-request-{mutation}", + requested_by_user_uuid=actor_uuid, + evidence=evidence, + passed_dry_run=passed_run, + connection=connection, + typed_connection_name=connection.conn_name, + destructive_acknowledged=destructive_acknowledged, + model_revision=revision, + schema_model=model, + now=datetime(2026, 8, 10, 3, tzinfo=timezone.utc), + ) + + session.execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_dry_run_reuses_only_the_same_effective_request() -> None: + """A duplicate key reuses the winner only when its request digest matches.""" + + now = datetime(2026, 8, 10, 3, tzinfo=timezone.utc) + plan = _migration_plan() + actor_uuid = uuid.uuid4() + existing = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + state="queued", + state_version=1, + idempotency_key_hash=hash_idempotency_key("same-key"), + plan_digest=plan.statement_digest, + request_digest=digest_run_request( + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + plan_digest=plan.statement_digest, + requested_by_user_uuid=actor_uuid, + ), + latest_event_digest="d" * 64, + requested_by_user_uuid=actor_uuid, + cancellation_requested=True, + evidence_json={}, + ) + session = SimpleNamespace( + execute=AsyncMock( + return_value=SimpleNamespace( + scalar_one_or_none=Mock(return_value=None) + ) + ), + scalar=AsyncMock(return_value=existing), + add=Mock(), + ) + + reused = await create_migration_run( + session, + plan=plan, + run_kind="dry_run", + idempotency_key="same-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + assert reused.migration_run_uuid == existing.migration_run_uuid + assert reused.reused is True + assert reused.cancellation_requested is True + session.add.assert_not_called() + + existing.request_digest = "f" * 64 + with pytest.raises(MigrationRunContractError, match="idempotency key conflict"): + await create_migration_run( + session, + plan=plan, + run_kind="dry_run", + idempotency_key="same-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + + existing.request_digest = digest_run_request( + project_space_uuid=plan.project_space_uuid, + migration_plan_uuid=plan.migration_plan_uuid, + run_kind="dry_run", + plan_digest=plan.statement_digest, + requested_by_user_uuid=actor_uuid, + ) + session.scalar.return_value = None + with pytest.raises(MigrationRunContractError, match="winner is unavailable"): + await create_migration_run( + session, + plan=plan, + run_kind="dry_run", + idempotency_key="same-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + + +@pytest.mark.asyncio +async def test_create_dry_run_rejects_unexecutable_or_expired_plan_before_insert() -> None: + """Run creation fails closed for apply, expiry, blockers, or plan tampering.""" + + now = datetime(2026, 8, 10, 3, tzinfo=timezone.utc) + actor_uuid = uuid.uuid4() + session = SimpleNamespace(execute=AsyncMock(), scalar=AsyncMock(), add=Mock()) + + apply_plan = _migration_plan() + with pytest.raises(MigrationRunContractError, match="dry-run confirmation"): + await create_migration_run( + session, + plan=apply_plan, + run_kind="dry_run", + idempotency_key="dry-run-with-confirmation", + requested_by_user_uuid=actor_uuid, + evidence={}, + typed_connection_name="must-not-be-present", + now=now, + ) + + with pytest.raises(MigrationRunContractError, match="apply confirmation"): + await create_migration_run( + session, + plan=apply_plan, + run_kind="apply", + idempotency_key="apply-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + + malformed_confirmation_plan = _migration_plan() + malformed_confirmation_plan.plan_json = { + **malformed_confirmation_plan.plan_json, + "requires_destructive_confirmation": "yes", + } + with ( + patch( + "app.forward.migration_run.verify_migration_plan_digest", + return_value=True, + ), + pytest.raises(MigrationRunContractError, match="apply confirmation"), + ): + await create_migration_run( + session, + plan=malformed_confirmation_plan, + run_kind="apply", + idempotency_key="malformed-confirmation-plan", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + + with pytest.raises(MigrationRunContractError, match="run kind"): + await create_migration_run( + session, + plan=apply_plan, + run_kind="preview", + idempotency_key="preview-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + with pytest.raises(MigrationRunContractError, match="timezone"): + await create_migration_run( + session, + plan=apply_plan, + run_kind="dry_run", + idempotency_key="naive-time-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=datetime(2026, 8, 10, 3), + ) + + expired = _migration_plan(expires_at=now) + with pytest.raises(MigrationRunContractError, match="expired"): + await create_migration_run( + session, + plan=expired, + run_kind="dry_run", + idempotency_key="expired-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + + blocked = _migration_plan() + blocked.plan_json = {**blocked.plan_json, "can_dry_run": False} + with pytest.raises(MigrationRunContractError, match="integrity"): + await create_migration_run( + session, + plan=blocked, + run_kind="dry_run", + idempotency_key="blocked-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + + valid_blocked = _migration_plan(blocked=True) + with pytest.raises(MigrationRunContractError, match="cannot be dry-run"): + await create_migration_run( + session, + plan=valid_blocked, + run_kind="dry_run", + idempotency_key="valid-blocked-key", + requested_by_user_uuid=actor_uuid, + evidence={}, + now=now, + ) + + session.execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancellation_intent_uses_cas_and_same_state_event_sequence() -> None: + """Cancellation increments the durable version without inventing a state.""" + + now = datetime(2026, 8, 10, 4, tzinfo=timezone.utc) + actor_uuid = uuid.uuid4() + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="sandbox_running", + state_version=2, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=actor_uuid, + cancellation_requested=False, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=1)), + add=Mock(), + ) + + result = await request_migration_run_cancellation( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=2, + actor_user_uuid=actor_uuid, + evidence={"request_source": "review_ui"}, + now=now, + ) + + statement = session.execute.await_args.args[0] + compiled = statement.compile() + assert compiled.params["state_version_1"] == 2 + assert compiled.params["state_1"] == "sandbox_running" + assert "migration_run.cancellation_requested IS false" in str(compiled) + event = session.add.call_args.args[0] + assert event.event_type == "cancellation_requested" + assert event.sequence_number == 3 + assert event.state_before == event.state_after == "sandbox_running" + assert event.evidence_json == {"request_source": "review_ui"} + assert result.state == "sandbox_running" + assert result.state_version == 3 + assert result.reused is False + assert run.cancellation_requested is True + assert run.state_version == 3 + assert run.updated_at == now + assert run.latest_event_digest == event.event_digest + + +@pytest.mark.asyncio +async def test_cancellation_is_idempotent_and_rejects_terminal_or_stale_run() -> None: + """Repeated intent is harmless while terminal and lost-CAS writes fail closed.""" + + run = MigrationRun( + migration_run_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="sandbox_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=True, + evidence_json={}, + ) + session = SimpleNamespace( + scalar=AsyncMock(return_value=run), + execute=AsyncMock(return_value=SimpleNamespace(rowcount=0)), + add=Mock(), + ) + repeated = await request_migration_run_cancellation( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + actor_user_uuid=None, + evidence={}, + ) + assert repeated.reused is True + session.execute.assert_not_awaited() + + run.cancellation_requested = False + with pytest.raises(MigrationRunContractError, match="state version conflict"): + await request_migration_run_cancellation( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + actor_user_uuid=None, + evidence={}, + ) + + run.state = "passed" + with pytest.raises(MigrationRunContractError, match="terminal"): + await request_migration_run_cancellation( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + actor_user_uuid=None, + evidence={}, + ) + + run.state = "unknown" + with pytest.raises(MigrationRunContractError, match="state is invalid"): + await request_migration_run_cancellation( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + actor_user_uuid=None, + evidence={}, + ) + + run.run_kind = "preview" + run.state = "queued" + with pytest.raises(MigrationRunContractError, match="state is invalid"): + await request_migration_run_cancellation( + session, + migration_run_uuid=run.migration_run_uuid, + expected_state_version=3, + actor_user_uuid=None, + evidence={}, + ) + + session.scalar.return_value = None + with pytest.raises(MigrationRunContractError, match="state version conflict"): + await request_migration_run_cancellation( + session, + migration_run_uuid=uuid.uuid4(), + expected_state_version=3, + actor_user_uuid=None, + evidence={}, + ) + + +@pytest.mark.asyncio +async def test_cancellation_validates_metadata_before_database_access() -> None: + """Invalid cancellation version, evidence, or time never reaches storage.""" + + session = SimpleNamespace( + scalar=AsyncMock(), execute=AsyncMock(), add=Mock() + ) + for version, evidence, now in ( + (0, {}, None), + (True, {}, None), + (1, {"databaseDsn": "postgresql://secret"}, None), + (1, {}, datetime(2026, 8, 10, 4)), + ): + with pytest.raises(MigrationRunContractError): + await request_migration_run_cancellation( + session, + migration_run_uuid=uuid.uuid4(), + expected_state_version=version, + actor_user_uuid=None, + evidence=evidence, + now=now, + ) + + session.scalar.assert_not_awaited() + session.execute.assert_not_awaited() diff --git a/backend/tests/test_forward_pre_apply_revalidation.py b/backend/tests/test_forward_pre_apply_revalidation.py new file mode 100644 index 000000000..a625a22be --- /dev/null +++ b/backend/tests/test_forward_pre_apply_revalidation.py @@ -0,0 +1,895 @@ +"""Execution-neutral pre-apply revalidation manifest contract tests.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from collections.abc import Mapping +from typing import Any + +import asyncpg +import pytest + +from app.forward.migration_plan import COMPILER_VERSION +from app.forward.pre_apply_revalidation import ( + PreApplyRevalidationContractError, + PreApplyRevalidationManifest, + assess_pre_apply_revalidation_observation, + capture_pre_apply_revalidation_observation, + compile_apply_privilege_queries, + compile_pre_apply_revalidation_manifest, +) +from app.forward.schema_model import schema_model_digest +from app.forward.snapshot_adapter import snapshot_to_schema_model + + +def _statement( + *, + kind: str = "alter_column_type", + schema_name: str = "Sales Data", + table_name: str = 'Order "Item"', + precondition_schema: str | None = None, + precondition_table: str | None = None, + required_privileges: list[str] | None = None, +) -> dict[str, object]: + """Build one exact compiler-v1 statement with a data precondition.""" + + return { + "kind": kind, + "target": f"{schema_name}.{table_name}.amount", + "object_ref": { + "schema_name": schema_name, + "table_name": table_name, + "column_name": "amount", + }, + "sql": "server-owned and never parsed by this boundary", + "transactional": True, + "dependencies": [], + "dependency_refs": [], + "reversible": False, + "risk": { + "severity": "warning", + "lock_mode": "ACCESS EXCLUSIVE", + "possible_rewrite": True, + "table_scan": True, + "data_loss": False, + "detail": "bounded test fixture", + }, + "required_privileges": ( + required_privileges if required_privileges is not None else ["OWNER"] + ), + "preconditions": [ + { + "kind": "no_null_values", + "schema_name": precondition_schema or schema_name, + "table_name": precondition_table or table_name, + "column_name": "amount", + } + ], + } + + +def _signed_plan(*statements: dict[str, object]) -> dict[str, object]: + """Build and sign the exact immutable plan shape used by compiler v1.""" + + plan: dict[str, object] = { + "compiler_version": COMPILER_VERSION, + "snapshot_contract_version": 1, + "postgresql_major": 18, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "statements": list(statements), + "proposed_statements": [], + "blockers": [], + "risk_summary": {"safe": 0, "warning": len(statements), "destructive": 0}, + "requires_destructive_confirmation": False, + "can_dry_run": True, + } + encoded = json.dumps( + plan, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + plan["plan_digest"] = hashlib.sha256(encoded).hexdigest() + return plan + + +def _new_object_statement( + *, kind: str, schema_name: str, table_name: str | None = None +) -> dict[str, object]: + """Build one exact compiler-v1 CREATE statement without target access.""" + + object_ref = {"schema_name": schema_name} + lock_mode = "none" + target = schema_name + if table_name is not None: + object_ref["table_name"] = table_name + lock_mode = "ACCESS EXCLUSIVE" + target = f"{schema_name}.{table_name}" + return { + "kind": kind, + "target": target, + "object_ref": object_ref, + "sql": "server-owned and never parsed by this boundary", + "transactional": True, + "dependencies": [], + "dependency_refs": [], + "reversible": True, + "risk": { + "severity": "safe", + "lock_mode": lock_mode, + "possible_rewrite": False, + "table_scan": False, + "data_loss": False, + "detail": "bounded test fixture", + }, + "required_privileges": ["CREATE"], + "preconditions": [], + } + + +def _resign(plan: dict[str, object]) -> None: + """Replace the claimed digest after an intentional fixture mutation.""" + + plan.pop("plan_digest", None) + encoded = json.dumps( + plan, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + plan["plan_digest"] = hashlib.sha256(encoded).hexdigest() + + +def _observation( + manifest: PreApplyRevalidationManifest, + *, + observed_base_digest: str | None = None, +) -> dict[str, object]: + """Build complete positional evidence for one compiled manifest.""" + + return { + "plan_digest": manifest.plan_digest, + "observed_base_digest": observed_base_digest or manifest.base_digest, + "privileges": [ + { + "statement_index": requirement.statement_index, + "privilege": requirement.privilege, + "scope": requirement.scope, + "schema_name": requirement.schema_name, + "table_name": requirement.table_name, + "allowed": True, + } + for requirement in manifest.privilege_requirements + ], + "preconditions": [ + { + "statement_index": query.statement_index, + "precondition_index": query.precondition_index, + "kind": query.kind, + "passed": True, + } + for query in manifest.precondition_queries + ], + } + + +def _strict_snapshot() -> dict[str, Any]: + """Build one strict PostgreSQL snapshot for capture-bound revalidation.""" + + return { + "snapshot_contract_version": 1, + "server_version_num": 180002, + "schemas": [{"schema_oid": 11, "schema_name": "Sales Data"}], + "relations": [ + { + "relation_oid": 42, + "schema_name": "Sales Data", + "relation_name": 'Order "Item"', + "relation_kind": "r", + } + ], + "columns": [ + { + "relation_oid": 42, + "column_name": "amount", + "data_type": "bigint", + "is_not_null": True, + "column_position": 1, + } + ], + "pk_columns": [], + "constraints": [], + "fk_edges": [], + "indexes": [], + } + + +class _CaptureTransaction: + """Record the bounded transaction lifecycle used by the capture primitive.""" + + def __init__(self, connection: "_CaptureConnection") -> None: + self.connection = connection + self.started = False + self.committed = False + self.rolled_back = False + + async def start(self) -> None: + self.started = True + self.connection.started = True + + async def commit(self) -> None: + self.committed = True + self.connection.committed = True + + async def rollback(self) -> None: + self.rolled_back = True + self.connection.rolled_back = True + + +class _CaptureConnection: + """Minimal asyncpg-shaped connection with ordered boolean observations.""" + + def __init__(self, results: list[object]) -> None: + self.results = iter(results) + self.transaction_options: dict[str, object] | None = None + self.started = False + self.committed = False + self.rolled_back = False + self.transactions: list[_CaptureTransaction] = [] + self.executed: list[tuple[str, tuple[object, ...]]] = [] + self.queries: list[tuple[str, tuple[object, ...], float | None]] = [] + + def transaction(self, **kwargs: object) -> _CaptureTransaction: + if kwargs: + self.transaction_options = kwargs + transaction = _CaptureTransaction(self) + self.transactions.append(transaction) + return transaction + + async def execute(self, sql: str, *args: object) -> None: + self.executed.append((sql, args)) + + async def fetchval( + self, + sql: str, + *args: object, + timeout: float | None = None, + ) -> object: + self.queries.append((sql, args, timeout)) + result = next(self.results) + if isinstance(result, BaseException): + raise result + return result + + +class _FailingCaptureConnection(_CaptureConnection): + """Raise one credential-bearing target error for redaction assertions.""" + + async def fetchval( + self, + sql: str, + *args: object, + timeout: float | None = None, + ) -> object: + self.queries.append((sql, args, timeout)) + raise RuntimeError("postgresql://user:secret@target/private-row") + + +@pytest.mark.asyncio +async def test_captures_complete_observation_in_one_read_only_target_snapshot() -> None: + """Fresh digest, privilege, and precondition facts share one connection.""" + + snapshot = _strict_snapshot() + base_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + plan = _signed_plan(_statement()) + plan["base_digest"] = base_digest + _resign(plan) + connection = _CaptureConnection([True, True]) + captured_connections: list[object] = [] + + async def capture_snapshot(candidate: object) -> Mapping[str, object]: + assert connection.started is True + captured_connections.append(candidate) + return snapshot + + assessment = await capture_pre_apply_revalidation_observation( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture_snapshot, + statement_timeout_ms=750, + ) + + assert captured_connections == [connection] + assert connection.transaction_options == { + "isolation": "repeatable_read", + "readonly": True, + } + assert connection.executed == [ + ( + "SELECT pg_catalog.set_config('statement_timeout', $1, true)", + ("750",), + ) + ] + assert [args for _sql, args, _timeout in connection.queries] == [ + ("Sales Data", 'Order "Item"'), + (), + ] + assert assessment.observed_base_digest == base_digest + assert assessment.base_matches is True + assert assessment.privileges_satisfied is True + assert assessment.preconditions_satisfied is True + assert connection.committed is True + assert connection.rolled_back is False + + +@pytest.mark.asyncio +async def test_capture_preserves_negative_facts_without_apply_authority() -> None: + """Drift, privilege denial, and failed checks remain explicit booleans.""" + + snapshot = _strict_snapshot() + plan = _signed_plan(_statement()) + connection = _CaptureConnection([False, False]) + + async def capture_snapshot(_candidate: object) -> Mapping[str, object]: + return snapshot + + assessment = await capture_pre_apply_revalidation_observation( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture_snapshot, + ) + + assert assessment.base_matches is False + assert assessment.privileges_satisfied is False + assert assessment.preconditions_satisfied is False + assert connection.committed is True + + +@pytest.mark.asyncio +async def test_capture_rolls_back_and_sanitizes_target_failure() -> None: + """Driver and callback detail never escapes the capture boundary.""" + + snapshot = _strict_snapshot() + base_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + plan = _signed_plan(_statement()) + plan["base_digest"] = base_digest + _resign(plan) + connection = _FailingCaptureConnection([]) + + async def capture_snapshot(_candidate: object) -> Mapping[str, object]: + return snapshot + + with pytest.raises( + PreApplyRevalidationContractError, + match=r"^pre-apply revalidation capture failed$", + ) as failure: + await capture_pre_apply_revalidation_observation( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture_snapshot, + ) + + assert "secret" not in str(failure.value) + assert "private-row" not in str(failure.value) + assert connection.rolled_back is True + assert connection.committed is False + + +@pytest.mark.asyncio +async def test_capture_rejects_non_boolean_privilege_result() -> None: + """A non-boolean catalog result fails closed and rolls back the capture.""" + + snapshot = _strict_snapshot() + base_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + plan = _signed_plan(_statement()) + plan["base_digest"] = base_digest + _resign(plan) + connection = _CaptureConnection([1, True]) + + async def capture_snapshot(_candidate: object) -> Mapping[str, object]: + return snapshot + + with pytest.raises( + PreApplyRevalidationContractError, + match="privilege result is invalid", + ): + await capture_pre_apply_revalidation_observation( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture_snapshot, + ) + + assert connection.rolled_back is True + assert connection.committed is False + + +@pytest.mark.asyncio +async def test_capture_records_cast_data_failure_as_negative_evidence() -> None: + """A cast failure rolls back its savepoint and remains a false fact.""" + + snapshot = _strict_snapshot() + base_digest = schema_model_digest(snapshot_to_schema_model(snapshot)) + plan = _signed_plan(_statement()) + plan["base_digest"] = base_digest + statements = plan["statements"] + assert isinstance(statements, list) + statement = statements[0] + assert isinstance(statement, dict) + statement["preconditions"] = [ + { + "kind": "castable_values", + "schema_name": "Sales Data", + "table_name": 'Order "Item"', + "column_name": "amount", + "target_data_type": "integer", + } + ] + _resign(plan) + connection = _CaptureConnection( + [True, asyncpg.DataError("invalid input value")] + ) + + async def capture_snapshot(_candidate: object) -> Mapping[str, object]: + return snapshot + + assessment = await capture_pre_apply_revalidation_observation( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture_snapshot, + ) + + assert assessment.base_matches is True + assert assessment.privileges_satisfied is True + assert assessment.preconditions_satisfied is False + outer_transaction, savepoint = connection.transactions + assert outer_transaction.committed is True + assert outer_transaction.rolled_back is False + assert savepoint.committed is False + assert savepoint.rolled_back is True + + +@pytest.mark.asyncio +async def test_capture_preserves_cancellation_after_best_effort_rollback() -> None: + """Cancellation is not converted into a contract or target failure.""" + + plan = _signed_plan(_statement()) + connection = _CaptureConnection([]) + + async def capture_snapshot(_candidate: object) -> Mapping[str, object]: + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await capture_pre_apply_revalidation_observation( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture_snapshot, + ) + + assert len(connection.transactions) == 1 + assert connection.transactions[0].rolled_back is True + assert connection.transactions[0].committed is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("statement_timeout_ms", [True, 0, 60_001, "5000"]) +async def test_capture_rejects_invalid_timeout_before_target_access( + statement_timeout_ms: object, +) -> None: + """Malformed timeout input cannot open a target transaction.""" + + plan = _signed_plan(_statement()) + connection = _CaptureConnection([]) + + async def capture_snapshot(_candidate: object) -> Mapping[str, object]: + return _strict_snapshot() + + with pytest.raises( + PreApplyRevalidationContractError, + match="statement timeout is invalid", + ): + await capture_pre_apply_revalidation_observation( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture_snapshot, + statement_timeout_ms=statement_timeout_ms, # type: ignore[arg-type] + ) + + assert connection.transaction_options is None + + +def test_binds_signed_plan_locks_and_checks_without_target_access() -> None: + """Manifest preserves exact authority inputs and deterministic ordering.""" + + plan = _signed_plan(_statement()) + + manifest = compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + assert manifest.plan_digest == plan["plan_digest"] + assert manifest.compiler_version == COMPILER_VERSION + assert manifest.snapshot_contract_version == 1 + assert manifest.postgresql_major == 18 + assert manifest.base_digest == "a" * 64 + assert manifest.target_digest == "b" * 64 + assert len(manifest.transaction_segments) == 1 + assert manifest.transaction_segments[0].segment_index == 0 + assert manifest.transaction_segments[0].statement_indexes == (0,) + assert manifest.transaction_segments[0].transactional is True + assert [ + ( + requirement.statement_index, + requirement.privilege, + requirement.scope, + requirement.schema_name, + requirement.table_name, + ) + for requirement in manifest.privilege_requirements + ] == [(0, "OWNER", "table", "Sales Data", 'Order "Item"')] + assert [target.sql for target in manifest.lock_targets] == [ + 'LOCK TABLE "Sales Data"."Order ""Item""" IN ACCESS EXCLUSIVE MODE' + ] + assert [query.sql for query in manifest.precondition_queries] == [ + 'SELECT NOT EXISTS (SELECT 1 FROM "Sales Data"."Order ""Item""" ' + 'WHERE "amount" IS NULL LIMIT 1)' + ] + + +def test_assesses_complete_bound_observation_without_granting_apply_authority() -> None: + """Pure assessment derives only evidence booleans from exact manifest rows.""" + + plan = _signed_plan(_statement()) + manifest = compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + assessment = assess_pre_apply_revalidation_observation( + manifest, + _observation(manifest), + ) + + assert assessment.observed_base_digest == "a" * 64 + assert assessment.base_matches is True + assert assessment.privileges_satisfied is True + assert assessment.preconditions_satisfied is True + + +def test_assessment_preserves_negative_observations_as_non_authorizing_facts() -> None: + """Drift, privilege denial, and failed checks remain explicit evidence.""" + + plan = _signed_plan(_statement()) + manifest = compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + observation = _observation(manifest, observed_base_digest="c" * 64) + privileges = observation["privileges"] + preconditions = observation["preconditions"] + assert isinstance(privileges, list) and isinstance(preconditions, list) + privileges[0]["allowed"] = False + preconditions[0]["passed"] = False + + assessment = assess_pre_apply_revalidation_observation(manifest, observation) + + assert assessment.base_matches is False + assert assessment.privileges_satisfied is False + assert assessment.preconditions_satisfied is False + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("unknown_field", "observation contract is invalid"), + ("wrong_plan", "plan digest does not match"), + ("missing_privilege", "privilege observations are incomplete"), + ("wrong_privilege_target", "privilege observation does not match"), + ("non_boolean_privilege", "privilege result is invalid"), + ("missing_precondition", "precondition observations are incomplete"), + ("wrong_precondition_kind", "precondition observation does not match"), + ("non_boolean_precondition", "precondition result is invalid"), + ], +) +def test_assessment_rejects_incomplete_or_unbound_observations( + mutation: str, message: str +) -> None: + """Untrusted caller evidence must match every manifest position exactly.""" + + plan = _signed_plan(_statement()) + manifest = compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + observation = _observation(manifest) + privileges = observation["privileges"] + preconditions = observation["preconditions"] + assert isinstance(privileges, list) and isinstance(preconditions, list) + + if mutation == "unknown_field": + observation["connection_id"] = "not-authority" + elif mutation == "wrong_plan": + observation["plan_digest"] = "d" * 64 + elif mutation == "missing_privilege": + privileges.clear() + elif mutation == "wrong_privilege_target": + privileges[0]["table_name"] = "other" + elif mutation == "non_boolean_privilege": + privileges[0]["allowed"] = 1 + elif mutation == "missing_precondition": + preconditions.clear() + elif mutation == "wrong_precondition_kind": + preconditions[0]["kind"] = "table_is_empty" + else: + preconditions[0]["passed"] = "yes" + + with pytest.raises(PreApplyRevalidationContractError, match=message): + assess_pre_apply_revalidation_observation(manifest, observation) + + +def test_compiles_one_ordered_segment_for_multiple_statements() -> None: + """Compiler-v1 never splits admitted apply work into implicit segments.""" + + plan = _signed_plan( + _statement(schema_name="alpha", table_name="first"), + _statement(schema_name="zeta", table_name="second"), + ) + + manifest = compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + assert len(manifest.transaction_segments) == 1 + assert manifest.transaction_segments[0].statement_indexes == (0, 1) + + +def test_maps_create_privileges_to_database_and_schema_scopes() -> None: + """CREATE labels retain their distinct PostgreSQL authority scopes.""" + + plan = _signed_plan( + _new_object_statement(kind="create_schema", schema_name="분석 영역"), + _new_object_statement( + kind="create_table", + schema_name="분석 영역", + table_name='Event "Log"', + ), + ) + + manifest = compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + assert [ + ( + requirement.statement_index, + requirement.privilege, + requirement.scope, + requirement.schema_name, + requirement.table_name, + ) + for requirement in manifest.privilege_requirements + ] == [ + (0, "CREATE", "database", None, None), + (1, "CREATE", "schema", "분석 영역", None), + ] + + +def test_compiles_parameterized_privilege_probes_without_target_access() -> None: + """Privilege scopes become bounded catalog reads with data parameters.""" + + plan = _signed_plan( + _new_object_statement(kind="create_schema", schema_name="분석 영역"), + _new_object_statement( + kind="create_table", + schema_name="분석 영역", + table_name='Event "Log"', + ), + _statement(schema_name="분석 영역", table_name='Event "Log"'), + ) + queries = compile_apply_privilege_queries( + plan, expected_plan_digest=plan["plan_digest"] + ) + + assert [ + (query.statement_index, query.privilege, query.scope, query.parameters) + for query in queries + ] == [ + (0, "CREATE", "database", ()), + (1, "CREATE", "schema", ("분석 영역",)), + (2, "OWNER", "table", ("분석 영역", 'Event "Log"')), + ] + assert queries[0].sql == ( + "SELECT pg_catalog.has_database_privilege(" + "pg_catalog.current_database(), 'CREATE')" + ) + assert queries[1].sql == ( + "SELECT pg_catalog.has_schema_privilege($1::text, 'CREATE')" + ) + assert "pg_catalog.pg_has_role(c.relowner, 'USAGE')" in queries[2].sql + assert "$1::text" in queries[2].sql and "$2::text" in queries[2].sql + + +def test_privilege_probe_compiler_rejects_redirected_signed_plan_target() -> None: + """A stale signature cannot redirect a valid probe to another object.""" + + plan = _signed_plan(_statement(schema_name="public", table_name="orders")) + expected_plan_digest = plan["plan_digest"] + statements = plan["statements"] + assert isinstance(statements, list) + statement = statements[0] + assert isinstance(statement, dict) + object_ref = statement["object_ref"] + assert isinstance(object_ref, dict) + object_ref["schema_name"] = "other_schema" + object_ref["table_name"] = "other_table" + + with pytest.raises( + PreApplyRevalidationContractError, match="plan digest is invalid" + ): + compile_apply_privilege_queries( + plan, expected_plan_digest=expected_plan_digest + ) + + +def test_compiles_no_transaction_segment_for_a_noop_plan() -> None: + """A converged no-op plan contains no synthetic executable segment.""" + + plan = _signed_plan() + + manifest = compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + assert manifest.transaction_segments == () + assert manifest.privilege_requirements == () + assert manifest.lock_targets == () + assert manifest.precondition_queries == () + + +def test_rejects_required_privileges_outside_compiler_v1_semantics() -> None: + """Signed metadata cannot make a weaker or unknown privilege executable.""" + + plan = _signed_plan(_statement(required_privileges=["ALTER"])) + + with pytest.raises( + PreApplyRevalidationContractError, match="required privileges are invalid" + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + +def test_rejects_content_that_no_longer_matches_the_stored_digest() -> None: + """A caller cannot bind lock/check work from mutated plan content.""" + + plan = _signed_plan(_statement()) + expected_digest = plan["plan_digest"] + plan["postgresql_major"] = 17 + + with pytest.raises( + PreApplyRevalidationContractError, match="plan digest is invalid" + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=expected_digest + ) + + +def test_rejects_noncanonical_plan_content_with_a_fixed_error() -> None: + """Digest verification never exposes serialization implementation detail.""" + + plan = _signed_plan(_statement()) + plan["risk_summary"] = {"warning": {1}} + + with pytest.raises( + PreApplyRevalidationContractError, match="plan digest is invalid" + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest="a" * 64 + ) + + +@pytest.mark.parametrize("expected_digest", [None, True, "A" * 64, "a" * 63]) +def test_rejects_invalid_expected_plan_digest(expected_digest: object) -> None: + """Stored digest input must be a canonical lowercase SHA-256 value.""" + + plan = _signed_plan(_statement()) + + with pytest.raises( + PreApplyRevalidationContractError, match="expected plan digest is invalid" + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=expected_digest + ) + + +def test_rejects_unknown_plan_fields_even_when_the_content_is_signed() -> None: + """A future contract cannot silently enter a compiler-v1 apply boundary.""" + + plan = _signed_plan(_statement()) + plan["future_authority"] = True + _resign(plan) + + with pytest.raises( + PreApplyRevalidationContractError, match="plan contract is invalid" + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + +def test_rejects_precondition_bound_to_a_different_table() -> None: + """Every data check must describe the statement's structured table ref.""" + + plan = _signed_plan( + _statement(precondition_schema="other", precondition_table="orders") + ) + + with pytest.raises( + PreApplyRevalidationContractError, + match="precondition target does not match statement", + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + +def test_rejects_preconditions_without_an_existing_table_lock() -> None: + """A data check cannot be scheduled unless its relation will be locked.""" + + plan = _signed_plan(_statement(kind="create_table")) + + with pytest.raises( + PreApplyRevalidationContractError, match="precondition target is not locked" + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("compiler_version", "future", "compiler is unsupported"), + ( + "snapshot_contract_version", + 2, + "snapshot contract is unsupported", + ), + ("proposed_statements", [{}], "proposals are not executable"), + ], +) +def test_rejects_incompatible_or_review_only_plan_contracts( + field: str, value: object, message: str +) -> None: + """Only exact executable compiler-v1 plans can produce a manifest.""" + + plan = _signed_plan(_statement()) + plan[field] = value + _resign(plan) + + with pytest.raises(PreApplyRevalidationContractError, match=message): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) + + +@pytest.mark.parametrize("postgresql_major", [True, 13, 19, "18"]) +def test_rejects_unsupported_postgresql_major(postgresql_major: object) -> None: + """Manifest compatibility remains bounded to supported PostgreSQL majors.""" + + plan = _signed_plan(_statement()) + plan["postgresql_major"] = postgresql_major + _resign(plan) + + with pytest.raises( + PreApplyRevalidationContractError, match="PostgreSQL major is invalid" + ): + compile_pre_apply_revalidation_manifest( + plan, expected_plan_digest=plan["plan_digest"] + ) diff --git a/backend/tests/test_forward_schema_model.py b/backend/tests/test_forward_schema_model.py new file mode 100644 index 000000000..121c0d644 --- /dev/null +++ b/backend/tests/test_forward_schema_model.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import copy + +import pytest + +from app.forward.schema_model import ( + SchemaModelValidationError, + canonicalize_schema_model, + schema_model_digest, +) + + +def _model() -> dict: + return { + "format_version": 1, + "postgresql_major": 18, + "schemas": [ + { + "schema_name": "Sales Data", + "tables": [ + { + "table_name": 'Order "Item"', + "comment": "Line items", + "columns": [ + { + "column_name": "Description", + "data_type": "text", + "nullable": True, + "ordinal_position": 2, + }, + { + "column_name": "Item ID", + "data_type": "bigint", + "nullable": False, + "ordinal_position": 1, + }, + ], + "primary_key": { + "constraint_name": 'Order "Item" pkey', + "columns": ["Item ID"], + "deferrable": False, + "initially_deferred": False, + }, + "unique_constraints": [], + "foreign_keys": [], + "indexes": [], + "unsupported_features": [], + } + ], + } + ], + } + + +def test_canonical_model_preserves_identifier_semantics_and_column_order() -> None: + model = _model() + model["capture_id"] = "volatile" + model["schemas"][0]["tables"][0]["relation_oid"] = 4242 + + canonical = canonicalize_schema_model(model) + + table = canonical["schemas"][0]["tables"][0] + assert table["table_name"] == 'Order "Item"' + assert [column["column_name"] for column in table["columns"]] == [ + "Item ID", + "Description", + ] + assert "capture_id" not in canonical + assert "relation_oid" not in table + + +def test_digest_is_stable_across_object_order_and_volatile_metadata() -> None: + left = _model() + right = copy.deepcopy(left) + right["captured_at"] = "2026-08-09T00:00:00Z" + right["schemas"][0]["tables"][0]["relation_oid"] = 999 + right["schemas"].reverse() + + assert schema_model_digest(left) == schema_model_digest(right) + + +def test_missing_and_null_column_comments_have_one_canonical_form() -> None: + without_comment = _model() + with_null_comment = copy.deepcopy(without_comment) + with_null_comment["schemas"][0]["tables"][0]["columns"][0]["comment"] = None + + canonical = canonicalize_schema_model(without_comment) + + assert all( + "comment" in column + for column in canonical["schemas"][0]["tables"][0]["columns"] + ) + assert schema_model_digest(without_comment) == schema_model_digest(with_null_comment) + + +@pytest.mark.parametrize( + ("input_type", "canonical_type"), + [ + ("INT", "integer"), + ("BOOL[]", "boolean[]"), + ("VarChar(32)", "character varying(32)"), + ("CHAR", "character(1)"), + ("DECIMAL(10, 2)", "numeric(10,2)"), + ("timestamp", "timestamp without time zone"), + ("TIME(3)", "time(3) without time zone"), + ], +) +def test_data_types_canonicalize_to_postgresql_catalog_spelling( + input_type: str, canonical_type: str +) -> None: + model = _model() + model["schemas"][0]["tables"][0]["columns"][0]["data_type"] = input_type + + canonical = canonicalize_schema_model(model) + + columns = canonical["schemas"][0]["tables"][0]["columns"] + assert next( + column["data_type"] + for column in columns + if column["column_name"] == "Description" + ) == canonical_type + + +@pytest.mark.parametrize("pseudo_type", ["smallserial", "serial", "bigserial", "serial[]"]) +def test_serial_pseudo_types_are_rejected_as_non_convergent(pseudo_type: str) -> None: + model = _model() + model["schemas"][0]["tables"][0]["columns"][0]["data_type"] = pseudo_type + + with pytest.raises(SchemaModelValidationError, match="serial pseudo-type"): + canonicalize_schema_model(model) + + +def test_primary_key_columns_must_be_explicitly_not_nullable() -> None: + model = _model() + item_id = model["schemas"][0]["tables"][0]["columns"][1] + item_id["nullable"] = True + + with pytest.raises(SchemaModelValidationError, match="primary_key.*not nullable"): + canonicalize_schema_model(model) + + +def test_digest_changes_for_compiler_relevant_mutation() -> None: + before = _model() + after = copy.deepcopy(before) + after["schemas"][0]["tables"][0]["columns"][0]["nullable"] = False + + assert schema_model_digest(before) != schema_model_digest(after) + + +def test_omitted_unsupported_collections_canonicalize_to_empty_lists() -> None: + model = _model() + table = model["schemas"][0]["tables"][0] + for field in ("unique_constraints", "foreign_keys", "indexes"): + table.pop(field) + + canonical = canonicalize_schema_model(model) + + canonical_table = canonical["schemas"][0]["tables"][0] + assert canonical_table["unique_constraints"] == [] + assert canonical_table["foreign_keys"] == [] + assert canonical_table["indexes"] == [] + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda model: model["schemas"][0]["tables"][0].update( + {"unsupported_features": ["row_security"]} + ), + "unsupported feature", + ), + ( + lambda model: model["schemas"][0]["tables"][0]["columns"].append( + { + "column_name": "Item ID", + "data_type": "integer", + "nullable": False, + "ordinal_position": 3, + } + ), + "duplicate column", + ), + ( + lambda model: model["schemas"][0]["tables"][0]["primary_key"].update( + {"columns": ["missing_column"]} + ), + "unknown column", + ), + ( + lambda model: model["schemas"][0].update({"schema_name": "bad\x00name"}), + "NUL", + ), + ( + lambda model: model.update({"format_version": 2}), + "format_version", + ), + ( + lambda model: model["schemas"][0]["tables"][0]["columns"][0].update( + {"data_type": "text); DROP TABLE audit_log; --"} + ), + "unsupported data type", + ), + ( + lambda model: model["schemas"][0]["tables"][0]["columns"][0].update( + {"default": "0); DROP TABLE audit_log; --"} + ), + "default expressions", + ), + ], +) +def test_model_validation_fails_closed(mutate, message: str) -> None: + model = _model() + mutate(model) + + with pytest.raises(SchemaModelValidationError, match=message): + canonicalize_schema_model(model) + + +def test_model_rejects_unrecognized_fields_in_authoritative_objects() -> None: + model = _model() + model["schemas"][0]["tables"][0]["mystery_sql"] = "DROP DATABASE production" + + with pytest.raises(SchemaModelValidationError, match="unrecognized field"): + canonicalize_schema_model(model) + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ([], "model must be an object"), + ({"format_version": 1, "postgresql_major": 18, "schemas": {}}, "schemas must be a list"), + ({"format_version": 1, "postgresql_major": 13, "schemas": []}, "supported version"), + ({"format_version": 1, "postgresql_major": True, "schemas": []}, "supported version"), + ({"format_version": 1, "postgresql_major": 18, "schemas": [1]}, "must be an object"), + ], +) +def test_root_shape_validation(value, message: str) -> None: + with pytest.raises(SchemaModelValidationError, match=message): + canonicalize_schema_model(value) + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (lambda model: model["schemas"][0].update({"schema_name": 1}), "must be text"), + (lambda model: model["schemas"][0].update({"schema_name": ""}), "must not be empty"), + (lambda model: model["schemas"][0].update({"schema_name": "가" * 22}), "63-byte"), + (lambda model: model["schemas"][0].update({"tables": {}}), "tables must be a list"), + (lambda model: model["schemas"][0]["tables"].append(1), "must be an object"), + (lambda model: model["schemas"][0]["tables"][0].update({"comment": 1}), "text or null"), + (lambda model: model["schemas"][0]["tables"][0].update({"comment": "bad\x00comment"}), "NUL"), + (lambda model: model["schemas"][0]["tables"][0].update({"columns": {}}), "columns must be a list"), + (lambda model: model["schemas"][0]["tables"][0]["columns"].append(1), "must be an object"), + (lambda model: model["schemas"][0]["tables"][0]["columns"][0].update({"nullable": 1}), "must be boolean"), + (lambda model: model["schemas"][0]["tables"][0]["columns"][0].update({"ordinal_position": 0}), "positive integer"), + (lambda model: model["schemas"][0]["tables"][0]["columns"][0].update({"comment": 1}), "text or null"), + (lambda model: model["schemas"][0]["tables"][0]["columns"][0].update({"comment": "bad\x00comment"}), "NUL"), + (lambda model: model["schemas"][0]["tables"][0]["primary_key"].update({"columns": []}), "must not be empty"), + (lambda model: model["schemas"][0]["tables"][0]["primary_key"].update({"columns": ["Item ID", "Item ID"]}), "duplicate column"), + (lambda model: model["schemas"][0]["tables"][0]["primary_key"].update({"deferrable": 1}), "must be boolean"), + ( + lambda model: model["schemas"][0]["tables"][0]["primary_key"].update( + {"deferrable": False, "initially_deferred": True} + ), + "initially_deferred requires deferrable", + ), + (lambda model: model["schemas"][0]["tables"][0]["columns"][0].update({"ordinal_position": 1}), "duplicate column ordinal"), + (lambda model: model["schemas"][0]["tables"][0].update({"indexes": [{}]}), "unsupported feature"), + ], +) +def test_nested_shape_validation(mutate, message: str) -> None: + model = _model() + mutate(model) + with pytest.raises(SchemaModelValidationError, match=message): + canonicalize_schema_model(model) + + +def test_duplicate_schema_and_table_are_rejected() -> None: + model = _model() + model["schemas"].append(copy.deepcopy(model["schemas"][0])) + with pytest.raises(SchemaModelValidationError, match="duplicate schema"): + canonicalize_schema_model(model) + + model = _model() + model["schemas"][0]["tables"].append( + copy.deepcopy(model["schemas"][0]["tables"][0]) + ) + with pytest.raises(SchemaModelValidationError, match="duplicate table"): + canonicalize_schema_model(model) diff --git a/backend/tests/test_forward_snapshot_adapter.py b/backend/tests/test_forward_snapshot_adapter.py new file mode 100644 index 000000000..e1b094317 --- /dev/null +++ b/backend/tests/test_forward_snapshot_adapter.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import pytest + +from app.forward.schema_model import SchemaModelValidationError +from app.forward.snapshot_adapter import snapshot_to_schema_model + + +def _snapshot() -> dict: + return { + "snapshot_contract_version": 1, + "server_version_num": 180002, + "schemas": [{"schema_oid": 11, "schema_name": "Sales Data"}], + "relations": [ + { + "relation_oid": 42, + "schema_name": "Sales Data", + "relation_name": 'Order "Item"', + "relation_kind": "r", + "relation_comment": "Line items", + } + ], + "columns": [ + { + "relation_oid": 42, + "column_name": "Description", + "data_type": "text", + "is_not_null": False, + "column_position": 2, + }, + { + "relation_oid": 42, + "column_name": "Item ID", + "data_type": "bigint", + "is_not_null": True, + "column_position": 1, + }, + ], + "pk_columns": [ + { + "constraint_oid": 700, + "relation_oid": 42, + "constraint_name": 'Order "Item" pkey', + "column_name": "Item ID", + "column_ordinal": 1, + "is_deferrable": False, + "is_initially_deferred": False, + } + ], + "constraints": [], + "fk_edges": [], + "indexes": [], + } + + +def test_snapshot_adapter_requires_current_capability_contract() -> None: + snapshot = _snapshot() + snapshot.pop("snapshot_contract_version") + + with pytest.raises(SchemaModelValidationError, match=r"recapture|required"): + snapshot_to_schema_model(snapshot) + + +def test_snapshot_adapter_removes_oids_and_preserves_supported_semantics() -> None: + model = snapshot_to_schema_model(_snapshot()) + + assert model["postgresql_major"] == 18 + table = model["schemas"][0]["tables"][0] + assert table["table_name"] == 'Order "Item"' + assert [column["column_name"] for column in table["columns"]] == [ + "Item ID", + "Description", + ] + assert table["primary_key"]["constraint_name"] == 'Order "Item" pkey' + assert "relation_oid" not in table + + +def test_snapshot_adapter_accepts_server_version_text_and_table_without_pk() -> None: + snapshot = _snapshot() + snapshot.pop("server_version_num") + snapshot["server_version"] = " 17.9 (Ubuntu)" + snapshot["pk_columns"] = [] + + model = snapshot_to_schema_model(snapshot) + + assert model["postgresql_major"] == 17 + assert model["schemas"][0]["tables"][0]["primary_key"] is None + + +@pytest.mark.parametrize( + "default_metadata", + [ + {"has_default": True, "default_expr": "0"}, + {"has_default": False, "default_expr": "nextval('items_id_seq')"}, + ], +) +def test_snapshot_adapter_rejects_actual_default_metadata( + default_metadata: dict[str, object], +) -> None: + snapshot = _snapshot() + snapshot["columns"][0].update(default_metadata) + + with pytest.raises(SchemaModelValidationError, match="default"): + snapshot_to_schema_model(snapshot) + + +def test_snapshot_adapter_preserves_primary_key_deferral_metadata() -> None: + snapshot = _snapshot() + snapshot["pk_columns"][0]["is_deferrable"] = True + snapshot["pk_columns"][0]["is_initially_deferred"] = True + + model = snapshot_to_schema_model(snapshot) + + primary_key = model["schemas"][0]["tables"][0]["primary_key"] + assert primary_key["deferrable"] is True + assert primary_key["initially_deferred"] is True + + +@pytest.mark.parametrize( + "generated_metadata", + [{"identity": "a"}, {"identity": "d"}, {"generated": "s"}], +) +def test_snapshot_adapter_rejects_identity_and_generated_catalog_metadata( + generated_metadata: dict[str, object], +) -> None: + snapshot = _snapshot() + snapshot["columns"][0].update(generated_metadata) + + with pytest.raises(SchemaModelValidationError, match=r"identity|generated"): + snapshot_to_schema_model(snapshot) + + +def test_snapshot_adapter_preserves_empty_schema_names() -> None: + snapshot = _snapshot() + snapshot["schemas"].append({"schema_oid": 12, "schema_name": "empty_schema"}) + + model = snapshot_to_schema_model(snapshot) + + schema_tables = { + schema["schema_name"]: schema["tables"] for schema in model["schemas"] + } + assert schema_tables["empty_schema"] == [] + + +def test_snapshot_adapter_allows_realistic_primary_constraint_and_backing_index() -> None: + snapshot = _snapshot() + snapshot["constraints"] = [ + { + "constraint_oid": 700, + "constraint_name": 'Order "Item" pkey', + "constraint_type": "p", + "relation_oid": 42, + } + ] + snapshot["indexes"] = [ + { + "index_oid": 701, + "index_name": 'Order "Item" pkey', + "relation_oid": None, + "table_oid": 42, + "is_primary": True, + } + ] + + model = snapshot_to_schema_model(snapshot) + + primary_key = model["schemas"][0]["tables"][0]["primary_key"] + assert primary_key["constraint_name"] == 'Order "Item" pkey' + + +@pytest.mark.parametrize("constraint_type", ["u", "c", "f"]) +def test_snapshot_adapter_rejects_unsupported_constraint_types( + constraint_type: str, +) -> None: + snapshot = _snapshot() + snapshot["constraints"] = [ + { + "constraint_oid": 800, + "constraint_name": "unsupported_constraint", + "constraint_type": constraint_type, + "relation_oid": 42, + } + ] + + with pytest.raises(SchemaModelValidationError, match="constraints"): + snapshot_to_schema_model(snapshot) + + +def test_snapshot_adapter_rejects_unrepresented_primary_constraint() -> None: + snapshot = _snapshot() + snapshot["constraints"] = [ + { + "constraint_oid": 800, + "constraint_name": "different_pkey", + "constraint_type": "p", + "relation_oid": 42, + } + ] + + with pytest.raises( + SchemaModelValidationError, match="not represented by pk_columns" + ): + snapshot_to_schema_model(snapshot) + + +@pytest.mark.parametrize( + "relation_metadata", + [ + {"relation_kind": "p"}, + {"is_partition": True}, + {"partition_key": "RANGE (created_at)"}, + {"partition_bound": "FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')"}, + {"partition_parent_oid": 7}, + {"partition_parent_schema": "public"}, + {"partition_parent_name": "orders"}, + {"tablespace_name": "fast_storage"}, + ], +) +def test_snapshot_adapter_rejects_partition_and_tablespace_metadata( + relation_metadata: dict[str, object], +) -> None: + snapshot = _snapshot() + snapshot["relations"][0].update(relation_metadata) + + with pytest.raises( + SchemaModelValidationError, match=r"partition|tablespace|relation kind" + ): + snapshot_to_schema_model(snapshot) + + +def test_snapshot_adapter_rejects_relations_with_dropped_column_slots() -> None: + snapshot = _snapshot() + snapshot["relations"][0]["has_dropped_columns"] = True + + with pytest.raises(SchemaModelValidationError, match="dropped columns"): + snapshot_to_schema_model(snapshot) + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda snapshot: snapshot["relations"][0].update({"relation_kind": "v"}), + "relation kind", + ), + ( + lambda snapshot: snapshot.update({"fk_edges": [{"fk_constraint_oid": 1}]}), + "foreign keys", + ), + ( + lambda snapshot: snapshot.update({"indexes": [{"index_oid": 1}]}), + "indexes", + ), + ( + lambda snapshot: snapshot.update({"server_version_num": 190000}), + "PostgreSQL major version", + ), + ( + lambda snapshot: snapshot.update({"citus_distributed_tables": [{}]}), + "distributed tables", + ), + ( + lambda snapshot: snapshot.update({"relations": {}}), + "relations and columns", + ), + ( + lambda snapshot: snapshot.update({"columns": {}}), + "relations and columns", + ), + ( + lambda snapshot: snapshot.update({"relations": [1]}), + "relation must be an object", + ), + ( + lambda snapshot: snapshot["relations"].append( + {**snapshot["relations"][0], "relation_name": "duplicate_oid"} + ), + "duplicate relation OID", + ), + ( + lambda snapshot: snapshot.update({"columns": [1]}), + "column must be an object", + ), + ( + lambda snapshot: snapshot["columns"][0].update({"relation_oid": 999}), + "unknown relation", + ), + ( + lambda snapshot: snapshot["columns"][0].update({"column_default": "0"}), + "default, identity, or generated", + ), + ( + lambda snapshot: snapshot.update({"pk_columns": [1]}), + "primary key must be an object", + ), + ( + lambda snapshot: snapshot["pk_columns"][0].update({"relation_oid": 999}), + "primary key references unknown", + ), + ( + lambda snapshot: snapshot["pk_columns"].append( + { + **snapshot["pk_columns"][0], + "constraint_name": "different_pkey", + "column_ordinal": 2, + } + ), + "ambiguous constraint names", + ), + ], +) +def test_snapshot_adapter_fails_closed_for_uncompiled_features(mutate, message: str) -> None: + snapshot = _snapshot() + mutate(snapshot) + + with pytest.raises(SchemaModelValidationError, match=message): + snapshot_to_schema_model(snapshot) diff --git a/backend/tests/test_forward_trd_traceability.py b/backend/tests/test_forward_trd_traceability.py new file mode 100644 index 000000000..fe1abcc20 --- /dev/null +++ b/backend/tests/test_forward_trd_traceability.py @@ -0,0 +1,64 @@ +"""Guard forward-engineering requirement-to-evidence traceability.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def _traceability_row(requirement: str) -> str: + """Return one normalized TRD traceability row by requirement identity.""" + + trd = (REPOSITORY_ROOT / "docs/TRD.md").read_text(encoding="utf-8") + prefix = f"| {requirement} |" + matches = [line for line in trd.splitlines() if line.startswith(prefix)] + + assert len(matches) == 1 + return " ".join(matches[0].split()).casefold() + + +def test_trd_traceability_records_current_forward_foundations() -> None: + """Keep implemented foundations distinct from remaining deployment gates.""" + + dry_run = _traceability_row("FE-TRD-006\u2013008") + durable_runs = _traceability_row("FE-TRD-009\u2013012") + + assert "metadata only" not in dry_run + assert "isolated dry-run execution/convergence core" in dry_run + assert "bound read-only live preflight" in dry_run + assert "test_forward_isolated_dry_run.py" in dry_run + assert "test_forward_live_preflight" in dry_run + assert "test_forward_apply_lock_plan.py" in dry_run + assert "test_forward_pre_apply_revalidation.py" in dry_run + assert "sandbox provisioning/materialization/isolation/cleanup" in dry_run + assert "credential-bound worker execution" in dry_run + assert "live apply" in dry_run + + assert "design/adr/contract only" not in durable_runs + assert "durable run/event/outbox/attempt persistence" in durable_runs + assert "uuid-only dispatch" in durable_runs + assert "exact signal/attempt leases" in durable_runs + assert "dry-run/apply-intent/cancellation apis" in durable_runs + assert "test_migration_run_consumer.py" in durable_runs + assert "test_postgres_migration_run_integration.py" in durable_runs + assert "application startup/credentials/deployed worker" in durable_runs + assert "crash recovery/no-replay reconciliation" in durable_runs + assert "live apply/convergence" in durable_runs + + +def test_trd_traceability_names_existing_test_files() -> None: + """Reject stale exact test filenames in the evidence matrix.""" + + trd = (REPOSITORY_ROOT / "docs/TRD.md").read_text(encoding="utf-8") + traceability = trd.split("## Requirement-to-evidence traceability", 1)[1] + test_filenames = set(re.findall(r"`(test_[^`*]+\.py)`", traceability)) + + missing = sorted( + filename + for filename in test_filenames + if not (REPOSITORY_ROOT / "backend/tests" / filename).is_file() + ) + assert missing == [] diff --git a/backend/tests/test_fuzz_properties.py b/backend/tests/test_fuzz_properties.py index 49e20ef03..93e2ae656 100644 --- a/backend/tests/test_fuzz_properties.py +++ b/backend/tests/test_fuzz_properties.py @@ -23,9 +23,10 @@ from hypothesis import HealthCheck, given, settings # noqa: E402 from hypothesis import strategies as st # noqa: E402 -from app.ddl.export import snapshot_json_to_sql # noqa: E402 +from app.ddl.export import quote_identifier, snapshot_json_to_sql # noqa: E402 from app.dsn_redaction import redact_dsn_error_message # noqa: E402 from app.sanitize import sanitize_for_storage, strip_nul # noqa: E402 +from app.spec.dbml_import import parse_dbml # noqa: E402 from app.spec.index_design import generate_index_design_spec # noqa: E402 from app.spec.naming_lint import lint_naming # noqa: E402 from app.spec.reversing import generate_reversing_spec # noqa: E402 @@ -192,6 +193,28 @@ def test_ddl_export_total_and_deterministic(snapshot: dict, dialect: str) -> Non assert out == snapshot_json_to_sql(snapshot, dialect) +_DBML_IDENTIFIER_TEXT = st.text( + alphabet=st.characters( + blacklist_categories=("Cs",), + blacklist_characters=("\x00", "\n", "\r"), + ), + min_size=1, + max_size=20, +).filter(lambda value: len(value.encode("utf-8")) <= 63) + + +@_SETTINGS +@given(_DBML_IDENTIFIER_TEXT) +def test_dbml_identifier_parse_render_round_trip(identifier: str) -> None: + """Quoted identifiers round-trip from DBML text to dialect rendering.""" + encoded = identifier.replace('"', '""') + snapshot = parse_dbml(f'Table "{encoded}" {{\n id integer\n}}') + ddl = snapshot_json_to_sql(snapshot, target_dialect="postgresql") + + assert snapshot["relations"][0]["relation_name"] == identifier + assert quote_identifier(identifier) in ddl + + @_SETTINGS @given(_snapshots(), st.sampled_from(["markdown", "llm-prompt"])) def test_spec_generators_total_and_deterministic(snapshot: dict, mode: str) -> None: diff --git a/backend/tests/test_job_worker_security.py b/backend/tests/test_job_worker_security.py new file mode 100644 index 000000000..3695535d2 --- /dev/null +++ b/backend/tests/test_job_worker_security.py @@ -0,0 +1,98 @@ +"""Security regressions for durable job failure evidence.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.jobs.worker import run_worker_forever, sanitize_job_error_message + + +@pytest.mark.parametrize( + "error", + [ + RuntimeError("postgresql://admin:s3cret@db.example/app"), + ValueError("password=hunter2 token=opaque SQL=DROP TABLE customer_data"), + OSError("sampled row value: national-id-123"), + ], +) +def test_worker_persists_fixed_failure_evidence(error: Exception) -> None: + """Raw exceptions, credentials, SQL, and row data never enter job rows.""" + + message = sanitize_job_error_message(error) + + assert message == "job_handler_failed" + assert str(error) not in message + + +def _session_factory() -> MagicMock: + """Return one reusable async session/context double for worker loops.""" + + session = MagicMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + transaction = MagicMock() + transaction.__aenter__ = AsyncMock(return_value=None) + transaction.__aexit__ = AsyncMock(return_value=False) + session.begin.return_value = transaction + factory = MagicMock(return_value=session) + factory.session = session + return factory + + +@pytest.mark.asyncio +async def test_worker_failure_path_never_persists_handler_exception() -> None: + """The real dispatch failure branch stores only its fixed error code.""" + + secret = "postgresql://admin:s3cret@db.example/app" + job = SimpleNamespace( + job_type="snapshot", + status="running", + last_error=None, + finished_at=None, + ) + factory = _session_factory() + handler = AsyncMock(side_effect=RuntimeError(secret)) + with patch( + "app.jobs.worker.claim_one_job", new=AsyncMock(side_effect=[job, None]) + ), patch( + "app.jobs.worker.asyncio.sleep", + new=AsyncMock(side_effect=asyncio.CancelledError), + ): + with pytest.raises(asyncio.CancelledError): + await run_worker_forever(factory, {"snapshot": handler}, poll_interval_s=0) + + assert job.status == "failed" + assert job.last_error == "job_handler_failed" + assert secret not in job.last_error + assert job.finished_at is not None + + +@pytest.mark.asyncio +async def test_worker_unknown_type_does_not_persist_untrusted_type_value() -> None: + """An unregistered type cannot inject metadata through durable errors.""" + + untrusted_type = "postgresql://admin:s3cret@db.example/app" + job = SimpleNamespace( + job_type=untrusted_type, + status="running", + last_error=None, + finished_at=None, + ) + factory = _session_factory() + with patch( + "app.jobs.worker.claim_one_job", new=AsyncMock(side_effect=[job, None]) + ), patch( + "app.jobs.worker.asyncio.sleep", + new=AsyncMock(side_effect=asyncio.CancelledError), + ): + with pytest.raises(asyncio.CancelledError): + await run_worker_forever(factory, {}, poll_interval_s=0) + + assert job.status == "failed" + assert job.last_error == "job_handler_unavailable" + assert untrusted_type not in job.last_error + assert job.finished_at is not None diff --git a/backend/tests/test_live_preflight_provider.py b/backend/tests/test_live_preflight_provider.py new file mode 100644 index 000000000..0afca98f4 --- /dev/null +++ b/backend/tests/test_live_preflight_provider.py @@ -0,0 +1,371 @@ +"""Concrete stored-target live-preflight provider boundary tests.""" + +from __future__ import annotations + +import asyncio +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.jobs.live_preflight_provider import ( + make_stored_postgres_durable_dry_run_attempt_handler, + make_stored_postgres_live_preflight_factory, + make_stored_postgres_migration_run_handler, +) +from app.jobs.migration_dry_run_worker import ( + GuardedLivePreflightTarget, + LivePreflightExecution, + LivePreflightRequest, + MigrationDryRunWorkerError, +) + + +def _request() -> LivePreflightRequest: + """Return one exact identifier-only live-preflight request.""" + + return LivePreflightRequest( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=2, + expected_state_version=7, + ) + + +@pytest.mark.asyncio +async def test_composition_binds_one_metadata_factory_to_provider_and_attempt() -> None: + """Prevent durable metadata and live-target authority from diverging.""" + + session_factory = MagicMock() + sandbox_factory = MagicMock() + live_factory = MagicMock() + delegate = AsyncMock() + signal_claim = MagicMock() + attempt_claim = MagicMock() + + with patch( + "app.jobs.live_preflight_provider." + "make_stored_postgres_live_preflight_factory", + return_value=live_factory, + ) as make_provider, patch( + "app.jobs.live_preflight_provider." + "make_durable_dry_run_attempt_handler", + return_value=delegate, + ) as make_delegate: + handler = make_stored_postgres_durable_dry_run_attempt_handler( + session_factory, + sandbox_factory, + preflight_stage_timeout_seconds=12.0, + connect_timeout_seconds=2.5, + ) + await handler(session_factory, signal_claim, attempt_claim) + + with pytest.raises( + MigrationDryRunWorkerError, + match="migration dry-run composition is invalid", + ): + await handler(MagicMock(), signal_claim, attempt_claim) + + make_provider.assert_called_once_with( + session_factory, connect_timeout_seconds=2.5 + ) + make_delegate.assert_called_once_with( + sandbox_factory, + live_factory, + lock_timeout_ms=1_000, + sandbox_statement_timeout_ms=30_000, + preflight_statement_timeout_ms=5_000, + sandbox_stage_timeout_seconds=300.0, + preflight_stage_timeout_seconds=12.0, + ) + delegate.assert_awaited_once_with( + session_factory, signal_claim, attempt_claim + ) + + +def test_consumer_composition_binds_attempt_leases_to_stored_provider() -> None: + """Expose one bounded handler without granting startup or apply authority.""" + + session_factory = MagicMock() + sandbox_factory = MagicMock() + attempt_handler = MagicMock() + consumer_handler = MagicMock() + + with patch( + "app.jobs.live_preflight_provider." + "make_stored_postgres_durable_dry_run_attempt_handler", + return_value=attempt_handler, + ) as make_attempt_handler, patch( + "app.jobs.live_preflight_provider." + "make_attempt_bound_migration_run_handler", + return_value=consumer_handler, + ) as make_consumer_handler: + handler = make_stored_postgres_migration_run_handler( + session_factory, + sandbox_factory, + worker_identity="worker-a", + attempt_lease_seconds=45, + heartbeat_interval_s=10.0, + preflight_stage_timeout_seconds=12.0, + connect_timeout_seconds=2.5, + ) + + assert handler is consumer_handler + make_attempt_handler.assert_called_once_with( + session_factory, + sandbox_factory, + lock_timeout_ms=1_000, + sandbox_statement_timeout_ms=30_000, + preflight_statement_timeout_ms=5_000, + sandbox_stage_timeout_seconds=300.0, + preflight_stage_timeout_seconds=12.0, + connect_timeout_seconds=2.5, + ) + make_consumer_handler.assert_called_once_with( + attempt_handler, + worker_identity="worker-a", + attempt_lease_seconds=45, + heartbeat_interval_s=10.0, + ) + + +@pytest.mark.parametrize( + "connect_timeout_seconds", + [0.0, -1.0, float("inf"), float("-inf"), float("nan"), 60.0001], +) +def test_provider_rejects_unbounded_connect_timeout( + connect_timeout_seconds: float, +) -> None: + """Keep connection acquisition finite, positive, and operationally bounded.""" + + with pytest.raises(ValueError, match="connect timeout"): + make_stored_postgres_live_preflight_factory( + MagicMock(), connect_timeout_seconds=connect_timeout_seconds + ) + + +@pytest.mark.asyncio +async def test_provider_binds_guarded_target_to_same_connection_capture() -> None: + """Decrypt only guarded material and scope capture to its exact connection.""" + + request = _request() + snapshot_uuid = uuid.uuid4() + target = GuardedLivePreflightTarget( + b"encrypted-target", + b"twelve-bytes", + snapshot_uuid, + "tenant$scope", + ) + metadata_session = object() + session_context = AsyncMock() + session_context.__aenter__.return_value = metadata_session + session_factory = MagicMock(return_value=session_context) + connection = SimpleNamespace(close=AsyncMock()) + captured = {"snapshot_contract_version": "postgresql/v1"} + + with patch( + "app.jobs.live_preflight_provider.load_guarded_live_preflight_target", + new=AsyncMock(return_value=target), + ) as load_target, patch( + "app.jobs.live_preflight_provider.decrypt_text", + return_value="postgresql://user:secret@db.example.test/app", + ) as decrypt, patch( + "app.jobs.live_preflight_provider.connect_guarded_postgres", + new=AsyncMock(return_value=connection), + ) as connect, patch( + "app.jobs.live_preflight_provider.capture_postgres_snapshot", + new=AsyncMock(return_value=captured), + ) as capture: + factory = make_stored_postgres_live_preflight_factory( + session_factory, connect_timeout_seconds=2.5 + ) + async with factory(request) as execution: + assert isinstance(execution, LivePreflightExecution) + assert execution.connection is connection + assert await execution.capture_snapshot(connection) == captured + with pytest.raises( + MigrationDryRunWorkerError, + match="live-preflight capture connection is invalid", + ): + await execution.capture_snapshot(object()) + + assert load_target.await_count == 2 + load_target.assert_has_awaits( + [ + ((metadata_session, request),), + ((metadata_session, request),), + ] + ) + decrypt.assert_called_once_with(b"encrypted-target", b"twelve-bytes") + connect.assert_awaited_once_with( + "postgresql://user:secret@db.example.test/app", timeout=2.5 + ) + capture.assert_awaited_once_with(connection, "tenant$scope") + connection.close.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_provider_revalidates_exact_target_after_connection_open() -> None: + """Close before reads when guarded metadata changes during acquisition.""" + + request = _request() + snapshot_uuid = uuid.uuid4() + initial = GuardedLivePreflightTarget( + b"encrypted-target", + b"twelve-bytes", + snapshot_uuid, + "tenant$scope", + ) + changed = GuardedLivePreflightTarget( + b"changed-encrypted-target", + b"twelve-bytes", + snapshot_uuid, + "tenant$scope", + ) + session_context = AsyncMock() + session_context.__aenter__.return_value = object() + session_factory = MagicMock(return_value=session_context) + connection = SimpleNamespace(close=AsyncMock()) + + with patch( + "app.jobs.live_preflight_provider.load_guarded_live_preflight_target", + new=AsyncMock(side_effect=(initial, changed)), + ) as load_target, patch( + "app.jobs.live_preflight_provider.decrypt_text", + return_value="postgresql://user:secret@db.example.test/app", + ), patch( + "app.jobs.live_preflight_provider.connect_guarded_postgres", + new=AsyncMock(return_value=connection), + ), patch( + "app.jobs.live_preflight_provider.capture_postgres_snapshot", + new=AsyncMock(), + ) as capture: + factory = make_stored_postgres_live_preflight_factory(session_factory) + with pytest.raises(MigrationDryRunWorkerError) as caught: + async with factory(request): + raise AssertionError("changed target must not be yielded") + + assert str(caught.value) == "migration live-preflight provider failed" + assert load_target.await_count == 2 + capture.assert_not_awaited() + connection.close.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_provider_sanitizes_decrypt_and_connect_failures() -> None: + """Do not reflect credential or driver detail from acquisition failures.""" + + request = _request() + target = GuardedLivePreflightTarget( + b"encrypted-secret-marker", + b"twelve-bytes", + uuid.uuid4(), + None, + ) + session_context = AsyncMock() + session_context.__aenter__.return_value = object() + session_factory = MagicMock(return_value=session_context) + + for failing_dependency, failure in ( + ("decrypt_text", ValueError("encrypted-secret-marker")), + ( + "connect_guarded_postgres", + RuntimeError("postgresql://user:secret@host/app"), + ), + ): + decrypt = MagicMock(return_value="postgresql://user:secret@host/app") + connect = AsyncMock(return_value=SimpleNamespace(close=AsyncMock())) + if failing_dependency == "decrypt_text": + decrypt.side_effect = failure + else: + connect.side_effect = failure + with patch( + "app.jobs.live_preflight_provider.load_guarded_live_preflight_target", + new=AsyncMock(return_value=target), + ), patch( + "app.jobs.live_preflight_provider.decrypt_text", new=decrypt + ), patch( + "app.jobs.live_preflight_provider.connect_guarded_postgres", + new=connect, + ): + factory = make_stored_postgres_live_preflight_factory( + session_factory + ) + with pytest.raises(MigrationDryRunWorkerError) as caught: + async with factory(request): + raise AssertionError("provider must not yield") + + assert str(caught.value) == "migration live-preflight provider failed" + assert "secret" not in str(caught.value) + + +@pytest.mark.asyncio +async def test_provider_sanitizes_metadata_context_failures() -> None: + """Do not reflect a metadata context failure that reuses worker errors.""" + + request = _request() + target = GuardedLivePreflightTarget( + b"encrypted-target", + b"twelve-bytes", + uuid.uuid4(), + None, + ) + session_context = AsyncMock() + session_context.__aenter__.return_value = object() + session_context.__aexit__.side_effect = MigrationDryRunWorkerError( + "postgresql://user:secret@metadata.example/app" + ) + session_factory = MagicMock(return_value=session_context) + + with patch( + "app.jobs.live_preflight_provider.load_guarded_live_preflight_target", + new=AsyncMock(return_value=target), + ), patch( + "app.jobs.live_preflight_provider.decrypt_text" + ) as decrypt: + factory = make_stored_postgres_live_preflight_factory(session_factory) + with pytest.raises(MigrationDryRunWorkerError) as caught: + async with factory(request): + raise AssertionError("provider must not yield") + + assert str(caught.value) == "migration live-preflight provider failed" + assert "secret" not in str(caught.value) + decrypt.assert_not_called() + + +@pytest.mark.asyncio +async def test_provider_propagates_cancellation_and_closes_target() -> None: + """Preserve process control while closing an acquired target connection.""" + + request = _request() + target = GuardedLivePreflightTarget( + b"encrypted-target", + b"twelve-bytes", + uuid.uuid4(), + None, + ) + session_context = AsyncMock() + session_context.__aenter__.return_value = object() + session_factory = MagicMock(return_value=session_context) + connection = SimpleNamespace(close=AsyncMock()) + + with patch( + "app.jobs.live_preflight_provider.load_guarded_live_preflight_target", + new=AsyncMock(return_value=target), + ), patch( + "app.jobs.live_preflight_provider.decrypt_text", + return_value="postgresql://user:secret@host/app", + ), patch( + "app.jobs.live_preflight_provider.connect_guarded_postgres", + new=AsyncMock(return_value=connection), + ): + factory = make_stored_postgres_live_preflight_factory(session_factory) + with pytest.raises(asyncio.CancelledError): + async with factory(request): + raise asyncio.CancelledError + + connection.close.assert_awaited_once_with() diff --git a/backend/tests/test_migration_dispatch_lifecycle.py b/backend/tests/test_migration_dispatch_lifecycle.py new file mode 100644 index 000000000..1eb7e551c --- /dev/null +++ b/backend/tests/test_migration_dispatch_lifecycle.py @@ -0,0 +1,110 @@ +"""Application lifecycle coverage for the identifier-only dispatch relay.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app import main as main_app +from app.settings import settings + + +def _blocking_task( + started: asyncio.Event, cancelled: asyncio.Event +) -> Callable[..., Awaitable[None]]: + async def run(*_args: object, **_kwargs: object) -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + return run + + +@pytest.mark.asyncio +async def test_lifespan_starts_and_stops_opted_in_dispatch_relay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Explicit Valkey relay configuration owns a cancellable lifecycle task.""" + + worker_started, worker_cancelled = asyncio.Event(), asyncio.Event() + relay_started, relay_cancelled = asyncio.Event(), asyncio.Event() + relay_run = AsyncMock(side_effect=_blocking_task(relay_started, relay_cancelled)) + monkeypatch.setattr(settings, "migration_dispatch_relay_enabled", True) + monkeypatch.setattr(settings, "migration_dispatch_relay_poll_interval_seconds", 0.25) + + with patch.object(main_app, "valkey_queue_enabled", return_value=True), patch.object( + main_app, + "run_worker_forever", + new=_blocking_task(worker_started, worker_cancelled), + ), patch.object( + main_app, + "run_migration_dispatch_relay_forever", + new=relay_run, + ), patch.object( + main_app, + "get_pooler_detection", + new=AsyncMock( + return_value=SimpleNamespace( + kind=SimpleNamespace(value="none"), detected=False + ) + ), + ): + async with main_app.lifespan(main_app.app): + await asyncio.wait_for(worker_started.wait(), timeout=1) + await asyncio.wait_for(relay_started.wait(), timeout=1) + + await asyncio.wait_for(worker_cancelled.wait(), timeout=1) + await asyncio.wait_for(relay_cancelled.wait(), timeout=1) + assert relay_run.call_args.kwargs == {"poll_interval_s": 0.25} + + +@pytest.mark.asyncio +async def test_lifespan_rejects_relay_without_valkey( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An enabled relay cannot silently poll an unavailable signal backend.""" + + monkeypatch.setattr(settings, "migration_dispatch_relay_enabled", True) + with patch.object(main_app, "valkey_queue_enabled", return_value=False): + with pytest.raises(RuntimeError, match="requires the Valkey queue backend"): + async with main_app.lifespan(main_app.app): + pass + + +@pytest.mark.asyncio +async def test_lifespan_leaves_dispatch_relay_disabled_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The new lifecycle is opt-in until a deployment configures Valkey.""" + + worker_started, worker_cancelled = asyncio.Event(), asyncio.Event() + monkeypatch.setattr(settings, "migration_dispatch_relay_enabled", False) + with patch.object( + main_app, + "run_worker_forever", + new=_blocking_task(worker_started, worker_cancelled), + ), patch.object( + main_app, + "run_migration_dispatch_relay_forever", + new=AsyncMock(), + create=True, + ) as relay, patch.object( + main_app, + "get_pooler_detection", + new=AsyncMock( + return_value=SimpleNamespace( + kind=SimpleNamespace(value="none"), detected=False + ) + ), + ): + async with main_app.lifespan(main_app.app): + await asyncio.wait_for(worker_started.wait(), timeout=1) + + await asyncio.wait_for(worker_cancelled.wait(), timeout=1) + relay.assert_not_awaited() diff --git a/backend/tests/test_migration_dispatch_relay.py b/backend/tests/test_migration_dispatch_relay.py new file mode 100644 index 000000000..c46d1338e --- /dev/null +++ b/backend/tests/test_migration_dispatch_relay.py @@ -0,0 +1,222 @@ +"""Identifier-only migration dispatch relay regressions.""" + +from __future__ import annotations + +import asyncio +import datetime as dt +import uuid +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.forward.migration_run import MigrationDispatchClaim +from app.jobs.migration_dispatch_relay import ( + MigrationDispatchSignalUnavailable, + publish_one_migration_dispatch, + run_migration_dispatch_relay_forever, +) + + +def _claim() -> MigrationDispatchClaim: + return MigrationDispatchClaim( + migration_run_dispatch_uuid=uuid.uuid4(), + migration_run_uuid=uuid.uuid4(), + dispatch_kind="isolated_dry_run", + attempt_count=1, + ) + + +def _session_factory() -> MagicMock: + """Return a factory that records a fresh session/transaction per call.""" + + sessions: list[MagicMock] = [] + transactions: list[MagicMock] = [] + + def create_session() -> MagicMock: + session = MagicMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + transaction = MagicMock() + transaction.__aenter__ = AsyncMock(return_value=None) + transaction.__aexit__ = AsyncMock(return_value=False) + session.begin.return_value = transaction + sessions.append(session) + transactions.append(transaction) + return session + + factory = MagicMock(side_effect=create_session) + factory.sessions = sessions + factory.transactions = transactions + return factory + + +@pytest.mark.asyncio +async def test_empty_outbox_does_not_touch_queue_or_transaction() -> None: + """An empty due outbox is a bounded no-op.""" + + session_double = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + session = cast(AsyncSession, session_double) + with patch( + "app.jobs.migration_dispatch_relay.claim_one_migration_dispatch", + new=AsyncMock(return_value=None), + ), patch( + "app.jobs.migration_dispatch_relay.enqueue_migration_run_signal", + new=AsyncMock(), + ) as enqueue: + assert await publish_one_migration_dispatch(session) is None + + enqueue.assert_not_awaited() + session_double.commit.assert_not_awaited() + session_double.rollback.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_relay_publishes_only_run_identity_then_marks_exact_claim() -> None: + """The queue signal precedes acknowledgement in the caller transaction.""" + + claim = _claim() + session_double = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + session = cast(AsyncSession, session_double) + with patch( + "app.jobs.migration_dispatch_relay.claim_one_migration_dispatch", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_dispatch_relay.enqueue_migration_run_signal", + new=AsyncMock(return_value=True), + ) as enqueue, patch( + "app.jobs.migration_dispatch_relay.mark_migration_dispatch_published", + new=AsyncMock(), + ) as mark: + published = await publish_one_migration_dispatch(session) + + assert published == claim + enqueue.assert_awaited_once() + assert enqueue.await_args is not None + assert enqueue.await_args.args == (claim.migration_run_uuid, None) + mark.assert_awaited_once_with(session, claim=claim) + session_double.commit.assert_not_awaited() + session_double.rollback.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_relay_uses_one_explicit_clock_for_claim_and_acknowledgement() -> None: + """One caller clock binds due selection to exact-attempt acknowledgement.""" + + claim = _claim() + now = dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc) + session_double = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + session = cast(AsyncSession, session_double) + with patch( + "app.jobs.migration_dispatch_relay.claim_one_migration_dispatch", + new=AsyncMock(return_value=claim), + ) as claim_one, patch( + "app.jobs.migration_dispatch_relay.enqueue_migration_run_signal", + new=AsyncMock(return_value=True), + ) as enqueue, patch( + "app.jobs.migration_dispatch_relay.mark_migration_dispatch_published", + new=AsyncMock(), + ) as mark: + assert await publish_one_migration_dispatch(session, now=now) == claim + + claim_one.assert_awaited_once_with(session, now=now) + enqueue.assert_awaited_once_with(claim.migration_run_uuid, now) + mark.assert_awaited_once_with(session, claim=claim, now=now) + session_double.commit.assert_not_awaited() + session_double.rollback.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_relay_failure_remains_pending_by_requiring_caller_rollback() -> None: + """Unavailable queue publication cannot acknowledge the outbox row.""" + + claim = _claim() + session_double = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + session = cast(AsyncSession, session_double) + with patch( + "app.jobs.migration_dispatch_relay.claim_one_migration_dispatch", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_dispatch_relay.enqueue_migration_run_signal", + new=AsyncMock(return_value=False), + ), patch( + "app.jobs.migration_dispatch_relay.mark_migration_dispatch_published", + new=AsyncMock(), + ) as mark: + with pytest.raises( + MigrationDispatchSignalUnavailable, + match="signal unavailable", + ): + await publish_one_migration_dispatch(session) + + mark.assert_not_awaited() + session_double.commit.assert_not_awaited() + session_double.rollback.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_scheduled_relay_commits_each_claim_and_polls_after_empty() -> None: + """The lifecycle drains one transaction at a time and idles when empty.""" + + claim = _claim() + factory = _session_factory() + with patch( + "app.jobs.migration_dispatch_relay.publish_one_migration_dispatch", + new=AsyncMock(side_effect=[claim, None]), + ) as publish, patch( + "app.jobs.migration_dispatch_relay.asyncio.sleep", + new=AsyncMock(side_effect=asyncio.CancelledError), + ) as sleep: + with pytest.raises(asyncio.CancelledError): + await run_migration_dispatch_relay_forever(factory, poll_interval_s=0.25) + + assert publish.await_count == 2 + assert factory.call_count == 2 + first_session = publish.await_args_list[0].args[0] + second_session = publish.await_args_list[1].args[0] + assert first_session is not second_session + assert factory.sessions == [first_session, second_session] + assert all(session.__aexit__.await_count == 1 for session in factory.sessions) + assert all(session.begin.call_count == 1 for session in factory.sessions) + assert all( + transaction.__aexit__.await_count == 1 + for transaction in factory.transactions + ) + sleep.assert_awaited_once_with(0.25) + + +@pytest.mark.asyncio +async def test_scheduled_relay_rolls_back_failed_publish_and_logs_fixed_code( + caplog: pytest.LogCaptureFixture, +) -> None: + """Publication failure is rolled back without logging exception contents.""" + + forbidden_log_marker = "forbidden-log-marker-7f42" + factory = _session_factory() + with patch( + "app.jobs.migration_dispatch_relay.publish_one_migration_dispatch", + new=AsyncMock(side_effect=[RuntimeError(forbidden_log_marker), None]), + ), patch( + "app.jobs.migration_dispatch_relay.asyncio.sleep", + new=AsyncMock(side_effect=[None, asyncio.CancelledError]), + ): + with pytest.raises(asyncio.CancelledError): + await run_migration_dispatch_relay_forever(factory, poll_interval_s=0.5) + + first_exit_args = factory.transactions[0].__aexit__.await_args_list[0] + assert first_exit_args.args[0] is RuntimeError + assert "migration_dispatch_relay_iteration_failed" in caplog.text + assert forbidden_log_marker not in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("interval", [0, -1, float("inf")]) +async def test_scheduled_relay_rejects_unbounded_poll_interval(interval: float) -> None: + """A misconfigured lifecycle cannot become a busy loop.""" + + with pytest.raises(ValueError, match="interval must be between"): + await run_migration_dispatch_relay_forever( + _session_factory(), poll_interval_s=interval + ) diff --git a/backend/tests/test_migration_dry_run_worker_failures.py b/backend/tests/test_migration_dry_run_worker_failures.py new file mode 100644 index 000000000..3fc2d04cb --- /dev/null +++ b/backend/tests/test_migration_dry_run_worker_failures.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import asyncio +import uuid +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.jobs.migration_dry_run_worker import ( + IsolatedSandboxExecution, + LivePreflightExecution, + MigrationDryRunWorkerError, + _MigrationDryRunWork, + make_durable_dry_run_attempt_handler, +) + + +def _work( + *, state: str = "sandbox_running", state_version: int = 2 +) -> _MigrationDryRunWork: + return _MigrationDryRunWork( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=3, + state=state, + state_version=state_version, + postgresql_major=16, + base_digest="a" * 64, + target_digest="b" * 64, + plan_digest="c" * 64, + plan_json={"statements": []}, + ) + + +@pytest.mark.asyncio +async def test_handler_propagates_cancellation_and_closes_sandbox_lease() -> None: + """Propagate lease cancellation while still closing the sandbox capability.""" + + work = _work() + cleaned = False + + @asynccontextmanager + async def sandbox_factory(_request): + nonlocal cleaned + try: + yield IsolatedSandboxExecution(object(), AsyncMock()) + finally: + cleaned = True + + handler = make_durable_dry_run_attempt_handler(sandbox_factory, MagicMock()) + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_isolated_dry_run", + new=AsyncMock(side_effect=asyncio.CancelledError), + ): + with pytest.raises(asyncio.CancelledError): + await handler(MagicMock(), signal_claim, attempt_claim) + assert cleaned + + +@pytest.mark.asyncio +async def test_sandbox_stage_timeout_is_sanitized_and_closes_capability() -> None: + """Bound provider/executor hangs without leaking capability details.""" + + work = _work() + cleaned = False + + @asynccontextmanager + async def sandbox_factory(_request): + nonlocal cleaned + try: + yield IsolatedSandboxExecution(object(), AsyncMock()) + finally: + cleaned = True + + async def hang(*_args, **_kwargs): + await asyncio.Event().wait() + + handler = make_durable_dry_run_attempt_handler( + sandbox_factory, + MagicMock(), + sandbox_stage_timeout_seconds=0.01, + ) + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_isolated_dry_run", + new=AsyncMock(side_effect=hang), + ): + with pytest.raises(MigrationDryRunWorkerError) as caught: + await handler(MagicMock(), signal_claim, attempt_claim) + + assert str(caught.value) == "isolated dry-run stage failed" + assert caught.value.__cause__ is None + assert cleaned + + +@pytest.mark.asyncio +async def test_non_cooperative_sandbox_outlives_cancellation_deadline() -> None: + """Prove an in-process deadline cannot forcibly stop a provider task.""" + + work = _work() + cancellation_requested = asyncio.Event() + release_provider = asyncio.Event() + cleaned = False + + @asynccontextmanager + async def sandbox_factory(_request): + nonlocal cleaned + try: + yield IsolatedSandboxExecution(object(), AsyncMock()) + finally: + cleaned = True + + async def suppress_cancellation(*_args, **_kwargs): + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_requested.set() + await release_provider.wait() + raise RuntimeError("non-cooperative provider released") from None + + handler = make_durable_dry_run_attempt_handler( + sandbox_factory, + MagicMock(), + sandbox_stage_timeout_seconds=0.01, + ) + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_isolated_dry_run", + new=AsyncMock(side_effect=suppress_cancellation), + ): + handler_task = asyncio.create_task( + handler(MagicMock(), signal_claim, attempt_claim) + ) + await asyncio.wait_for(cancellation_requested.wait(), timeout=1) + assert not handler_task.done() + assert not cleaned + + release_provider.set() + with pytest.raises(MigrationDryRunWorkerError) as caught: + await handler_task + + assert str(caught.value) == "isolated dry-run stage failed" + assert caught.value.__cause__ is None + assert cleaned + + +@pytest.mark.asyncio +async def test_live_preflight_failure_is_sanitized_and_closes_reader() -> None: + """Close the target reader and discard read-only provider details.""" + + work = _work(state="live_preflight_running", state_version=9) + cleaned = False + + def sandbox_factory(_request): + raise AssertionError("sandbox must not run") + + @asynccontextmanager + async def live_factory(_request): + nonlocal cleaned + try: + yield LivePreflightExecution(object(), AsyncMock()) + finally: + cleaned = True + + secret = "opaque live provider detail" + handler = make_durable_dry_run_attempt_handler(sandbox_factory, live_factory) + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker._refresh_live_stage", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_bound_live_preflight", + new=AsyncMock(side_effect=RuntimeError(secret)), + ): + with pytest.raises(MigrationDryRunWorkerError) as caught: + await handler(MagicMock(), signal_claim, attempt_claim) + assert str(caught.value) == "live preflight stage failed" + assert secret not in repr(caught.value) + assert caught.value.__cause__ is None + assert cleaned + + +@pytest.mark.asyncio +async def test_live_stage_timeout_is_sanitized_and_closes_capability() -> None: + """Bound live-reader hangs and close the read-only capability on timeout.""" + + work = _work(state="live_preflight_running", state_version=9) + cleaned = False + + def sandbox_factory(_request): + raise AssertionError("sandbox must not run") + + @asynccontextmanager + async def live_factory(_request): + nonlocal cleaned + try: + yield LivePreflightExecution(object(), AsyncMock()) + finally: + cleaned = True + + async def hang(*_args, **_kwargs): + await asyncio.Event().wait() + + handler = make_durable_dry_run_attempt_handler( + sandbox_factory, + live_factory, + preflight_stage_timeout_seconds=0.01, + ) + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker._refresh_live_stage", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_bound_live_preflight", + new=AsyncMock(side_effect=hang), + ): + with pytest.raises(MigrationDryRunWorkerError) as caught: + await handler(MagicMock(), signal_claim, attempt_claim) + + assert str(caught.value) == "live preflight stage failed" + assert caught.value.__cause__ is None + assert cleaned + + +@pytest.mark.asyncio +async def test_handler_rechecks_cancellation_before_live_target_io() -> None: + """Do not open a target capability after the sandbox stage loses CAS.""" + + work = _work() + live_factory = MagicMock() + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + + @asynccontextmanager + async def sandbox_factory(_request): + yield IsolatedSandboxExecution(object(), AsyncMock()) + + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_isolated_dry_run", + new=AsyncMock(return_value={"converged": True}), + ), patch( + "app.jobs.migration_dry_run_worker._complete_isolated_stage", + new=AsyncMock( + return_value=SimpleNamespace( + state="live_preflight_running", state_version=3 + ) + ), + ), patch( + "app.jobs.migration_dry_run_worker._refresh_live_stage", + new=AsyncMock( + side_effect=MigrationDryRunWorkerError( + "migration dry-run metadata contract is invalid" + ) + ), + ): + handler = make_durable_dry_run_attempt_handler( + sandbox_factory, live_factory + ) + with pytest.raises( + MigrationDryRunWorkerError, match="metadata contract" + ): + await handler(MagicMock(), signal_claim, attempt_claim) + + live_factory.assert_not_called() + + +def test_handler_rejects_unsafe_configuration_before_factory_use() -> None: + """Reject unsafe timeout and provider configuration before capability use.""" + + sandbox_factory = MagicMock() + live_factory = MagicMock() + for kwargs, label in ( + ({"lock_timeout_ms": True}, "lock timeout"), + ({"lock_timeout_ms": 0}, "lock timeout"), + ({"sandbox_statement_timeout_ms": 0}, "statement timeout"), + ({"preflight_statement_timeout_ms": 0}, "live-preflight"), + ({"sandbox_stage_timeout_seconds": 0}, "sandbox stage timeout"), + ({"preflight_stage_timeout_seconds": 0}, "preflight stage timeout"), + ): + with pytest.raises(ValueError, match=label): + make_durable_dry_run_attempt_handler( + sandbox_factory, live_factory, **kwargs + ) + for invalid in (None, 7): + with pytest.raises(ValueError, match="capability factory"): + make_durable_dry_run_attempt_handler( + invalid, live_factory # type: ignore[arg-type] + ) + with pytest.raises(ValueError, match="capability factory"): + make_durable_dry_run_attempt_handler( + sandbox_factory, invalid # type: ignore[arg-type] + ) + sandbox_factory.assert_not_called() + live_factory.assert_not_called() + + +@pytest.mark.asyncio +async def test_handler_rejects_invalid_live_terminal_transition() -> None: + """Reject any terminal state not derived by the live-preflight contract.""" + + work = _work(state="live_preflight_running", state_version=4) + + def sandbox_factory(_request): + raise AssertionError("sandbox must not run") + + @asynccontextmanager + async def live_factory(_request): + yield LivePreflightExecution(object(), AsyncMock()) + + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker._refresh_live_stage", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_bound_live_preflight", + new=AsyncMock(return_value={"checks": []}), + ), patch( + "app.jobs.migration_dry_run_worker._complete_live_stage", + new=AsyncMock( + return_value=SimpleNamespace(state="applying", state_version=5) + ), + ): + handler = make_durable_dry_run_attempt_handler( + sandbox_factory, live_factory + ) + with pytest.raises( + MigrationDryRunWorkerError, + match="live preflight completion is invalid", + ): + await handler(MagicMock(), signal_claim, attempt_claim) diff --git a/backend/tests/test_migration_dry_run_worker_metadata.py b/backend/tests/test_migration_dry_run_worker_metadata.py new file mode 100644 index 000000000..24a52fb54 --- /dev/null +++ b/backend/tests/test_migration_dry_run_worker_metadata.py @@ -0,0 +1,403 @@ +"""Durable dry-run worker metadata contract tests.""" + +from __future__ import annotations + +import datetime as dt +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy.dialects import postgresql + +from app.jobs.migration_dry_run_worker import ( + GuardedLivePreflightTarget, + LivePreflightRequest, + MigrationDryRunWorkerError, + _MigrationDryRunWork, + _make_work, + guard_live_preflight_handoff, + load_guarded_live_preflight_target, +) + + +def _work( + *, state: str = "sandbox_running", state_version: int = 2 +) -> _MigrationDryRunWork: + """Build deterministic durable work metadata for persistence tests.""" + + return _MigrationDryRunWork( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=3, + state=state, + state_version=state_version, + postgresql_major=16, + base_digest="a" * 64, + target_digest="b" * 64, + plan_digest="c" * 64, + plan_json={"statements": []}, + ) + + +def test_make_work_rejects_tampered_or_cancelled_metadata() -> None: + """Reject cancelled or integrity-invalid durable metadata.""" + + run_uuid = uuid.uuid4() + plan_uuid = uuid.uuid4() + project_uuid = uuid.uuid4() + plan_digest = "c" * 64 + now = dt.datetime(2026, 8, 12, tzinfo=dt.timezone.utc) + run = SimpleNamespace( + migration_run_uuid=run_uuid, + project_space_uuid=project_uuid, + migration_plan_uuid=plan_uuid, + run_kind="dry_run", + state="sandbox_running", + state_version=2, + plan_digest=plan_digest, + cancellation_requested=False, + ) + plan = SimpleNamespace( + migration_plan_uuid=plan_uuid, + project_space_uuid=project_uuid, + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + statement_digest=plan_digest, + compiler_version="pg-erd-forward/v1", + base_digest="a" * 64, + target_digest="b" * 64, + expires_at=now + dt.timedelta(hours=1), + plan_json={ + "compiler_version": "pg-erd-forward/v1", + "postgresql_major": 16, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "plan_digest": plan_digest, + "can_dry_run": True, + "blockers": [], + }, + ) + claim = SimpleNamespace( + migration_run_attempt_uuid=uuid.uuid4(), + migration_run_uuid=run_uuid, + attempt_number=1, + acquired_state_version=2, + ) + with patch( + "app.jobs.migration_dry_run_worker_contract.verify_migration_plan_digest", + return_value=True, + ): + work = _make_work(run, plan, claim, now=now) + assert work.plan_json is not plan.plan_json + run.cancellation_requested = True + with pytest.raises(MigrationDryRunWorkerError, match="metadata contract"): + _make_work(run, plan, claim, now=now) + + +def _transactional_session_factory(*scalar_values: object) -> MagicMock: + """Build a recording transaction factory with ordered scalar results.""" + + session = MagicMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + transaction = MagicMock() + transaction.__aenter__ = AsyncMock(return_value=None) + transaction.__aexit__ = AsyncMock(return_value=False) + session.begin.return_value = transaction + session.scalar = AsyncMock(side_effect=scalar_values) + factory = MagicMock(return_value=session) + factory.session = session + factory.transaction = transaction + return factory + + +@pytest.mark.asyncio +async def test_live_preflight_handoff_guard_is_one_exact_fail_closed_query() -> None: + """Bind provider access to one fresh exact run/plan/attempt observation.""" + + now = dt.datetime(2026, 8, 14, 12, tzinfo=dt.timezone.utc) + request = LivePreflightRequest( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=3, + expected_state_version=9, + ) + session = SimpleNamespace( + scalar=AsyncMock(return_value=request.migration_run_attempt_uuid) + ) + + await guard_live_preflight_handoff(session, request, now=now) + + session.scalar.assert_awaited_once() + statement = str( + session.scalar.await_args.args[0].compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + for expected in ( + str(request.migration_run_uuid), + str(request.migration_plan_uuid), + str(request.project_space_uuid), + str(request.db_connection_uuid), + str(request.migration_run_attempt_uuid), + "migration_run.run_kind = 'dry_run'", + "migration_run.state = 'live_preflight_running'", + "migration_run.state_version = 9", + "migration_run.cancellation_requested IS false", + "migration_run_attempt.attempt_number = 3", + "migration_run_attempt.status = 'active'", + "migration_run_attempt.lease_expires_at >", + "migration_plan.expires_at >", + "migration_plan.statement_digest = migration_run.plan_digest", + ): + assert expected in statement + assert "FOR UPDATE" not in statement + + +@pytest.mark.asyncio +async def test_live_preflight_handoff_guard_rejects_invalid_or_stale_input() -> None: + """Reject malformed input before I/O and any non-matching fresh query.""" + + now = dt.datetime(2026, 8, 14, 12, tzinfo=dt.timezone.utc) + request = LivePreflightRequest( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=1, + expected_state_version=4, + ) + stale_session = SimpleNamespace(scalar=AsyncMock(return_value=None)) + + with pytest.raises(MigrationDryRunWorkerError, match="handoff is invalid"): + await guard_live_preflight_handoff(stale_session, request, now=now) + stale_session.scalar.assert_awaited_once() + + invalid_session = SimpleNamespace(scalar=AsyncMock()) + invalid_request = LivePreflightRequest( + **{ + **request.__dict__, + "expected_state_version": True, + } + ) + with pytest.raises(MigrationDryRunWorkerError, match="handoff is invalid"): + await guard_live_preflight_handoff( + invalid_session, invalid_request, now=now + ) + with pytest.raises(MigrationDryRunWorkerError, match="handoff is invalid"): + await guard_live_preflight_handoff( + invalid_session, + request, + now=now.replace(tzinfo=None), + ) + for invalid_now in (True, False): + with pytest.raises( + MigrationDryRunWorkerError, match="handoff is invalid" + ): + await guard_live_preflight_handoff( + invalid_session, + request, + now=invalid_now, # type: ignore[arg-type] + ) + invalid_session.scalar.assert_not_awaited() + + secret = "postgresql://reader:secret@target/database" + failed_session = SimpleNamespace( + scalar=AsyncMock(side_effect=RuntimeError(secret)) + ) + with pytest.raises(MigrationDryRunWorkerError) as caught: + await guard_live_preflight_handoff(failed_session, request, now=now) + assert str(caught.value) == "migration live-preflight handoff is invalid" + assert secret not in str(caught.value) + + +@pytest.mark.asyncio +async def test_guarded_live_target_uses_one_exact_secret_safe_query() -> None: + """Release encrypted target material only for the exact live attempt.""" + + now = dt.datetime(2026, 8, 15, 1, tzinfo=dt.timezone.utc) + request = LivePreflightRequest( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=2, + expected_state_version=7, + ) + ciphertext = b"encrypted-target-dsn" + nonce = b"twelve-bytes" + snapshot_uuid = uuid.uuid4() + result = MagicMock() + result.one_or_none.return_value = ( + ciphertext, + nonce, + snapshot_uuid, + "tenant$scope", + ) + session = SimpleNamespace(execute=AsyncMock(return_value=result)) + + target = await load_guarded_live_preflight_target( + session, request, now=now + ) + + assert target == GuardedLivePreflightTarget( + ciphertext, + nonce, + snapshot_uuid, + "tenant$scope", + ) + assert "encrypted-target-dsn" not in repr(target) + assert "twelve-bytes" not in repr(target) + assert "tenant$scope" not in repr(target) + session.execute.assert_awaited_once() + statement = str( + session.execute.await_args.args[0].compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + for expected in ( + str(request.migration_run_uuid), + str(request.migration_plan_uuid), + str(request.project_space_uuid), + str(request.db_connection_uuid), + str(request.migration_run_attempt_uuid), + "migration_run.run_kind = 'dry_run'", + "migration_run.state = 'live_preflight_running'", + "migration_run.state_version = 7", + "migration_run.cancellation_requested IS false", + "migration_run_attempt.attempt_number = 2", + "migration_run_attempt.status = 'active'", + "migration_run_attempt.lease_expires_at >", + "migration_plan.expires_at >", + "migration_plan.statement_digest = migration_run.plan_digest", + "db_connection.project_space_uuid", + "schema_snapshot.status = 'succeeded'", + "schema_snapshot.finished_at IS NOT NULL", + "schema_snapshot.project_space_uuid", + "schema_snapshot.db_connection_uuid", + "schema_snapshot.schema_snapshot_uuid = migration_plan.base_schema_snapshot_uuid", + ): + assert expected in statement + assert "FOR UPDATE" not in statement + + +@pytest.mark.asyncio +async def test_guarded_live_target_fails_closed_without_secret_reflection() -> None: + """Reject stale, malformed, or failed target resolution with one error.""" + + now = dt.datetime(2026, 8, 15, 1, tzinfo=dt.timezone.utc) + request = LivePreflightRequest( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=1, + expected_state_version=4, + ) + valid_snapshot_uuid = uuid.uuid4() + for row in ( + None, + (b"", b"twelve-bytes", valid_snapshot_uuid, None), + (b"ciphertext", b"short", valid_snapshot_uuid, None), + (b"ciphertext", b"twelve-bytes", None, None), + (b"ciphertext", b"twelve-bytes", valid_snapshot_uuid, "bad\x00scope"), + (b"ciphertext", b"twelve-bytes", valid_snapshot_uuid, "bad scope"), + ( + b"ciphertext", + b"twelve-bytes", + valid_snapshot_uuid, + "s" * 64, + ), + ): + result = MagicMock() + result.one_or_none.return_value = row + session = SimpleNamespace(execute=AsyncMock(return_value=result)) + with pytest.raises(MigrationDryRunWorkerError) as caught: + await load_guarded_live_preflight_target(session, request, now=now) + assert str(caught.value) == "migration live-preflight target is invalid" + + secret = "postgresql://reader:secret@target/database" + failed_session = SimpleNamespace( + execute=AsyncMock(side_effect=RuntimeError(secret)) + ) + with pytest.raises(MigrationDryRunWorkerError) as caught: + await load_guarded_live_preflight_target( + failed_session, request, now=now + ) + assert str(caught.value) == "migration live-preflight target is invalid" + assert secret not in str(caught.value) + + invalid_session = SimpleNamespace(execute=AsyncMock()) + invalid_request = LivePreflightRequest( + **{**request.__dict__, "expected_state_version": True} + ) + with pytest.raises(MigrationDryRunWorkerError, match="target is invalid"): + await load_guarded_live_preflight_target( + invalid_session, invalid_request, now=now + ) + invalid_session.execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_load_and_begin_commits_exact_queued_transition() -> None: + """Bind the queued transition to the exact durable attempt identity.""" + + from app.forward.migration_run import MigrationRunTransition + from app.jobs.migration_dry_run_worker import _load_and_begin + + work = _work(state="queued", state_version=7) + run = SimpleNamespace(migration_plan_uuid=work.migration_plan_uuid) + plan = object() + factory = _transactional_session_factory(run, plan) + claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=work.state_version, + ) + transition = MigrationRunTransition("sandbox_running", 8, None, None) + + query = MagicMock() + query.where.return_value = query + query.with_for_update.return_value = query + with patch( + "app.jobs.migration_dry_run_worker.select", + return_value=query, + ), patch( + "app.jobs.migration_dry_run_worker._make_work", + return_value=work, + ) as make_work, patch( + "app.jobs.migration_dry_run_worker.transition_migration_run", + new=AsyncMock(return_value=transition), + ) as transition_run: + loaded = await _load_and_begin(factory, claim) + + assert loaded.state == "sandbox_running" + assert loaded.state_version == 8 + assert factory.call_count == 1 + assert factory.transaction.__aexit__.await_count == 1 + make_work.assert_called_once() + transition_run.assert_awaited_once() + kwargs = transition_run.await_args.kwargs + assert kwargs["migration_run_uuid"] == work.migration_run_uuid + assert kwargs["expected_state_version"] == 7 + assert kwargs["next_state"] == "sandbox_running" + assert kwargs["event_type"] == "sandbox_started" + assert kwargs["evidence"] == { + "attempt_number": work.attempt_number, + "migration_run_attempt_uuid": str(work.migration_run_attempt_uuid), + } + assert kwargs["actor_user_uuid"] is None diff --git a/backend/tests/test_migration_dry_run_worker_stages.py b/backend/tests/test_migration_dry_run_worker_stages.py new file mode 100644 index 000000000..814f3d667 --- /dev/null +++ b/backend/tests/test_migration_dry_run_worker_stages.py @@ -0,0 +1,281 @@ +"""Durable dry-run worker stage orchestration contract tests.""" + +from __future__ import annotations + +import uuid +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.jobs.migration_dry_run_worker import ( + IsolatedSandboxExecution, + IsolatedSandboxRequest, + LivePreflightExecution, + LivePreflightRequest, + MigrationDryRunWorkerError, + _MigrationDryRunWork, + make_durable_dry_run_attempt_handler, +) + + +def _work( + *, state: str = "sandbox_running", state_version: int = 2 +) -> _MigrationDryRunWork: + """Build deterministic durable dry-run work metadata for stage tests.""" + + return _MigrationDryRunWork( + migration_run_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + project_space_uuid=uuid.uuid4(), + db_connection_uuid=uuid.uuid4(), + base_schema_snapshot_uuid=uuid.uuid4(), + migration_run_attempt_uuid=uuid.uuid4(), + attempt_number=3, + state=state, + state_version=state_version, + postgresql_major=16, + base_digest="a" * 64, + target_digest="b" * 64, + plan_digest="c" * 64, + plan_json={"statements": []}, + ) + + +@pytest.mark.asyncio +async def test_handler_runs_both_stages_through_capability_leases() -> None: + """Run sandbox and live-preflight cores through separate exact leases.""" + + work = _work() + sandbox_conn = object() + live_conn = object() + sandbox_capture = AsyncMock() + live_capture = AsyncMock() + order: list[str] = [] + sandbox_requests: list[IsolatedSandboxRequest] = [] + live_requests: list[LivePreflightRequest] = [] + + @asynccontextmanager + async def sandbox_factory(request): + sandbox_requests.append(request) + order.append("sandbox-enter") + try: + yield IsolatedSandboxExecution(sandbox_conn, sandbox_capture) + finally: + order.append("sandbox-exit") + + @asynccontextmanager + async def live_factory(request): + live_requests.append(request) + order.append("live-enter") + try: + yield LivePreflightExecution(live_conn, live_capture) + finally: + order.append("live-exit") + + sandbox_result = { + "postgresql_major": 16, + "statement_count": 0, + "base_digest": "a" * 64, + "target_digest": "b" * 64, + "converged": True, + } + preflight_result = { + "preconditions_passed": True, + "checks": [], + "observed_base_digest": "a" * 64, + "matches_plan_base": True, + } + + async def execute_sandbox(*args, **kwargs): + order.append("sandbox-execute") + assert args == (sandbox_conn, work.plan_json) + assert kwargs["expected_plan_digest"] == work.plan_digest + assert kwargs["capture_snapshot"] is sandbox_capture + return sandbox_result + + async def execute_preflight(*args, **kwargs): + order.append("live-execute") + assert args == (live_conn, work.plan_json) + assert kwargs["capture_snapshot"] is live_capture + return preflight_result + + async def complete_sandbox(_factory, actual, result): + order.append("sandbox-complete") + assert actual == work + assert result == sandbox_result + return SimpleNamespace(state="live_preflight_running", state_version=3) + + async def complete_live(_factory, actual, result): + order.append("live-complete") + assert actual.state == "live_preflight_running" + assert result == preflight_result + return SimpleNamespace(state="passed", state_version=4) + + async def refresh_live(_factory, _claim, actual): + order.append("live-refresh") + return actual + + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=1, + ) + + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_isolated_dry_run", + new=execute_sandbox, + ), patch( + "app.jobs.migration_dry_run_worker.execute_bound_live_preflight", + new=execute_preflight, + ), patch( + "app.jobs.migration_dry_run_worker._complete_isolated_stage", + new=complete_sandbox, + ), patch( + "app.jobs.migration_dry_run_worker._complete_live_stage", + new=complete_live, + ), patch( + "app.jobs.migration_dry_run_worker._refresh_live_stage", + new=refresh_live, + ): + handler = make_durable_dry_run_attempt_handler( + sandbox_factory, live_factory + ) + await handler(MagicMock(), signal_claim, attempt_claim) + + assert ( + sandbox_requests[0].migration_run_attempt_uuid + == work.migration_run_attempt_uuid + ) + assert ( + live_requests[0].migration_run_attempt_uuid + == work.migration_run_attempt_uuid + ) + assert order == [ + "sandbox-enter", + "sandbox-execute", + "sandbox-exit", + "sandbox-complete", + "live-refresh", + "live-enter", + "live-execute", + "live-exit", + "live-complete", + ] + sandbox_fields = sandbox_requests[0].__dataclass_fields__ + assert "db_connection_uuid" not in sandbox_fields + assert "target_digest" not in sandbox_fields + assert "plan_json" not in sandbox_fields + assert sandbox_requests[0].postgresql_major == work.postgresql_major + assert sandbox_requests[0].base_digest == work.base_digest + live_fields = live_requests[0].__dataclass_fields__ + assert live_requests[0].db_connection_uuid == work.db_connection_uuid + assert live_requests[0].expected_state_version == 3 + assert "postgresql_major" not in live_fields + assert "base_digest" not in live_fields + assert "plan_json" not in live_fields + + +@pytest.mark.asyncio +async def test_handler_resumes_live_preflight_without_replaying_sandbox() -> None: + """Resume the read-only stage without replaying a completed sandbox.""" + + work = _work(state="live_preflight_running", state_version=9) + + def sandbox_factory(_request): + raise AssertionError("sandbox must not be replayed") + + @asynccontextmanager + async def live_factory(_request): + yield LivePreflightExecution(object(), AsyncMock()) + + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=9, + ) + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker._refresh_live_stage", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_bound_live_preflight", + new=AsyncMock(return_value={"checks": []}), + ), patch( + "app.jobs.migration_dry_run_worker._complete_live_stage", + new=AsyncMock(return_value=SimpleNamespace(state="drifted", state_version=10)), + ), patch( + "app.jobs.migration_dry_run_worker.execute_isolated_dry_run", + new=AsyncMock(), + ) as sandbox_execute: + handler = make_durable_dry_run_attempt_handler(sandbox_factory, live_factory) + await handler(MagicMock(), signal_claim, attempt_claim) + sandbox_execute.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handler_rejects_mismatched_claims_before_metadata_io() -> None: + """Reject divergent queue and durable-attempt identities before database I/O.""" + + session_factory = MagicMock() + handler = make_durable_dry_run_attempt_handler(MagicMock(), MagicMock()) + with pytest.raises(MigrationDryRunWorkerError, match="claim is invalid"): + await handler( + session_factory, + SimpleNamespace(migration_run_uuid=uuid.uuid4()), + SimpleNamespace( + migration_run_uuid=uuid.uuid4(), + attempt_number=1, + acquired_state_version=1, + ), + ) + session_factory.assert_not_called() + + +@pytest.mark.asyncio +async def test_sandbox_failure_is_sanitized_and_lease_cleanup_runs() -> None: + """Close the sandbox lease and discard provider failure details.""" + + work = _work() + cleaned = False + + @asynccontextmanager + async def sandbox_factory(_request): + nonlocal cleaned + try: + yield IsolatedSandboxExecution(object(), AsyncMock()) + finally: + cleaned = True + + handler = make_durable_dry_run_attempt_handler(sandbox_factory, MagicMock()) + signal_claim = SimpleNamespace(migration_run_uuid=work.migration_run_uuid) + attempt_claim = SimpleNamespace( + migration_run_attempt_uuid=work.migration_run_attempt_uuid, + migration_run_uuid=work.migration_run_uuid, + attempt_number=work.attempt_number, + acquired_state_version=1, + ) + marker = "opaque sandbox provider detail" + with patch( + "app.jobs.migration_dry_run_worker._load_and_begin", + new=AsyncMock(return_value=work), + ), patch( + "app.jobs.migration_dry_run_worker.execute_isolated_dry_run", + new=AsyncMock(side_effect=RuntimeError(marker)), + ): + with pytest.raises(MigrationDryRunWorkerError) as caught: + await handler(MagicMock(), signal_claim, attempt_claim) + assert str(caught.value) == "isolated dry-run stage failed" + assert marker not in repr(caught.value) + assert caught.value.__cause__ is None + assert cleaned diff --git a/backend/tests/test_migration_run_consumer.py b/backend/tests/test_migration_run_consumer.py new file mode 100644 index 000000000..32bb1f8c4 --- /dev/null +++ b/backend/tests/test_migration_run_consumer.py @@ -0,0 +1,1049 @@ +"""Execution-neutral migration-run signal consumer contract tests.""" + +from __future__ import annotations + +import asyncio +import datetime as dt +import uuid +from collections.abc import Callable +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.forward.migration_run import ( + MigrationRunAttemptClaim, + MigrationRunContractError, +) +from app.jobs.migration_run_consumer import ( + MigrationRunAttemptHandlerError, + MigrationRunAttemptLeaseLost, + MigrationRunConsumerError, + MigrationRunSignalLeaseLost, + make_attempt_bound_migration_run_handler, + process_one_migration_run_signal, + run_migration_run_consumer_forever, +) +from app.jobs.valkey_queue import MigrationRunSignalClaim +from app.models import MigrationRun, MigrationRunAttempt + + +def _session_factory() -> AsyncSession: + """Stand in for the injected metadata-session factory without opening I/O.""" + + raise AssertionError("the execution-neutral consumer must not open a session") + + +def _transactional_session_factory() -> MagicMock: + """Return fresh recording sessions for durable-attempt transactions.""" + + sessions: list[MagicMock] = [] + transactions: list[MagicMock] = [] + + def create_session() -> MagicMock: + session = MagicMock() + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=False) + transaction = MagicMock() + transaction.__aenter__ = AsyncMock(return_value=None) + transaction.__aexit__ = AsyncMock(return_value=False) + session.begin.return_value = transaction + sessions.append(session) + transactions.append(transaction) + return session + + factory = MagicMock(side_effect=create_session) + factory.sessions = sessions + factory.transactions = transactions + return factory + + +def _attempt_claim(run_uuid: uuid.UUID) -> MigrationRunAttemptClaim: + """Build an exact durable attempt claim for one migration run.""" + + return MigrationRunAttemptClaim( + migration_run_attempt_uuid=uuid.uuid4(), + migration_run_uuid=run_uuid, + attempt_number=1, + acquired_state_version=0, + lease_expires_at=dt.datetime(2026, 8, 11, 7, 1, tzinfo=dt.timezone.utc), + ) + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_finishes_exact_success_before_signal_ack() -> None: + """A handler result is durable only while both exact leases still belong to it.""" + + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + + executor = AsyncMock() + + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ) as acquire, patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(return_value=True), + ) as finish: + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + ) + await handler(factory, signal_claim) + + acquire.assert_awaited_once_with( + factory.sessions[0], + migration_run_uuid=run_uuid, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + lease_seconds=60, + ) + executor.assert_awaited_once_with(factory, signal_claim, attempt_claim) + finish.assert_awaited_once_with( + factory.sessions[1], + claim=attempt_claim, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + succeeded=True, + ) + assert factory.call_count == 2 + assert all(tx.__aexit__.await_count == 1 for tx in factory.transactions) + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_acknowledges_persisted_cancellation_without_retry() -> None: + """A redelivered cancelled run terminates before acquiring execution authority.""" + + now = dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc) + run_uuid = uuid.uuid4() + run = MigrationRun( + migration_run_uuid=run_uuid, + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="sandbox_running", + state_version=3, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=True, + evidence_json={}, + created_at=now, + updated_at=now, + started_at=now, + ) + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt = MigrationRunAttempt( + migration_run_attempt_uuid=uuid.uuid4(), + migration_run_uuid=run_uuid, + attempt_number=1, + acquired_state_version=1, + status="active", + worker_identity_hash="e" * 64, + signal_lease_token_hash="f" * 64, + lease_expires_at=now + dt.timedelta(minutes=1), + acquired_at=now - dt.timedelta(minutes=1), + last_heartbeat_at=now, + finished_at=None, + ) + factory = _transactional_session_factory() + executor = AsyncMock() + + def configure_session() -> MagicMock: + session = factory() + session.scalar = AsyncMock(side_effect=[run, attempt, run]) + session.execute = AsyncMock(return_value=MagicMock(rowcount=1)) + session.add = Mock() + return session + + configured_factory = MagicMock(side_effect=configure_session) + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock( + side_effect=MigrationRunContractError( + "migration run is not executable" + ) + ), + ): + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + ) + await handler(configured_factory, signal_claim) + + assert run.state == "cancelled" + assert run.state_version == 4 + assert run.cancellation_requested is True + assert run.finished_at is not None + assert attempt.status == "abandoned" + assert attempt.finished_at is not None + executor.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_acks_terminal_redelivery_without_replay() -> None: + """A crash after terminal commit cannot replay sandbox or live preflight.""" + + now = dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc) + run_uuid = uuid.uuid4() + run = MigrationRun( + migration_run_uuid=run_uuid, + project_space_uuid=uuid.uuid4(), + migration_plan_uuid=uuid.uuid4(), + run_kind="dry_run", + state="passed", + state_version=4, + idempotency_key_hash="a" * 64, + plan_digest="b" * 64, + request_digest="c" * 64, + latest_event_digest="d" * 64, + requested_by_user_uuid=uuid.uuid4(), + cancellation_requested=False, + observed_base_digest="e" * 64, + evidence_json={}, + created_at=now, + updated_at=now, + started_at=now, + finished_at=now, + ) + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt = MigrationRunAttempt( + migration_run_attempt_uuid=uuid.uuid4(), + migration_run_uuid=run_uuid, + attempt_number=1, + acquired_state_version=1, + status="active", + worker_identity_hash="e" * 64, + signal_lease_token_hash="f" * 64, + lease_expires_at=now + dt.timedelta(minutes=1), + acquired_at=now - dt.timedelta(minutes=1), + last_heartbeat_at=now, + finished_at=None, + ) + factory = _transactional_session_factory() + executor = AsyncMock() + + def configure_session() -> MagicMock: + session = factory() + session.scalar = AsyncMock(side_effect=[run, attempt]) + return session + + configured_factory = MagicMock(side_effect=configure_session) + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock( + side_effect=MigrationRunContractError( + "migration run is not executable" + ) + ), + ): + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + ) + await handler(configured_factory, signal_claim) + + assert run.state == "passed" + assert run.state_version == 4 + assert attempt.status == "abandoned" + assert attempt.finished_at is not None + executor.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_composed_consumer_acks_only_after_durable_attempt_completion() -> None: + """The outer signal cannot be acknowledged before exact DB completion.""" + + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + order: list[str] = [] + + async def executor(*_args: object) -> None: + order.append("executed") + + async def finish(*_args: object, **_kwargs: object) -> bool: + order.append("attempt-finished") + return True + + async def ack(actual_claim: MigrationRunSignalClaim) -> bool: + assert actual_claim == signal_claim + order.append("signal-acked") + return True + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=signal_claim), + ), patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=finish, + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=ack, + ), patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ) as release: + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + ) + assert await process_one_migration_run_signal( + factory, + handler, + now=dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc), + ) + + assert order == ["executed", "attempt-finished", "signal-acked"] + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_abandons_sanitized_failure() -> None: + """Worker details cannot escape, while an exact failed owner is abandoned.""" + + marker = "postgresql://owner:secret@target/private" + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(return_value=True), + ) as finish: + handler = make_attempt_bound_migration_run_handler( + AsyncMock(side_effect=RuntimeError(marker)), + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + ) + with pytest.raises(MigrationRunAttemptHandlerError) as caught: + await handler(factory, signal_claim) + + assert str(caught.value) == "migration run attempt handler failed" + assert marker not in repr(caught.value) + finish.assert_awaited_once_with( + factory.sessions[1], + claim=attempt_claim, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + succeeded=False, + ) + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_renews_and_cancels_on_ownership_loss() -> None: + """Losing the DB lease cancels execution and cannot finish as success.""" + + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + cancelled = asyncio.Event() + + async def executor(*_args: object) -> None: + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_attempt", + new=AsyncMock(side_effect=[True, False]), + ) as renew, patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(return_value=True), + ) as finish: + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=2, + heartbeat_interval_s=0.01, + ) + with pytest.raises(MigrationRunAttemptLeaseLost, match="renewal"): + await handler(factory, signal_claim) + + assert cancelled.is_set() + assert renew.await_count == 2 + for index, renewed_call in enumerate(renew.await_args_list, start=1): + assert renewed_call == call( + factory.sessions[index], + claim=attempt_claim, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + lease_seconds=2, + ) + finish.assert_awaited_once_with( + factory.sessions[3], + claim=attempt_claim, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + succeeded=False, + ) + + +@pytest.mark.asyncio +async def test_attempt_renewal_failure_is_sanitized_before_it_escapes() -> None: + """Replace durable-heartbeat provider failures with one fixed lease error.""" + + marker = "opaque durable-heartbeat provider detail" + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + + async def executor(*_args: object) -> None: + await asyncio.Event().wait() + + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_attempt", + new=AsyncMock(side_effect=RuntimeError(marker)), + ), patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(return_value=True), + ) as finish: + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=2, + heartbeat_interval_s=0.01, + ) + with pytest.raises(MigrationRunAttemptLeaseLost) as caught: + await handler(factory, signal_claim) + + assert str(caught.value) == ( + "migration run attempt renewal ended without handler completion" + ) + assert marker not in repr(caught.value) + assert caught.value.__cause__ is None + finish.assert_awaited_once_with( + factory.sessions[2], + claim=attempt_claim, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + succeeded=False, + ) + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_finishes_when_handler_and_heartbeat_complete_together( +) -> None: + """Let exact attempt completion decide a simultaneous terminal heartbeat race.""" + + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + + async def wait_for_both( + tasks: set[asyncio.Task[object]], *, return_when: object + ) -> tuple[set[asyncio.Task[object]], set[asyncio.Task[object]]]: + assert return_when is asyncio.FIRST_COMPLETED + await asyncio.gather(*tasks) + return set(tasks), set() + + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_attempt", + new=AsyncMock(return_value=False), + ) as renew, patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(return_value=True), + ) as finish, patch( + "app.jobs.migration_run_consumer.wait", + new=wait_for_both, + ): + handler = make_attempt_bound_migration_run_handler( + AsyncMock(), + worker_identity="forward-worker-1", + attempt_lease_seconds=2, + heartbeat_interval_s=0.01, + ) + await handler(factory, signal_claim) + + renew.assert_awaited_once() + finish.assert_awaited_once_with( + factory.sessions[2], + claim=attempt_claim, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + succeeded=True, + ) + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_fails_closed_when_completion_owner_is_lost() -> None: + """A successful callback cannot authorize signal ack after DB lease loss.""" + + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(return_value=False), + ): + handler = make_attempt_bound_migration_run_handler( + AsyncMock(), + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + ) + with pytest.raises(MigrationRunAttemptLeaseLost, match="completion"): + await handler(factory, signal_claim) + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_abandons_when_lifecycle_is_cancelled() -> None: + """Outer signal-lease cancellation removes the durable attempt owner too.""" + + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + started = asyncio.Event() + + async def executor(*_args: object) -> None: + started.set() + await asyncio.Event().wait() + + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(return_value=True), + ) as finish: + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + ) + task: asyncio.Future[None] = asyncio.ensure_future( + handler(factory, signal_claim) + ) + await started.wait() + task.cancel() + [cancellation] = await asyncio.gather(task, return_exceptions=True) + assert isinstance(cancellation, asyncio.CancelledError) + + finish.assert_awaited_once_with( + factory.sessions[1], + claim=attempt_claim, + worker_identity="forward-worker-1", + signal_lease_token=signal_claim.lease_token, + succeeded=False, + ) + + +@pytest.mark.asyncio +async def test_attempt_abandonment_failure_logs_only_fixed_code( + caplog: pytest.LogCaptureFixture, +) -> None: + """Cleanup driver details cannot escape when durable renewal is lost.""" + + marker = "postgresql://owner:secret@metadata/private" + run_uuid = uuid.uuid4() + signal_claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + attempt_claim = _attempt_claim(run_uuid) + factory = _transactional_session_factory() + + async def executor(*_args: object) -> None: + await asyncio.Event().wait() + + with patch( + "app.jobs.migration_run_consumer.acquire_migration_run_attempt", + new=AsyncMock(return_value=attempt_claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_attempt", + new=AsyncMock(return_value=False), + ), patch( + "app.jobs.migration_run_consumer.finish_migration_run_attempt", + new=AsyncMock(side_effect=RuntimeError(marker)), + ): + handler = make_attempt_bound_migration_run_handler( + executor, + worker_identity="forward-worker-1", + attempt_lease_seconds=2, + heartbeat_interval_s=0.01, + ) + with pytest.raises(MigrationRunAttemptLeaseLost, match="renewal"): + await handler(factory, signal_claim) + + assert "migration_run_attempt_abandon_failed" in caplog.text + assert marker not in caplog.text + + +@pytest.mark.asyncio +async def test_attempt_bound_handler_rejects_invalid_timing_before_database_io() -> None: + """Attempt ownership cannot be configured with unsafe lease timing.""" + + factory = _transactional_session_factory() + signal_claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + for lease in (True, 0, 301, 1.5): + with pytest.raises(ValueError, match="attempt lease"): + make_attempt_bound_migration_run_handler( + AsyncMock(), + worker_identity="forward-worker-1", + attempt_lease_seconds=lease, # type: ignore[arg-type] + ) + for heartbeat in (0, float("nan"), 60): + with pytest.raises(ValueError, match="attempt heartbeat"): + make_attempt_bound_migration_run_handler( + AsyncMock(), + worker_identity="forward-worker-1", + attempt_lease_seconds=60, + heartbeat_interval_s=heartbeat, + ) + + assert factory.call_count == 0 + assert signal_claim.migration_run_uuid + + +@pytest.mark.asyncio +async def test_consumer_acknowledges_exact_lease_only_after_handler_success() -> None: + """Successful injected work precedes exact-lease acknowledgement.""" + + run_uuid = uuid.uuid4() + claim = MigrationRunSignalClaim(run_uuid, uuid.uuid4()) + order: list[str] = [] + + async def handler( + factory: Callable[[], AsyncSession], actual_claim: MigrationRunSignalClaim + ) -> None: + assert factory is _session_factory + assert actual_claim == claim + order.append("handled") + + async def ack(actual: MigrationRunSignalClaim) -> bool: + assert actual == claim + order.append("acked") + return True + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=ack, + ), patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ) as release: + assert await process_one_migration_run_signal( + _session_factory, + handler, + now=dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc), + ) + + assert order == ["handled", "acked"] + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_consumer_releases_exact_lease_after_sanitized_handler_failure() -> None: + """Failed work is retried without exposing the handler exception or payload.""" + + now = dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc) + claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + handler = AsyncMock( + side_effect=RuntimeError("postgresql://admin:secret@target/private") + ) + release = AsyncMock(return_value=True) + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(), + ) as ack, patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=release, + ): + with pytest.raises(MigrationRunConsumerError) as caught: + await process_one_migration_run_signal( + _session_factory, + handler, + now=now, + retry_delay_s=2.5, + ) + + assert str(caught.value) == "migration run handler failed" + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + release.assert_awaited_once_with(claim, now + dt.timedelta(seconds=2.5)) + ack.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_consumer_fails_closed_when_exact_lease_cannot_be_completed() -> None: + """Lost acknowledgement or release ownership is never reported as success.""" + + claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + now = dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc) + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(return_value=False), + ), patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ): + with pytest.raises(MigrationRunSignalLeaseLost, match="acknowledgement"): + await process_one_migration_run_signal( + _session_factory, + AsyncMock(), + now=now, + ) + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(), + ), patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(return_value=False), + ): + with pytest.raises(MigrationRunSignalLeaseLost, match="retry release"): + await process_one_migration_run_signal( + _session_factory, + AsyncMock(side_effect=RuntimeError("hostile detail")), + now=now, + ) + + +@pytest.mark.asyncio +async def test_consumer_renews_exact_lease_while_handler_runs() -> None: + """Long-running work renews its own claim before acknowledgement.""" + + claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + handler_can_finish = asyncio.Event() + + async def handler( + _factory: Callable[[], AsyncSession], actual_claim: MigrationRunSignalClaim + ) -> None: + assert actual_claim == claim + await handler_can_finish.wait() + + async def renew( + actual_claim: MigrationRunSignalClaim, **_kwargs: object + ) -> bool: + assert actual_claim == claim + handler_can_finish.set() + return True + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_signal", + new=AsyncMock(side_effect=renew), + ) as renewal, patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(return_value=True), + ) as ack, patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ) as release: + assert await process_one_migration_run_signal( + _session_factory, + handler, + lease_seconds=0.1, + heartbeat_interval_s=0.01, + ) + + renewal.assert_awaited_once_with(claim, lease_seconds=0.1) + ack.assert_awaited_once_with(claim) + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_consumer_cancels_handler_when_exact_renewal_is_lost() -> None: + """Lease loss removes handler authority and cannot become success or retry.""" + + claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + cancelled = asyncio.Event() + + async def handler( + _factory: Callable[[], AsyncSession], _claim: MigrationRunSignalClaim + ) -> None: + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_signal", + new=AsyncMock(return_value=False), + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(), + ) as ack, patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ) as release: + with pytest.raises(MigrationRunSignalLeaseLost, match="renewal"): + await process_one_migration_run_signal( + _session_factory, + handler, + lease_seconds=0.1, + heartbeat_interval_s=0.01, + ) + + assert cancelled.is_set() + ack.assert_not_awaited() + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_signal_renewal_failure_is_sanitized_before_it_escapes() -> None: + """Replace signal-heartbeat provider failures with one fixed lease error.""" + + marker = "opaque signal-heartbeat provider detail" + claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + + async def handler( + _factory: Callable[[], AsyncSession], _claim: MigrationRunSignalClaim + ) -> None: + await asyncio.Event().wait() + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_signal", + new=AsyncMock(side_effect=RuntimeError(marker)), + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(), + ) as ack, patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ) as release: + with pytest.raises(MigrationRunSignalLeaseLost) as caught: + await process_one_migration_run_signal( + _session_factory, + handler, + lease_seconds=0.1, + heartbeat_interval_s=0.01, + ) + + assert str(caught.value) == ( + "migration run renewal ended without handler completion" + ) + assert marker not in repr(caught.value) + assert caught.value.__cause__ is None + ack.assert_not_awaited() + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_consumer_acks_when_handler_and_signal_heartbeat_complete_together( +) -> None: + """Let exact signal acknowledgement decide simultaneous completion.""" + + claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + + async def wait_for_both( + tasks: set[asyncio.Task[object]], *, return_when: object + ) -> tuple[set[asyncio.Task[object]], set[asyncio.Task[object]]]: + assert return_when is asyncio.FIRST_COMPLETED + await asyncio.gather(*tasks) + return set(tasks), set() + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_signal", + new=AsyncMock(return_value=False), + ) as renew, patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(return_value=True), + ) as ack, patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ) as release, patch( + "app.jobs.migration_run_consumer.wait", + new=wait_for_both, + ): + assert await process_one_migration_run_signal( + _session_factory, + AsyncMock(), + lease_seconds=0.1, + heartbeat_interval_s=0.01, + ) + + renew.assert_awaited_once() + ack.assert_awaited_once_with(claim) + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_consumer_lifecycle_cancellation_retrieves_both_tasks() -> None: + """Shutdown cancellation cannot leave handler or heartbeat tasks running.""" + + claim = MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + started = asyncio.Event() + cancelled = asyncio.Event() + + async def handler( + _factory: Callable[[], AsyncSession], _claim: MigrationRunSignalClaim + ) -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=AsyncMock(return_value=claim), + ), patch( + "app.jobs.migration_run_consumer.renew_migration_run_signal", + new=AsyncMock(return_value=True), + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(), + ) as ack, patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ) as release: + task = asyncio.create_task( + process_one_migration_run_signal( + _session_factory, + handler, + lease_seconds=60, + heartbeat_interval_s=20, + ) + ) + await started.wait() + task.cancel() + [cancellation] = await asyncio.gather(task, return_exceptions=True) + assert isinstance(cancellation, asyncio.CancelledError) + + assert cancelled.is_set() + ack.assert_not_awaited() + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_consumer_empty_queue_and_invalid_timing_are_io_free() -> None: + """An empty queue sleeps, while invalid bounds fail before signal I/O.""" + + claim = AsyncMock(return_value=None) + handler = AsyncMock() + with patch( + "app.jobs.migration_run_consumer.claim_due_migration_run_signal", + new=claim, + ), patch( + "app.jobs.migration_run_consumer.ack_migration_run_signal", + new=AsyncMock(), + ), patch( + "app.jobs.migration_run_consumer.release_migration_run_signal", + new=AsyncMock(), + ): + assert not await process_one_migration_run_signal( + _session_factory, + handler, + now=dt.datetime(2026, 8, 11, 7, tzinfo=dt.timezone.utc), + ) + for retry_delay in (0, float("inf"), 3601): + with pytest.raises(ValueError, match="retry delay"): + await process_one_migration_run_signal( + _session_factory, + handler, + retry_delay_s=retry_delay, + ) + for heartbeat_interval in (0, float("inf"), 60): + with pytest.raises(ValueError, match="heartbeat interval"): + await process_one_migration_run_signal( + _session_factory, + handler, + lease_seconds=60, + heartbeat_interval_s=heartbeat_interval, + ) + with pytest.raises(ValueError, match="include a timezone"): + await process_one_migration_run_signal( + _session_factory, + handler, + now=dt.datetime(2026, 8, 11, 7), + ) + + claim.assert_awaited_once() + handler.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_consumer_lifecycle_uses_bounded_sleep_and_fixed_logs( + caplog: pytest.LogCaptureFixture, +) -> None: + """Lifecycle failures retry at a bounded cadence without secret details.""" + + process = AsyncMock( + side_effect=[ + MigrationRunConsumerError("secret detail"), + False, + True, + asyncio.CancelledError, + ] + ) + sleep = AsyncMock() + handler = AsyncMock() + with patch( + "app.jobs.migration_run_consumer.process_one_migration_run_signal", + new=process, + ), patch("app.jobs.migration_run_consumer.sleep", new=sleep): + with pytest.raises(asyncio.CancelledError): + await run_migration_run_consumer_forever( + _session_factory, + handler, + poll_interval_s=0.25, + retry_delay_s=2, + ) + + assert sleep.await_args_list == [call(0.25), call(0.25)] + assert "migration_run_consumer_iteration_failed" in caplog.text + assert "secret detail" not in caplog.text + + for poll_interval in (0, float("nan"), 61): + with pytest.raises(ValueError, match="poll interval"): + await run_migration_run_consumer_forever( + _session_factory, + handler, + poll_interval_s=poll_interval, + ) diff --git a/backend/tests/test_observability.py b/backend/tests/test_observability.py index 8d9ff1d6b..cf6fa992e 100644 --- a/backend/tests/test_observability.py +++ b/backend/tests/test_observability.py @@ -21,8 +21,8 @@ def test_request_id_header_and_metrics_endpoint() -> None: app = FastAPI() @app.get("/healthz") - def healthz() -> dict[str, bool]: - return {"ok": True} + def healthz(request: Request) -> dict[str, object]: + return {"ok": True, "request_id": request.state.request_id} setup_observability(app) client = TestClient(app) @@ -30,6 +30,7 @@ def healthz() -> dict[str, bool]: r = client.get("/healthz") assert r.status_code == 200 assert "X-Request-Id" in r.headers + assert r.json()["request_id"] == r.headers["X-Request-Id"] unauth = client.get("/metrics") assert unauth.status_code == 403 diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py index acc6f5f31..f6d1a5479 100644 --- a/backend/tests/test_permissions.py +++ b/backend/tests/test_permissions.py @@ -51,3 +51,16 @@ async def test_require_project_member_rejects_non_member() -> None: assert exc_info.value.status_code == 403 assert exc_info.value.detail == "project access denied" + + +@pytest.mark.asyncio +async def test_deployer_role_sits_between_editor_and_owner() -> None: + role = await require_project_member( + FakeSession("deployer"), uuid.uuid4(), uuid.uuid4(), minimum_role="deployer" + ) + assert role == "deployer" + + with pytest.raises(HTTPException): + await require_project_member( + FakeSession("editor"), uuid.uuid4(), uuid.uuid4(), minimum_role="deployer" + ) diff --git a/backend/tests/test_pg_introspect_connection.py b/backend/tests/test_pg_introspect_connection.py index 1eb9c5528..51598b52f 100644 --- a/backend/tests/test_pg_introspect_connection.py +++ b/backend/tests/test_pg_introspect_connection.py @@ -4,6 +4,7 @@ import ssl from typing import Any +import asyncpg import pytest from app.pg_introspect import introspect @@ -15,7 +16,25 @@ def fake_addrinfo(*ips: str) -> list[tuple[int, int, int, str, tuple[str, int]]] class FakeConnection: - async def fetchval(self, *_args: object) -> str: + def __init__(self) -> None: + self.transaction_options: dict[str, object] | None = None + self.transaction_calls = 0 + self.transaction_depth = 0 + self.transaction_started = False + self.transaction_committed = False + self.transaction_rolled_back = False + + def transaction(self, **kwargs: object) -> "FakeTransaction": + self.transaction_calls += 1 + self.transaction_options = kwargs + return FakeTransaction(self) + + def is_in_transaction(self) -> bool: + return self.transaction_depth > 0 + + async def fetchval(self, *_args: object) -> bool | str: + if _args and "SELECT EXISTS" in str(_args[0]): + return False return "16.0" async def fetch(self, *_args: object) -> list[dict[str, object]]: @@ -25,6 +44,39 @@ async def close(self) -> None: return None +class FakeTransaction: + def __init__(self, connection: FakeConnection) -> None: + self.connection = connection + + async def start(self) -> None: + self.connection.transaction_depth += 1 + self.connection.transaction_started = True + + async def commit(self) -> None: + self.connection.transaction_depth -= 1 + self.connection.transaction_committed = True + + async def rollback(self) -> None: + self.connection.transaction_depth -= 1 + self.connection.transaction_rolled_back = True + + +class OptionalCitusFailureConnection(FakeConnection): + def __init__(self, failure: type[asyncpg.PostgresError]) -> None: + super().__init__() + self.failure = failure + + async def fetchval(self, *_args: object) -> bool | str: + if _args and "SELECT EXISTS" in str(_args[0]): + return True + return "18.2" + + async def fetch(self, *_args: object) -> list[dict[str, object]]: + if _args and _args[0] == introspect.queries.CITUS_DISTRIBUTED_TABLES_SQL: + raise self.failure("optional Citus metadata unavailable") + return [] + + @pytest.mark.asyncio async def test_introspection_connects_to_validated_ip( monkeypatch: pytest.MonkeyPatch, @@ -82,4 +134,112 @@ async def fake_connect(dsn: str, **kwargs: object) -> FakeConnection: assert captured["host"] == "93.184.216.34" assert isinstance(captured["ssl"], ssl.SSLContext) - assert getattr(captured["ssl"], "_server_hostname") == "db.example.com" + assert captured["ssl"]._server_hostname == "db.example.com" + + +@pytest.mark.asyncio +async def test_introspection_uses_one_read_only_repeatable_read_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + connection = FakeConnection() + + async def fake_connect(_dsn: str, **_kwargs: object) -> FakeConnection: + return connection + + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *_args, **_kwargs: fake_addrinfo("93.184.216.34"), + ) + monkeypatch.setattr(settings, "db_introspection_allowed_hosts", "db.example.com") + monkeypatch.setattr(introspect.asyncpg, "connect", fake_connect) + + snapshot = await introspect.introspect_postgres( + "postgresql://user:pass@db.example.com/app", schema_filter=None + ) + + assert connection.transaction_options == { + "isolation": "repeatable_read", + "readonly": True, + } + assert connection.transaction_started is True + assert connection.transaction_committed is True + assert connection.transaction_rolled_back is False + assert snapshot["snapshot_contract_version"] == 1 + + +@pytest.mark.asyncio +async def test_snapshot_capture_reuses_caller_owned_connection() -> None: + """Capture live-preflight evidence without opening or closing a connection.""" + + connection = FakeConnection() + connection.transaction_depth = 1 + + snapshot = await introspect.capture_postgres_snapshot( + connection, schema_filter=None + ) + + assert connection.transaction_calls == 0 + assert connection.transaction_options is None + assert connection.transaction_started is False + assert connection.transaction_committed is False + assert connection.transaction_rolled_back is False + assert connection.transaction_depth == 1 + assert snapshot["snapshot_contract_version"] == 1 + + +@pytest.mark.asyncio +async def test_snapshot_capture_requires_caller_transaction_before_citus() -> None: + """Reject absent outer ownership before optional Citus transaction access.""" + + connection = OptionalCitusFailureConnection( + asyncpg.InsufficientPrivilegeError + ) + + with pytest.raises( + RuntimeError, match="postgres snapshot capture transaction is missing" + ): + await introspect.capture_postgres_snapshot( + connection, schema_filter=None + ) + + assert connection.transaction_calls == 0 + assert connection.transaction_started is False + assert connection.transaction_committed is False + assert connection.transaction_rolled_back is False + + +@pytest.mark.parametrize( + "failure", + [ + asyncpg.InsufficientPrivilegeError, + asyncpg.UndefinedColumnError, + asyncpg.UndefinedFunctionError, + asyncpg.UndefinedTableError, + ], +) +@pytest.mark.asyncio +async def test_optional_citus_metadata_failures_do_not_abort_snapshot( + monkeypatch: pytest.MonkeyPatch, + failure: type[asyncpg.PostgresError], +) -> None: + connection = OptionalCitusFailureConnection(failure) + + async def fake_connect(_dsn: str, **_kwargs: object) -> FakeConnection: + return connection + + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *_args, **_kwargs: fake_addrinfo("93.184.216.34"), + ) + monkeypatch.setattr(settings, "db_introspection_allowed_hosts", "db.example.com") + monkeypatch.setattr(introspect.asyncpg, "connect", fake_connect) + + snapshot = await introspect.introspect_postgres( + "postgresql://user:pass@db.example.com/app", schema_filter=None + ) + + assert snapshot["citus_distributed_tables"] == [] + assert connection.transaction_rolled_back is True + assert connection.transaction_committed is True diff --git a/backend/tests/test_pg_introspect_queries.py b/backend/tests/test_pg_introspect_queries.py index 3f75ce613..e417795f2 100644 --- a/backend/tests/test_pg_introspect_queries.py +++ b/backend/tests/test_pg_introspect_queries.py @@ -15,6 +15,8 @@ def test_columns_query_captures_postgresql_type_catalog_metadata() -> None: assert "pg_catalog.format_type(typ.typbasetype, typ.typtypmod)" in sql assert "pg_catalog.format_type(typ.typelem, -1)" in sql assert "a.attndims AS array_dimensions" in sql + assert "a.attidentity::text AS identity" in sql + assert "a.attgenerated::text AS generated" in sql def test_indexes_query_captures_dynamic_index_method_metadata() -> None: @@ -50,6 +52,20 @@ def test_relations_query_captures_partition_metadata() -> None: assert "LEFT JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.oid" in sql +def test_relations_query_reports_dropped_user_columns() -> None: + sql = queries.RELATIONS_SQL + + assert "dropped.attisdropped" in sql + assert "AS has_dropped_columns" in sql + + +def test_primary_key_query_captures_deferral_metadata() -> None: + sql = queries.PK_COLUMNS_SQL + + assert "con.condeferrable AS is_deferrable" in sql + assert "con.condeferred AS is_initially_deferred" in sql + + def test_citus_query_captures_distributed_table_metadata() -> None: sql = queries.CITUS_DISTRIBUTED_TABLES_SQL diff --git a/backend/tests/test_postgres_migration_run_integration.py b/backend/tests/test_postgres_migration_run_integration.py new file mode 100644 index 000000000..14a07722a --- /dev/null +++ b/backend/tests/test_postgres_migration_run_integration.py @@ -0,0 +1,2062 @@ +"""Real-PostgreSQL migration-run/outbox and live-read integration acceptance.""" + +from __future__ import annotations + +import asyncio +import copy +import datetime as dt +import hashlib +import hmac +import os +import uuid +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import cast +from urllib.parse import urlparse + +import asyncpg +import pytest +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.ddl.export import quote_identifier, snapshot_json_to_sql +from app.forward.isolated_dry_run import ( + IsolatedPostgresConnection, + execute_isolated_dry_run, +) +from app.forward.live_preflight import ( + LivePreflightContractError, + execute_bound_live_preflight, + execute_live_preflight, +) +from app.forward.migration_plan import compile_migration_plan +from app.forward.migration_run import ( + MigrationRunAttemptClaim, + acquire_migration_run_attempt, + claim_one_migration_dispatch, + complete_isolated_dry_run, + complete_live_preflight, + create_migration_run, + finish_migration_run_attempt, + mark_migration_dispatch_published, + renew_migration_run_attempt, + transition_migration_run, +) +from app.forward.pre_apply_revalidation import ( + capture_pre_apply_revalidation_observation, + compile_apply_privilege_queries, + compile_pre_apply_revalidation_manifest, +) +from app.forward.schema_model import schema_model_digest +from app.forward.snapshot_adapter import snapshot_to_schema_model +from app.jobs import valkey_queue +from app.jobs.migration_run_consumer import ( + MigrationRunConsumerError, + make_attempt_bound_migration_run_handler, + process_one_migration_run_signal, +) +from app.jobs.migration_dry_run_worker import ( + IsolatedSandboxExecution, + IsolatedSandboxRequest, + LivePreflightExecution, + LivePreflightRequest, + MigrationDryRunWorkerError, + load_guarded_live_preflight_target, +) +from app.jobs.live_preflight_provider import ( + make_stored_postgres_durable_dry_run_attempt_handler, + make_stored_postgres_live_preflight_factory, +) +from app.jobs.migration_dry_run_worker_contract import ( + LivePreflightFactory, + SessionFactory, +) +from app.jobs.valkey_queue import MigrationRunSignalClaim +from app.models import ( + DbConnection, + MigrationPlan, + MigrationRun, + MigrationRunAttempt, + MigrationRunDispatch, + MigrationRunEvent, + ProjectSpace, + SchemaModel, + SchemaModelRevision, + SchemaSnapshot, + UserAccount, +) +from app.pg_introspect import queries +from app.pg_introspect.introspect import capture_postgres_snapshot +from app.pg_introspect.snapshot_contract import ( + CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, +) +from app.security import encrypt_text +from app.settings import settings +from app.spec.dbml_import import parse_dbml + +_POSTGRES_URL = os.getenv("POSTGRES_INTEGRATION_URL") +_POSTGRES_SANDBOX_URL = os.getenv("POSTGRES_SANDBOX_INTEGRATION_URL") +_POSTGRES_TARGET_URL = os.getenv("POSTGRES_TARGET_INTEGRATION_URL") +_POSTGRES_PREFLIGHT_URL = os.getenv("POSTGRES_PREFLIGHT_INTEGRATION_URL") +_VALKEY_URL = os.getenv("VALKEY_INTEGRATION_URL") +_EXPECTED_MAJOR = os.getenv("EXPECTED_POSTGRES_MAJOR") +pytestmark = pytest.mark.skipif( + not _POSTGRES_URL + or not _POSTGRES_SANDBOX_URL + or not _POSTGRES_TARGET_URL + or not _POSTGRES_PREFLIGHT_URL + or not _EXPECTED_MAJOR, + reason=( + "metadata, sandbox, target-admin, preflight, and expected-major " + "configuration are required for real PostgreSQL acceptance" + ), +) + + +def _asyncpg_url() -> str: + assert _POSTGRES_URL is not None + return _POSTGRES_URL.replace("postgresql+asyncpg://", "postgresql://", 1) + + +def _sandbox_asyncpg_url() -> str: + assert _POSTGRES_SANDBOX_URL is not None + return _POSTGRES_SANDBOX_URL.replace( + "postgresql+asyncpg://", "postgresql://", 1 + ) + + +def _target_asyncpg_url() -> str: + assert _POSTGRES_TARGET_URL is not None + return _POSTGRES_TARGET_URL.replace( + "postgresql+asyncpg://", "postgresql://", 1 + ) + + +def _preflight_asyncpg_url() -> str: + assert _POSTGRES_PREFLIGHT_URL is not None + return _POSTGRES_PREFLIGHT_URL.replace( + "postgresql+asyncpg://", "postgresql://", 1 + ) + + +@pytest.mark.asyncio +async def test_real_postgres_executes_dbml_with_hostile_quoted_identifiers() -> None: + """Prove DBML names remain one intended PostgreSQL schema/table definition.""" + schema_name = f'dbml; -- {uuid.uuid4().hex}' + table_name = 'orders"; CREATE TABLE escaped_attempt(id int); --' + column_name = 'value"; DROP SCHEMA public CASCADE; --' + encoded_schema = schema_name.replace('"', '""') + encoded_table = table_name.replace('"', '""') + encoded_column = column_name.replace('"', '""') + snapshot = parse_dbml( + f'Table "{encoded_schema}"."{encoded_table}" {{\n' + f' "{encoded_column}" integer [pk]\n' + "}" + ) + ddl = snapshot_json_to_sql(snapshot, target_dialect="postgresql") + connection = await asyncpg.connect(_sandbox_asyncpg_url()) + + try: + await connection.execute(ddl) + relation_names = await connection.fetch( + "SELECT c.relname FROM pg_catalog.pg_class AS c " + "JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace " + "WHERE n.nspname = $1 AND c.relkind IN ('r', 'p')", + schema_name, + ) + assert [str(row["relname"]) for row in relation_names] == [table_name] + assert await connection.fetchval( + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_class WHERE relname = $1)", + "escaped_attempt", + ) is False + finally: + await connection.execute( + f"DROP SCHEMA IF EXISTS {quote_identifier(schema_name)} CASCADE" + ) + await connection.close() + + +async def _delete_project_fixture( + session: AsyncSession, *, project_space_uuid: uuid.UUID +) -> None: + """Delete one committed integration fixture in restrictive-FK order.""" + + parameters = {"project_space_uuid": project_space_uuid} + await session.execute( + text( + "DELETE FROM migration_run " + "WHERE project_space_uuid = :project_space_uuid " + "AND passed_dry_run_uuid IS NOT NULL" + ), + parameters, + ) + await session.execute( + text( + "DELETE FROM migration_run " + "WHERE project_space_uuid = :project_space_uuid" + ), + parameters, + ) + await session.execute( + text( + "DELETE FROM migration_plan " + "WHERE project_space_uuid = :project_space_uuid" + ), + parameters, + ) + await session.execute( + text( + "DELETE FROM schema_model_revision " + "WHERE schema_model_uuid IN (" + "SELECT schema_model_uuid FROM schema_model " + "WHERE project_space_uuid = :project_space_uuid" + ")" + ), + parameters, + ) + await session.execute( + text( + "DELETE FROM project_space " + "WHERE project_space_uuid = :project_space_uuid" + ), + parameters, + ) + + +@pytest.mark.asyncio +async def test_real_postgres_persists_terminal_cancellation_state_contract() -> None: + """Verify migration checks admit cancellation in runs and event history.""" + + connection = await asyncpg.connect(_asyncpg_url()) + try: + rows = await connection.fetch( + "SELECT conname, pg_get_constraintdef(oid) AS definition " + "FROM pg_catalog.pg_constraint " + "WHERE conname = ANY($1::text[]) ORDER BY conname", + [ + "ck_migration_run__state", + "ck_migration_run__kind_state", + "ck_migration_run_event__state_before", + "ck_migration_run_event__state_after", + ], + ) + definitions = { + str(row["conname"]): str(row["definition"]) for row in rows + } + assert set(definitions) == { + "ck_migration_run__state", + "ck_migration_run__kind_state", + "ck_migration_run_event__state_before", + "ck_migration_run_event__state_after", + } + assert all( + "cancelled" in definition for definition in definitions.values() + ) + assert definitions["ck_migration_run__kind_state"].count( + "cancelled" + ) == 2 + finally: + await connection.close() + + +def _preflight_plan(*preconditions: dict[str, object]) -> dict[str, object]: + return { + "can_dry_run": True, + "blockers": [], + "statements": [{"preconditions": list(preconditions)}], + } + + +def _migration_models_with_precondition( + postgresql_major: int, +) -> tuple[dict[str, object], dict[str, object]]: + """Return one base/target pair requiring a table-emptiness check.""" + + base_model: dict[str, object] = { + "format_version": 1, + "postgresql_major": postgresql_major, + "schemas": [ + { + "schema_name": "public", + "tables": [ + { + "table_name": "accounts", + "comment": None, + "columns": [ + { + "column_name": "id", + "data_type": "bigint", + "nullable": False, + "ordinal_position": 1, + } + ], + "primary_key": None, + "unique_constraints": [], + "foreign_keys": [], + "indexes": [], + "unsupported_features": [], + } + ], + } + ], + } + target_model = copy.deepcopy(base_model) + target_schemas = target_model["schemas"] + assert isinstance(target_schemas, list) + target_table = target_schemas[0]["tables"][0] + target_table["columns"].append( + { + "column_name": "tenant_id", + "data_type": "bigint", + "nullable": False, + "ordinal_position": 2, + } + ) + return base_model, target_model + + +async def _capture_filtered_snapshot( + connection: asyncpg.Connection[asyncpg.Record], + schema_name: str, + *, + stage_evidence: list[str] | None = None, +) -> dict[str, object]: + """Capture the strict capability rows from the owned sandbox connection.""" + + include_system = False + + def mark(stage: str) -> None: + if stage_evidence is not None: + stage_evidence.append(stage) + + async def fetch_rows(label: str, query: str) -> list[dict[str, object]]: + mark(f"capture-{label}-started") + rows = await connection.fetch(query, schema_name, include_system) + mark(f"capture-{label}-completed") + return [dict(row) for row in rows] + + mark("capture-version-started") + server_version = str(await connection.fetchval("SHOW server_version")) + mark("capture-version-completed") + return { + "snapshot_contract_version": CURRENT_POSTGRES_SNAPSHOT_CONTRACT_VERSION, + "server_version": server_version, + "schemas": await fetch_rows("schemas", queries.SCHEMAS_SQL), + "relations": await fetch_rows("relations", queries.RELATIONS_SQL), + "columns": await fetch_rows("columns", queries.COLUMNS_SQL), + "constraints": await fetch_rows("constraints", queries.CONSTRAINTS_SQL), + "indexes": await fetch_rows("indexes", queries.INDEXES_SQL), + "pk_columns": await fetch_rows("pk-columns", queries.PK_COLUMNS_SQL), + "fk_edges": await fetch_rows("fk-edges", queries.FK_EDGES_SQL), + } + + +@pytest.mark.asyncio +async def test_real_postgres_executes_exact_isolated_plan_and_converges() -> None: + """Prove exact signed-plan execution and re-introspection on PostgreSQL.""" + + assert _EXPECTED_MAJOR is not None + assert _POSTGRES_URL is not None + assert _POSTGRES_SANDBOX_URL is not None + assert urlparse(_POSTGRES_URL).path != urlparse(_POSTGRES_SANDBOX_URL).path + major = int(_EXPECTED_MAJOR) + schema_name = f"Dry Run {uuid.uuid4().hex}" + table_name = '주문 "항목"' + base = {"format_version": 1, "postgresql_major": major, "schemas": []} + target = { + "format_version": 1, + "postgresql_major": major, + "schemas": [ + { + "schema_name": schema_name, + "tables": [ + { + "table_name": table_name, + "columns": [ + { + "column_name": "Item ID", + "data_type": "bigint", + "nullable": True, + "ordinal_position": 1, + } + ], + } + ], + } + ], + } + plan = compile_migration_plan(base, target) + connection = await asyncpg.connect(_sandbox_asyncpg_url()) + + async def capture( + owned_connection: asyncpg.Connection[asyncpg.Record], + ) -> dict[str, object]: + return await _capture_filtered_snapshot(owned_connection, schema_name) + + quoted_schema = '"' + schema_name.replace('"', '""') + '"' + try: + evidence = await execute_isolated_dry_run( + connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, # type: ignore[arg-type] + lock_timeout_ms=2_000, + statement_timeout_ms=5_000, + ) + assert evidence == { + "postgresql_major": major, + "statement_count": 2, + "base_digest": plan["base_digest"], + "target_digest": plan["target_digest"], + "converged": True, + } + assert await connection.fetchval( + "SELECT EXISTS (" + "SELECT 1 FROM pg_catalog.pg_class AS c " + "JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace " + "WHERE n.nspname = $1 AND c.relname = $2)", + schema_name, + table_name, + ) is True + finally: + await connection.execute(f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE") + await connection.close() + + +@pytest.mark.asyncio +async def test_real_postgres_manifest_lock_covers_bound_precondition() -> None: + """Prove compiler-v1 lock/check inputs compose on PostgreSQL 14 through 18.""" + + assert _EXPECTED_MAJOR is not None + major = int(_EXPECTED_MAJOR) + schema_name = f"Apply Lock {uuid.uuid4().hex}" + table_name = '주문 "항목"' + quoted_schema = '"' + schema_name.replace('"', '""') + '"' + quoted_table = '"' + table_name.replace('"', '""') + '"' + quoted_id = '"ID 값"' + qualified = f"{quoted_schema}.{quoted_table}" + base_model: dict[str, object] = { + "format_version": 1, + "postgresql_major": major, + "schemas": [ + { + "schema_name": schema_name, + "tables": [ + { + "table_name": table_name, + "columns": [ + { + "column_name": "ID 값", + "data_type": "bigint", + "nullable": False, + "ordinal_position": 1, + } + ], + } + ], + } + ], + } + target_model = copy.deepcopy(base_model) + target_schemas = target_model["schemas"] + assert isinstance(target_schemas, list) + target_schemas[0]["tables"][0]["columns"].append( + { + "column_name": "tenant ID", + "data_type": "bigint", + "nullable": False, + "ordinal_position": 2, + } + ) + plan = compile_migration_plan(base_model, target_model) + manifest = compile_pre_apply_revalidation_manifest( + plan, + expected_plan_digest=plan["plan_digest"], + ) + assert manifest.postgresql_major == major + assert [ + (target.schema_name, target.table_name) + for target in manifest.lock_targets + ] == [(schema_name, table_name)] + assert [query.kind for query in manifest.precondition_queries] == [ + "table_is_empty" + ] + privilege_queries = compile_apply_privilege_queries( + plan, + expected_plan_digest=plan["plan_digest"], + ) + assert [query.scope for query in privilege_queries] == ["table"] + + lock_connection: asyncpg.Connection[asyncpg.Record] | None = None + contender: asyncpg.Connection[asyncpg.Record] | None = None + denied_role: asyncpg.Connection[asyncpg.Record] | None = None + transaction: asyncpg.Transaction | None = None + transaction_started = False + try: + lock_connection = await asyncpg.connect(_target_asyncpg_url()) + contender = await asyncpg.connect(_target_asyncpg_url()) + denied_role = await asyncpg.connect(_preflight_asyncpg_url()) + transaction = lock_connection.transaction() + await lock_connection.execute(f"CREATE SCHEMA {quoted_schema}") + await lock_connection.execute( + f"CREATE TABLE {qualified} ({quoted_id} bigint NOT NULL)" + ) + await lock_connection.execute( + f"INSERT INTO {qualified} ({quoted_id}) VALUES (1)" + ) + privilege_query = privilege_queries[0] + assert await lock_connection.fetchval( + privilege_query.sql, *privilege_query.parameters + ) is True + assert await denied_role.fetchval( + privilege_query.sql, *privilege_query.parameters + ) is False + + async def capture( + owned_connection: asyncpg.Connection[asyncpg.Record], + ) -> dict[str, object]: + return await _capture_filtered_snapshot( + owned_connection, + schema_name, + ) + + assessment = await capture_pre_apply_revalidation_observation( + lock_connection, + plan, + expected_plan_digest=plan["plan_digest"], + capture_snapshot=capture, # type: ignore[arg-type] + statement_timeout_ms=2_000, + ) + assert assessment.base_matches is True + assert assessment.privileges_satisfied is True + assert assessment.preconditions_satisfied is False + + await transaction.start() + transaction_started = True + await lock_connection.execute(manifest.lock_targets[0].sql) + + await contender.execute("SET statement_timeout = '150ms'") + with pytest.raises(asyncpg.QueryCanceledError): + await contender.execute( + f"INSERT INTO {qualified} ({quoted_id}) VALUES (2)" + ) + await contender.execute("SET statement_timeout = 0") + + check = manifest.precondition_queries[0] + assert await lock_connection.fetchval(check.sql) is False + await transaction.rollback() + transaction_started = False + + await contender.execute( + f"INSERT INTO {qualified} ({quoted_id}) VALUES (2)" + ) + assert await contender.fetchval(f"SELECT count(*) FROM {qualified}") == 2 + finally: + if transaction_started and transaction is not None: + await transaction.rollback() + if contender is not None: + await contender.execute("SET statement_timeout = 0") + if lock_connection is not None: + await lock_connection.execute( + f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE" + ) + if denied_role is not None: + await denied_role.close() + if contender is not None: + await contender.close() + if lock_connection is not None: + await lock_connection.close() + + +@pytest.mark.asyncio +async def test_real_postgres_executes_only_bounded_preflight_reads() -> None: + """Prove least-privilege reads, DDL denial, timeout cleanup, and fixed failures.""" + + assert _POSTGRES_URL is not None + assert _POSTGRES_SANDBOX_URL is not None + assert _POSTGRES_TARGET_URL is not None + assert _POSTGRES_PREFLIGHT_URL is not None + database_paths = { + urlparse(_POSTGRES_URL).path, + urlparse(_POSTGRES_SANDBOX_URL).path, + urlparse(_POSTGRES_TARGET_URL).path, + } + assert len(database_paths) == 3 + admin_connection = await asyncpg.connect(_target_asyncpg_url()) + schema_name = f"Preflight {uuid.uuid4().hex}" + table_name = '주문 "항목"' + denied_table_name = '비공개 "항목"' + quoted_schema = '"' + schema_name.replace('"', '""') + '"' + quoted_table = '"' + table_name.replace('"', '""') + '"' + quoted_denied_table = '"' + denied_table_name.replace('"', '""') + '"' + qualified = f"{quoted_schema}.{quoted_table}" + denied_qualified = f"{quoted_schema}.{quoted_denied_table}" + try: + await admin_connection.execute(f"CREATE SCHEMA {quoted_schema}") + await admin_connection.execute( + f'CREATE TABLE {qualified} ("amount value" text)' + ) + await admin_connection.execute( + f'CREATE TABLE {denied_qualified} ("private value" text)' + ) + await admin_connection.execute( + f"INSERT INTO {qualified} VALUES ('12'), ('not-an-integer'), (NULL)" + ) + await admin_connection.execute( + f"GRANT USAGE ON SCHEMA {quoted_schema} TO cwl_erd_preflight" + ) + await admin_connection.execute( + f"GRANT SELECT ON {qualified} TO cwl_erd_preflight" + ) + planned_snapshot = await _capture_filtered_snapshot( + admin_connection, schema_name + ) + plan = _preflight_plan( + { + "kind": "table_is_empty", + "schema_name": schema_name, + "table_name": table_name, + }, + { + "kind": "no_null_values", + "schema_name": schema_name, + "table_name": table_name, + "column_name": "amount value", + }, + ) + plan["base_digest"] = schema_model_digest( + snapshot_to_schema_model(planned_snapshot) + ) + connection = await asyncpg.connect(_preflight_asyncpg_url()) + try: + privileges = await connection.fetchrow( + "SELECT " + "pg_catalog.has_database_privilege(" + "current_user, current_database(), 'CREATE') AS can_create, " + "pg_catalog.has_database_privilege(" + "current_user, current_database(), 'TEMP') AS can_temp" + ) + assert privileges is not None + assert dict(privileges) == { + "can_create": False, + "can_temp": False, + } + role_attributes = await connection.fetchrow( + "SELECT rolsuper, rolcreaterole, rolcreatedb, rolreplication, " + "rolbypassrls FROM pg_catalog.pg_roles WHERE rolname = current_user" + ) + assert role_attributes is not None + assert dict(role_attributes) == { + "rolsuper": False, + "rolcreaterole": False, + "rolcreatedb": False, + "rolreplication": False, + "rolbypassrls": False, + } + with pytest.raises( + ( + asyncpg.InsufficientPrivilegeError, + asyncpg.ReadOnlySQLTransactionError, + ) + ): + await connection.execute( + f'CREATE TABLE {quoted_schema}."must not exist" (id integer)' + ) + + async def capture( + owned_connection: asyncpg.Connection[asyncpg.Record], + ) -> dict[str, object]: + return await capture_postgres_snapshot( + owned_connection, schema_name + ) + + evidence = await execute_bound_live_preflight( + connection, + plan, + capture_snapshot=capture, # type: ignore[arg-type] + statement_timeout_ms=2000, + ) + + assert evidence == { + "preconditions_passed": False, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": False, + }, + { + "statement_index": 0, + "precondition_index": 1, + "kind": "no_null_values", + "passed": False, + }, + ], + "observed_base_digest": plan["base_digest"], + "matches_plan_base": True, + } + + blocking_transaction = admin_connection.transaction() + await blocking_transaction.start() + try: + await admin_connection.execute( + f"LOCK TABLE {qualified} IN ACCESS EXCLUSIVE MODE" + ) + with pytest.raises(LivePreflightContractError) as timed_out: + await execute_live_preflight( + connection, + _preflight_plan( + { + "kind": "table_is_empty", + "schema_name": schema_name, + "table_name": table_name, + } + ), + statement_timeout_ms=100, + ) + assert str(timed_out.value) == "live preflight query failed" + assert timed_out.value.__cause__ is None + assert connection.is_in_transaction() is False + finally: + await blocking_transaction.rollback() + + with pytest.raises(LivePreflightContractError) as denied: + await execute_live_preflight( + connection, + _preflight_plan( + { + "kind": "table_is_empty", + "schema_name": schema_name, + "table_name": denied_table_name, + } + ), + statement_timeout_ms=2000, + ) + assert str(denied.value) == "live preflight query failed" + assert denied.value.__cause__ is None + assert connection.is_in_transaction() is False + + with pytest.raises(LivePreflightContractError) as captured: + await execute_live_preflight( + connection, + _preflight_plan( + { + "kind": "castable_values", + "schema_name": schema_name, + "table_name": table_name, + "column_name": "amount value", + "target_data_type": "integer", + } + ), + statement_timeout_ms=2000, + ) + assert str(captured.value) == "live preflight query failed" + assert captured.value.__cause__ is None + + await admin_connection.execute(f"DELETE FROM {qualified}") + empty_evidence = await execute_live_preflight( + connection, + _preflight_plan( + { + "kind": "table_is_empty", + "schema_name": schema_name, + "table_name": table_name, + } + ), + statement_timeout_ms=2000, + ) + assert empty_evidence["passed"] is True + + backend_pid = connection.get_server_pid() + blocking_transaction = admin_connection.transaction() + await blocking_transaction.start() + interrupted = None + try: + await admin_connection.execute( + f"LOCK TABLE {qualified} IN ACCESS EXCLUSIVE MODE" + ) + interrupted = asyncio.create_task( + execute_live_preflight( + connection, + _preflight_plan( + { + "kind": "table_is_empty", + "schema_name": schema_name, + "table_name": table_name, + } + ), + statement_timeout_ms=2000, + ) + ) + for _ in range(100): + await admin_connection.execute( + "SELECT pg_catalog.pg_stat_clear_snapshot()" + ) + if await admin_connection.fetchval( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_stat_activity + WHERE pid = $1 + AND state = 'active' + AND wait_event_type = 'Lock' + ) + """, + backend_pid, + ): + break + assert not interrupted.done(), ( + "live preflight finished before entering its lock wait" + ) + await asyncio.sleep(0.01) + else: + pytest.fail( + "live preflight did not enter a lock wait before timeout" + ) + assert await admin_connection.fetchval( + "SELECT pg_catalog.pg_terminate_backend($1)", backend_pid + ) is True + with pytest.raises(LivePreflightContractError) as disconnected: + unexpected_result = await interrupted + pytest.fail( + "terminated live preflight unexpectedly returned " + f"{type(unexpected_result).__name__}" + ) + assert str(disconnected.value) == "live preflight query failed" + assert disconnected.value.__cause__ is None + assert connection.is_closed() is True + finally: + if interrupted is not None: + if not interrupted.done(): + interrupted.cancel() + await asyncio.gather(interrupted, return_exceptions=True) + await blocking_transaction.rollback() + finally: + await connection.close() + finally: + await admin_connection.execute( + f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE" + ) + await admin_connection.close() + + +@pytest.mark.asyncio +async def test_real_postgres_durable_worker_recovers_without_sandbox_replay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Resume a crashed durable dry run without replaying committed sandbox DDL.""" + + assert _POSTGRES_URL is not None + assert _POSTGRES_SANDBOX_URL is not None + assert _POSTGRES_TARGET_URL is not None + assert _POSTGRES_PREFLIGHT_URL is not None + assert _EXPECTED_MAJOR is not None + database_paths = { + urlparse(_POSTGRES_URL).path, + urlparse(_POSTGRES_SANDBOX_URL).path, + urlparse(_POSTGRES_TARGET_URL).path, + } + assert len(database_paths) == 3 + major = int(_EXPECTED_MAJOR) + schema_name = f"Worker Dry Run {uuid.uuid4().hex}" + preflight_schema_filter = f"worker_preflight_{uuid.uuid4().hex}" + table_name = '검증 "테이블"' + quoted_schema = '"' + schema_name.replace('"', '""') + '"' + base_model: dict[str, object] = { + "format_version": 1, + "postgresql_major": major, + "schemas": [], + } + target_model: dict[str, object] = { + "format_version": 1, + "postgresql_major": major, + "schemas": [ + { + "schema_name": schema_name, + "tables": [ + { + "table_name": table_name, + "columns": [ + { + "column_name": "ID 값", + "data_type": "bigint", + "nullable": True, + "ordinal_position": 1, + } + ], + } + ], + } + ], + } + plan_json = compile_migration_plan(base_model, target_model) + engine = create_async_engine(_POSTGRES_URL) + sessions = async_sessionmaker(engine, expire_on_commit=False) + now = dt.datetime.now(dt.timezone.utc) + user_uuid = uuid.uuid4() + project_uuid = uuid.uuid4() + connection_uuid = uuid.uuid4() + snapshot_uuid = uuid.uuid4() + model_uuid = uuid.uuid4() + revision_uuid = uuid.uuid4() + plan_uuid = uuid.uuid4() + signal_token = uuid.uuid4() + sandbox_requests: list[IsolatedSandboxRequest] = [] + live_requests: list[LivePreflightRequest] = [] + capability_order: list[str] = [] + sandbox_stages: list[str] = [] + crash_before_first_live_read = True + encrypted_preflight_dsn = encrypt_text(_preflight_asyncpg_url()) + expected_preflight_dsn_digest = hashlib.sha256( + _preflight_asyncpg_url().encode("utf-8") + ).digest() + + async def connect_test_loopback_target( + dsn: str, *, timeout: float + ) -> asyncpg.Connection[asyncpg.Record]: + # This test-only loopback connector is necessary because the production + # DNS/SSRF guard correctly rejects the private CI target. Separate guard + # tests retain production route validation authority. + if not hmac.compare_digest( + hashlib.sha256(dsn.encode("utf-8")).digest(), + expected_preflight_dsn_digest, + ): + raise RuntimeError("integration provider target mismatch") + capability_order.append("live-guard") + return await asyncpg.connect(dsn, timeout=timeout) + + monkeypatch.setattr( + "app.jobs.live_preflight_provider.connect_guarded_postgres", + connect_test_loopback_target, + ) + provider_factory = make_stored_postgres_live_preflight_factory(sessions) + + @asynccontextmanager + async def sandbox_factory( + request: IsolatedSandboxRequest, + ) -> AsyncIterator[IsolatedSandboxExecution]: + sandbox_requests.append(request) + capability_order.append("sandbox-enter") + sandbox_stages.append("connect-started") + connection = await asyncpg.connect(_sandbox_asyncpg_url()) + sandbox_stages.append("connected") + + async def capture( + owned_connection: IsolatedPostgresConnection, + ) -> dict[str, object]: + sandbox_stages.append("capture-started") + snapshot = await _capture_filtered_snapshot( + cast("asyncpg.Connection[asyncpg.Record]", owned_connection), + schema_name, + stage_evidence=sandbox_stages, + ) + sandbox_stages.append("capture-completed") + return snapshot + + try: + yield IsolatedSandboxExecution(connection, capture) + sandbox_stages.append("execution-completed") + finally: + sandbox_stages.append("cleanup-started") + await connection.execute( + f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE" + ) + await connection.close() + sandbox_stages.append("cleanup-completed") + capability_order.append("sandbox-exit") + + @asynccontextmanager + async def live_factory( + request: LivePreflightRequest, + ) -> AsyncIterator[LivePreflightExecution]: + nonlocal crash_before_first_live_read + live_requests.append(request) + if crash_before_first_live_read: + async with sessions() as guard_session: + async with guard_session.begin(): + guarded_target = await load_guarded_live_preflight_target( + guard_session, + request, + now=now + dt.timedelta(seconds=1.5), + ) + assert guarded_target.dsn_ciphertext == ( + encrypted_preflight_dsn.ciphertext + ) + assert guarded_target.dsn_nonce == encrypted_preflight_dsn.nonce + assert guarded_target.base_schema_snapshot_uuid == snapshot_uuid + assert guarded_target.schema_filter == preflight_schema_filter + capability_order.append("live-guard") + crash_before_first_live_read = False + capability_order.append("live-crash") + raise asyncio.CancelledError + async with provider_factory(request) as execution: + capability_order.append("live-enter") + try: + yield execution + finally: + capability_order.append("live-exit") + + def make_crash_injected_provider( + actual_sessions: SessionFactory, + *, + connect_timeout_seconds: float = 10.0, + ) -> LivePreflightFactory: + assert actual_sessions is sessions + assert connect_timeout_seconds == 10.0 + return live_factory + + monkeypatch.setattr( + "app.jobs.live_preflight_provider." + "make_stored_postgres_live_preflight_factory", + make_crash_injected_provider, + ) + + try: + async with sessions() as setup_session: + setup_session.add( + UserAccount( + user_account_uuid=user_uuid, + oidc_subject=f"durable-worker-integration:{user_uuid}", + display_name="Durable worker integration", + created_at=now, + ) + ) + await setup_session.flush() + setup_session.add( + ProjectSpace( + project_space_uuid=project_uuid, + project_name="durable worker integration", + created_by_user_uuid=user_uuid, + created_at=now, + ) + ) + await setup_session.flush() + setup_session.add_all( + [ + DbConnection( + db_connection_uuid=connection_uuid, + project_space_uuid=project_uuid, + conn_name="durable worker target", + dsn_ciphertext=encrypted_preflight_dsn.ciphertext, + dsn_nonce=encrypted_preflight_dsn.nonce, + created_at=now, + updated_at=now, + ), + SchemaSnapshot( + schema_snapshot_uuid=snapshot_uuid, + project_space_uuid=project_uuid, + db_connection_uuid=connection_uuid, + status="succeeded", + schema_filter=preflight_schema_filter, + started_at=now, + finished_at=now, + error_message=None, + created_at=now, + ), + SchemaModel( + schema_model_uuid=model_uuid, + project_space_uuid=project_uuid, + model_name="durable_worker_model", + current_revision_number=1, + created_by_user_uuid=user_uuid, + created_at=now, + updated_at=now, + ), + ] + ) + await setup_session.flush() + setup_session.add( + SchemaModelRevision( + schema_model_revision_uuid=revision_uuid, + schema_model_uuid=model_uuid, + revision_number=1, + revision_digest=plan_json["target_digest"], + model_json=target_model, + base_schema_snapshot_uuid=snapshot_uuid, + created_by_user_uuid=user_uuid, + created_at=now, + ) + ) + await setup_session.flush() + plan = MigrationPlan( + migration_plan_uuid=plan_uuid, + project_space_uuid=project_uuid, + schema_model_revision_uuid=revision_uuid, + db_connection_uuid=connection_uuid, + base_schema_snapshot_uuid=snapshot_uuid, + compiler_version=plan_json["compiler_version"], + base_digest=plan_json["base_digest"], + target_digest=plan_json["target_digest"], + statement_digest=plan_json["plan_digest"], + plan_json=plan_json, + created_by_user_uuid=user_uuid, + expires_at=now + dt.timedelta(hours=1), + created_at=now, + ) + setup_session.add(plan) + await setup_session.flush() + created = await create_migration_run( + setup_session, + plan=plan, + run_kind="dry_run", + idempotency_key="real-postgres-durable-worker", + requested_by_user_uuid=user_uuid, + evidence={"request_source": "postgresql_matrix"}, + now=now, + ) + await setup_session.commit() + + async with sessions() as claim_session: + async with claim_session.begin(): + attempt_claim = await acquire_migration_run_attempt( + claim_session, + migration_run_uuid=created.migration_run_uuid, + worker_identity="crashed-real-postgres-worker", + signal_lease_token=signal_token, + lease_seconds=1, + now=now + dt.timedelta(seconds=1), + ) + + handler = make_stored_postgres_durable_dry_run_attempt_handler( + sessions, + sandbox_factory, + lock_timeout_ms=2_000, + sandbox_statement_timeout_ms=5_000, + preflight_statement_timeout_ms=2_000, + ) + with pytest.raises(asyncio.CancelledError): + try: + await handler( + sessions, + MigrationRunSignalClaim( + created.migration_run_uuid, signal_token + ), + attempt_claim, + ) + except MigrationDryRunWorkerError as error: + pytest.fail( + "durable sandbox stage failed after fixed evidence " + f"{sandbox_stages!r}: {error}" + ) + + async with sessions() as expired_guard_session: + async with expired_guard_session.begin(): + with pytest.raises( + MigrationDryRunWorkerError, + match="target is invalid", + ): + await load_guarded_live_preflight_target( + expired_guard_session, + live_requests[0], + now=now + dt.timedelta(seconds=2), + ) + + recovery_token = uuid.uuid4() + async with sessions() as recovery_claim_session: + async with recovery_claim_session.begin(): + recovery_claim = await acquire_migration_run_attempt( + recovery_claim_session, + migration_run_uuid=created.migration_run_uuid, + worker_identity="recovered-real-postgres-worker", + signal_lease_token=recovery_token, + lease_seconds=60, + now=now + dt.timedelta(seconds=3), + ) + + await handler( + sessions, + MigrationRunSignalClaim( + created.migration_run_uuid, recovery_token + ), + recovery_claim, + ) + + async with sessions() as finish_session: + async with finish_session.begin(): + assert await finish_migration_run_attempt( + finish_session, + claim=recovery_claim, + worker_identity="recovered-real-postgres-worker", + signal_lease_token=recovery_token, + succeeded=True, + now=now + dt.timedelta(seconds=4), + ) + + async with sessions() as verify_session: + persisted_run = await verify_session.get( + MigrationRun, created.migration_run_uuid + ) + assert persisted_run is not None + assert persisted_run.state == "passed" + assert persisted_run.state_version == 4 + assert persisted_run.observed_base_digest == plan_json["base_digest"] + assert await verify_session.scalar( + select(func.count(MigrationRunEvent.migration_run_event_uuid)).where( + MigrationRunEvent.migration_run_uuid + == created.migration_run_uuid + ) + ) == 4 + persisted_attempts = list( + await verify_session.scalars( + select(MigrationRunAttempt) + .where( + MigrationRunAttempt.migration_run_uuid + == created.migration_run_uuid + ) + .order_by(MigrationRunAttempt.attempt_number) + ) + ) + assert [attempt.status for attempt in persisted_attempts] == [ + "abandoned", + "completed", + ] + assert [attempt.acquired_state_version for attempt in persisted_attempts] == [ + 1, + 3, + ] + + assert len(sandbox_requests) == 1 + assert len(live_requests) == 2 + assert sandbox_requests[0].migration_run_attempt_uuid == ( + attempt_claim.migration_run_attempt_uuid + ) + assert live_requests[0].migration_run_attempt_uuid == ( + attempt_claim.migration_run_attempt_uuid + ) + assert live_requests[1].migration_run_attempt_uuid == ( + recovery_claim.migration_run_attempt_uuid + ) + assert not hasattr(sandbox_requests[0], "db_connection_uuid") + assert all( + request.db_connection_uuid == connection_uuid + for request in live_requests + ) + assert [request.expected_state_version for request in live_requests] == [ + 3, + 3, + ] + assert capability_order == [ + "sandbox-enter", + "sandbox-exit", + "live-guard", + "live-crash", + "live-guard", + "live-enter", + "live-exit", + ] + finally: + async with sessions() as cleanup_session: + await _delete_project_fixture( + cleanup_session, project_space_uuid=project_uuid + ) + await cleanup_session.execute( + text( + "DELETE FROM user_account " + "WHERE user_account_uuid = :user_account_uuid" + ), + {"user_account_uuid": user_uuid}, + ) + await cleanup_session.commit() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_real_postgres_concurrent_duplicate_apply_intents_choose_one_winner() -> None: + """Prove concurrent same-key apply requests persist one execution-free intent.""" + + assert _POSTGRES_URL is not None + engine = create_async_engine(_POSTGRES_URL) + sessions = async_sessionmaker(engine, expire_on_commit=False) + now = dt.datetime.now(dt.timezone.utc) + user_uuid = uuid.uuid4() + project_uuid = uuid.uuid4() + connection_uuid = uuid.uuid4() + snapshot_uuid = uuid.uuid4() + model_uuid = uuid.uuid4() + revision_uuid = uuid.uuid4() + plan_uuid = uuid.uuid4() + passed_run_uuid = uuid.uuid4() + assert _EXPECTED_MAJOR is not None + base_model, target_model = _migration_models_with_precondition( + int(_EXPECTED_MAJOR) + ) + plan_json = compile_migration_plan(base_model, target_model) + + try: + async with sessions() as setup_session: + setup_session.add( + UserAccount( + user_account_uuid=user_uuid, + oidc_subject=f"concurrent-integration:{user_uuid}", + display_name="Concurrent PostgreSQL integration", + created_at=now, + ) + ) + await setup_session.flush() + setup_session.add( + ProjectSpace( + project_space_uuid=project_uuid, + project_name="concurrent migration run integration", + created_by_user_uuid=user_uuid, + created_at=now, + ) + ) + await setup_session.flush() + setup_session.add_all( + [ + DbConnection( + db_connection_uuid=connection_uuid, + project_space_uuid=project_uuid, + conn_name="concurrent integration target", + dsn_ciphertext=b"not-used", + dsn_nonce=b"not-used", + created_at=now, + updated_at=now, + ), + SchemaSnapshot( + schema_snapshot_uuid=snapshot_uuid, + project_space_uuid=project_uuid, + db_connection_uuid=connection_uuid, + status="succeeded", + schema_filter=None, + started_at=now, + finished_at=now, + error_message=None, + created_at=now, + ), + SchemaModel( + schema_model_uuid=model_uuid, + project_space_uuid=project_uuid, + model_name="concurrent_integration_model", + current_revision_number=1, + created_by_user_uuid=user_uuid, + created_at=now, + updated_at=now, + ), + ] + ) + await setup_session.flush() + setup_session.add( + SchemaModelRevision( + schema_model_revision_uuid=revision_uuid, + schema_model_uuid=model_uuid, + revision_number=1, + revision_digest=plan_json["target_digest"], + model_json=target_model, + base_schema_snapshot_uuid=snapshot_uuid, + created_by_user_uuid=user_uuid, + created_at=now, + ) + ) + await setup_session.flush() + setup_session.add( + MigrationPlan( + migration_plan_uuid=plan_uuid, + project_space_uuid=project_uuid, + schema_model_revision_uuid=revision_uuid, + db_connection_uuid=connection_uuid, + base_schema_snapshot_uuid=snapshot_uuid, + compiler_version=plan_json["compiler_version"], + base_digest=plan_json["base_digest"], + target_digest=plan_json["target_digest"], + statement_digest=plan_json["plan_digest"], + plan_json=plan_json, + created_by_user_uuid=user_uuid, + expires_at=now + dt.timedelta(hours=1), + created_at=now, + ) + ) + setup_session.add( + MigrationRun( + migration_run_uuid=passed_run_uuid, + project_space_uuid=project_uuid, + migration_plan_uuid=plan_uuid, + passed_dry_run_uuid=None, + run_kind="dry_run", + state="passed", + state_version=1, + idempotency_key_hash="a" * 64, + plan_digest=plan_json["plan_digest"], + request_digest="b" * 64, + confirmation_digest=None, + destructive_confirmation=None, + latest_event_digest="c" * 64, + requested_by_user_uuid=user_uuid, + cancellation_requested=False, + observed_base_digest=plan_json["base_digest"], + evidence_json={}, + error_code=None, + created_at=now, + updated_at=now, + started_at=now, + finished_at=now, + ) + ) + await setup_session.commit() + + first_session = sessions() + second_session = sessions() + try: + first_plan = await first_session.get(MigrationPlan, plan_uuid) + second_plan = await second_session.get(MigrationPlan, plan_uuid) + first_passed_run = await first_session.get( + MigrationRun, passed_run_uuid + ) + second_passed_run = await second_session.get( + MigrationRun, passed_run_uuid + ) + first_connection = await first_session.get( + DbConnection, connection_uuid + ) + second_connection = await second_session.get( + DbConnection, connection_uuid + ) + first_revision = await first_session.get( + SchemaModelRevision, revision_uuid + ) + second_revision = await second_session.get( + SchemaModelRevision, revision_uuid + ) + first_model = await first_session.get(SchemaModel, model_uuid) + second_model = await second_session.get(SchemaModel, model_uuid) + assert first_plan is not None + assert second_plan is not None + assert first_passed_run is not None + assert second_passed_run is not None + assert first_connection is not None + assert second_connection is not None + assert first_revision is not None + assert second_revision is not None + assert first_model is not None + assert second_model is not None + second_backend_pid = await second_session.scalar( + text("SELECT pg_backend_pid()") + ) + assert isinstance(second_backend_pid, int) + first = await create_migration_run( + first_session, + plan=first_plan, + run_kind="apply", + idempotency_key="concurrent-real-postgres-apply-key", + requested_by_user_uuid=user_uuid, + evidence={"request_source": "concurrent_postgresql_matrix"}, + passed_dry_run=first_passed_run, + connection=first_connection, + typed_connection_name=first_connection.conn_name, + destructive_acknowledged=bool( + plan_json["requires_destructive_confirmation"] + ), + model_revision=first_revision, + schema_model=first_model, + now=now, + ) + await first_session.flush() + second_task = asyncio.create_task( + create_migration_run( + second_session, + plan=second_plan, + run_kind="apply", + idempotency_key="concurrent-real-postgres-apply-key", + requested_by_user_uuid=user_uuid, + evidence={ + "request_source": "concurrent_postgresql_matrix" + }, + passed_dry_run=second_passed_run, + connection=second_connection, + typed_connection_name=second_connection.conn_name, + destructive_acknowledged=bool( + plan_json["requires_destructive_confirmation"] + ), + model_revision=second_revision, + schema_model=second_model, + now=now, + ) + ) + async with sessions() as observer_session: + for _ in range(500): + waiting_on_winner = await observer_session.scalar( + text( + "SELECT EXISTS (" + "SELECT 1 FROM pg_catalog.pg_stat_activity " + "WHERE pid = :pid AND wait_event_type = 'Lock'" + ")" + ), + {"pid": second_backend_pid}, + ) + if waiting_on_winner: + break + assert not second_task.done(), ( + "duplicate insert completed before the winner committed" + ) + await asyncio.sleep(0.01) + else: + pytest.fail( + "duplicate insert did not wait on the concurrent winner" + ) + await first_session.commit() + second = await asyncio.wait_for(second_task, timeout=5) + await second_session.commit() + finally: + await first_session.close() + await second_session.close() + + assert second.reused is True + assert second.migration_run_uuid == first.migration_run_uuid + async with sessions() as verify_session: + assert await verify_session.scalar( + select(func.count(MigrationRun.migration_run_uuid)).where( + MigrationRun.project_space_uuid == project_uuid, + MigrationRun.run_kind == "apply", + ) + ) == 1 + assert await verify_session.scalar( + select(func.count(MigrationRunEvent.migration_run_event_uuid)).where( + MigrationRunEvent.migration_run_uuid + == first.migration_run_uuid + ) + ) == 1 + assert await verify_session.scalar( + select(func.count(MigrationRunDispatch.migration_run_uuid)).where( + MigrationRunDispatch.migration_run_uuid + == first.migration_run_uuid + ) + ) == 0 + finally: + async with sessions() as cleanup_session: + await _delete_project_fixture( + cleanup_session, project_space_uuid=project_uuid + ) + await cleanup_session.execute( + text( + "DELETE FROM user_account " + "WHERE user_account_uuid = :user_account_uuid" + ), + {"user_account_uuid": user_uuid}, + ) + await cleanup_session.commit() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_real_postgres_and_valkey_recover_failure_and_crash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prove dual-lease failure/retry and crash takeover across both stores.""" + + assert _POSTGRES_URL is not None + if not _VALKEY_URL: + pytest.skip("VALKEY_INTEGRATION_URL is not configured") + engine = create_async_engine(_POSTGRES_URL) + connection = await engine.connect() + outer_transaction = await connection.begin() + sessions = async_sessionmaker( + connection, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + ) + now = dt.datetime.now(dt.timezone.utc) + user_uuid = uuid.uuid4() + project_uuid = uuid.uuid4() + connection_uuid = uuid.uuid4() + snapshot_uuid = uuid.uuid4() + model_uuid = uuid.uuid4() + revision_uuid = uuid.uuid4() + plan_uuid = uuid.uuid4() + assert _EXPECTED_MAJOR is not None + expected_major = int(_EXPECTED_MAJOR) + base_model, target_model = _migration_models_with_precondition(expected_major) + plan_json = compile_migration_plan(base_model, target_model) + assert plan_json["statements"][0]["preconditions"] == [ + { + "kind": "table_is_empty", + "schema_name": "public", + "table_name": "accounts", + } + ] + + suffix = uuid.uuid4().hex + queue_key = f"pg-erd-cloud:test:migration:{suffix}" + processing_key = f"pg-erd-cloud:test:migration-processing:{suffix}" + lease_token_key = f"pg-erd-cloud:test:migration-lease:{suffix}" + monkeypatch.setattr(settings, "job_queue_backend", "valkey") + monkeypatch.setattr(settings, "valkey_url", _VALKEY_URL) + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + monkeypatch.setattr(settings, "valkey_migration_run_queue_key", queue_key) + monkeypatch.setattr( + settings, "valkey_migration_run_processing_key", processing_key + ) + monkeypatch.setattr( + settings, "valkey_migration_run_lease_token_key", lease_token_key + ) + redis_asyncio = valkey_queue._load_redis_module() + client = redis_asyncio.from_url(_VALKEY_URL) + + try: + await client.delete(queue_key, processing_key, lease_token_key) + async with sessions() as session: + server_version_num = int( + await session.scalar( + text("SELECT current_setting('server_version_num')") + ) + ) + assert server_version_num // 10_000 == expected_major + session.add( + UserAccount( + user_account_uuid=user_uuid, + oidc_subject=f"integration:{user_uuid}", + display_name="PostgreSQL integration", + created_at=now, + ) + ) + await session.flush() + session.add( + ProjectSpace( + project_space_uuid=project_uuid, + project_name="migration run integration", + created_by_user_uuid=user_uuid, + created_at=now, + ) + ) + await session.flush() + session.add( + DbConnection( + db_connection_uuid=connection_uuid, + project_space_uuid=project_uuid, + conn_name="integration target", + dsn_ciphertext=b"not-used", + dsn_nonce=b"not-used", + created_at=now, + updated_at=now, + ) + ) + await session.flush() + session.add_all( + [ + SchemaSnapshot( + schema_snapshot_uuid=snapshot_uuid, + project_space_uuid=project_uuid, + db_connection_uuid=connection_uuid, + status="succeeded", + schema_filter=None, + started_at=now, + finished_at=now, + error_message=None, + created_at=now, + ), + SchemaModel( + schema_model_uuid=model_uuid, + project_space_uuid=project_uuid, + model_name="integration_model", + current_revision_number=1, + created_by_user_uuid=user_uuid, + created_at=now, + updated_at=now, + ), + ] + ) + await session.flush() + session.add( + SchemaModelRevision( + schema_model_revision_uuid=revision_uuid, + schema_model_uuid=model_uuid, + revision_number=1, + revision_digest=plan_json["target_digest"], + model_json=target_model, + base_schema_snapshot_uuid=snapshot_uuid, + created_by_user_uuid=user_uuid, + created_at=now, + ) + ) + await session.flush() + plan = MigrationPlan( + migration_plan_uuid=plan_uuid, + project_space_uuid=project_uuid, + schema_model_revision_uuid=revision_uuid, + db_connection_uuid=connection_uuid, + base_schema_snapshot_uuid=snapshot_uuid, + compiler_version=plan_json["compiler_version"], + base_digest=plan_json["base_digest"], + target_digest=plan_json["target_digest"], + statement_digest=plan_json["plan_digest"], + plan_json=plan_json, + created_by_user_uuid=user_uuid, + expires_at=now + dt.timedelta(hours=1), + created_at=now, + ) + session.add(plan) + await session.flush() + + first = await create_migration_run( + session, + plan=plan, + run_kind="dry_run", + idempotency_key="real-postgres-retry-key", + requested_by_user_uuid=user_uuid, + evidence={"request_source": "postgresql_matrix"}, + now=now, + ) + await session.flush() + reused = await create_migration_run( + session, + plan=plan, + run_kind="dry_run", + idempotency_key="real-postgres-retry-key", + requested_by_user_uuid=user_uuid, + evidence={"request_source": "postgresql_matrix"}, + now=now, + ) + await session.flush() + + assert reused.migration_run_uuid == first.migration_run_uuid + assert reused.reused is True + assert await session.scalar( + select(func.count(MigrationRun.migration_run_uuid)).where( + MigrationRun.migration_run_uuid == first.migration_run_uuid + ) + ) == 1 + assert await session.scalar( + select(func.count(MigrationRunEvent.migration_run_event_uuid)).where( + MigrationRunEvent.migration_run_uuid == first.migration_run_uuid + ) + ) == 1 + dispatch = await session.scalar( + select(MigrationRunDispatch).where( + MigrationRunDispatch.migration_run_uuid + == first.migration_run_uuid + ) + ) + assert dispatch is not None + assert dispatch.dispatch_kind == "isolated_dry_run" + assert dispatch.status == "pending" + assert dispatch.attempt_count == 0 + assert { + column.name for column in MigrationRunDispatch.__table__.columns + }.isdisjoint({"payload_json", "dsn", "sql", "plan_json"}) + claim = await claim_one_migration_dispatch(session, now=now) + assert claim is not None + assert claim.migration_run_uuid == first.migration_run_uuid + assert claim.migration_run_dispatch_uuid == ( + dispatch.migration_run_dispatch_uuid + ) + assert claim.dispatch_kind == "isolated_dry_run" + assert claim.attempt_count == 1 + await mark_migration_dispatch_published( + session, + claim=claim, + now=now + dt.timedelta(seconds=1), + ) + await session.refresh(dispatch) + assert dispatch.status == "published" + assert dispatch.attempt_count == 1 + assert dispatch.published_at == now + dt.timedelta(seconds=1) + + await session.commit() + assert await valkey_queue.enqueue_migration_run_signal( + first.migration_run_uuid, now + dt.timedelta(seconds=2) + ) + handler_calls = 0 + secret = "postgresql://owner:secret@target/private" + + async def fail_then_succeed(*_args: object) -> None: + nonlocal handler_calls + handler_calls += 1 + if handler_calls == 1: + raise RuntimeError(secret) + + handler = make_attempt_bound_migration_run_handler( + fail_then_succeed, + worker_identity="composed-postgres-valkey-worker", + attempt_lease_seconds=60, + ) + with pytest.raises(MigrationRunConsumerError) as failed: + await process_one_migration_run_signal( + sessions, + handler, + now=now + dt.timedelta(seconds=2), + retry_delay_s=1, + ) + assert str(failed.value) == "migration run handler failed" + assert secret not in repr(failed.value) + assert await process_one_migration_run_signal( + sessions, + handler, + now=now + dt.timedelta(seconds=3), + retry_delay_s=1, + ) + assert handler_calls == 2 + persisted_consumer_attempts = list( + await session.scalars( + select(MigrationRunAttempt) + .where( + MigrationRunAttempt.migration_run_uuid + == first.migration_run_uuid + ) + .order_by(MigrationRunAttempt.attempt_number) + ) + ) + assert [attempt.status for attempt in persisted_consumer_attempts] == [ + "abandoned", + "completed", + ] + assert [attempt.attempt_number for attempt in persisted_consumer_attempts] == [ + 1, + 2, + ] + assert all( + len(attempt.worker_identity_hash) == 64 + and len(attempt.signal_lease_token_hash) == 64 + for attempt in persisted_consumer_attempts + ) + assert await client.zrange(queue_key, 0, -1) == [] + assert await client.zrange(processing_key, 0, -1) == [] + assert await client.hlen(lease_token_key) == 0 + + crash_started_at = dt.datetime.now(dt.timezone.utc) + assert await valkey_queue.enqueue_migration_run_signal( + first.migration_run_uuid, crash_started_at + ) + crash_signal_claim = await valkey_queue.claim_due_migration_run_signal( + now=crash_started_at, + lease_seconds=1, + ) + assert crash_signal_claim is not None + async with sessions() as crash_session: + async with crash_session.begin(): + crash_attempt_claim = await acquire_migration_run_attempt( + crash_session, + migration_run_uuid=first.migration_run_uuid, + worker_identity="crashed-postgres-valkey-worker", + signal_lease_token=crash_signal_claim.lease_token, + lease_seconds=1, + now=crash_started_at, + ) + assert await renew_migration_run_attempt( + crash_session, + claim=crash_attempt_claim, + worker_identity="crashed-postgres-valkey-worker", + signal_lease_token=uuid.uuid4(), + lease_seconds=1, + now=crash_started_at, + ) is False + + await asyncio.sleep(1.1) + assert not await valkey_queue.renew_migration_run_signal( + crash_signal_claim, + now=dt.datetime.now(dt.timezone.utc), + lease_seconds=60, + ) + recovered_attempts: list[MigrationRunAttemptClaim] = [] + recovered_signals: list[valkey_queue.MigrationRunSignalClaim] = [] + + async def recover_to_pass( + factory: Callable[[], AsyncSession], + recovered_signal: valkey_queue.MigrationRunSignalClaim, + recovered_attempt: MigrationRunAttemptClaim, + ) -> None: + recovered_signals.append(recovered_signal) + recovered_attempts.append(recovered_attempt) + async with factory() as worker_session: + async with worker_session.begin(): + await transition_migration_run( + worker_session, + migration_run_uuid=first.migration_run_uuid, + expected_state_version=1, + next_state="sandbox_running", + event_type="sandbox_started", + evidence={"postgresql_major": expected_major}, + actor_user_uuid=None, + ) + async with factory() as worker_session: + async with worker_session.begin(): + await complete_isolated_dry_run( + worker_session, + migration_run_uuid=first.migration_run_uuid, + expected_state_version=2, + result={ + "postgresql_major": expected_major, + "statement_count": len(plan.plan_json["statements"]), + "base_digest": plan.base_digest, + "target_digest": plan.target_digest, + "converged": True, + }, + actor_user_uuid=None, + ) + async with factory() as worker_session: + async with worker_session.begin(): + await complete_live_preflight( + worker_session, + migration_run_uuid=first.migration_run_uuid, + expected_state_version=3, + result={ + "preconditions_passed": True, + "checks": [ + { + "statement_index": 0, + "precondition_index": 0, + "kind": "table_is_empty", + "passed": True, + } + ], + "observed_base_digest": plan.base_digest, + "matches_plan_base": True, + }, + actor_user_uuid=None, + ) + + recovery_handler = make_attempt_bound_migration_run_handler( + recover_to_pass, + worker_identity="recovered-postgres-valkey-worker", + attempt_lease_seconds=60, + ) + recovery_time = dt.datetime.now(dt.timezone.utc) + assert await process_one_migration_run_signal( + sessions, + recovery_handler, + now=recovery_time, + ) + assert len(recovered_attempts) == 1 + assert len(recovered_signals) == 1 + assert recovered_signals[0].lease_token != crash_signal_claim.lease_token + assert not await valkey_queue.ack_migration_run_signal( + crash_signal_claim + ) + + persisted_attempts = list( + await session.scalars( + select(MigrationRunAttempt) + .where( + MigrationRunAttempt.migration_run_uuid + == first.migration_run_uuid + ) + .order_by(MigrationRunAttempt.attempt_number) + ) + ) + assert [attempt.attempt_number for attempt in persisted_attempts] == [ + 1, + 2, + 3, + 4, + ] + assert [attempt.status for attempt in persisted_attempts] == [ + "abandoned", + "completed", + "abandoned", + "completed", + ] + assert all( + len(attempt.worker_identity_hash) == 64 + and len(attempt.signal_lease_token_hash) == 64 + for attempt in persisted_attempts + ) + persisted_run = await session.scalar( + select(MigrationRun).where( + MigrationRun.migration_run_uuid == first.migration_run_uuid + ) + ) + assert persisted_run is not None + assert persisted_run.state == "passed" + assert persisted_run.state_version == 4 + assert persisted_run.observed_base_digest == plan.base_digest + assert await renew_migration_run_attempt( + session, + claim=recovered_attempts[0], + worker_identity="recovered-postgres-valkey-worker", + signal_lease_token=recovered_signals[0].lease_token, + lease_seconds=60, + now=dt.datetime.now(dt.timezone.utc), + ) is False + assert await finish_migration_run_attempt( + session, + claim=recovered_attempts[0], + worker_identity="recovered-postgres-valkey-worker", + signal_lease_token=recovered_signals[0].lease_token, + succeeded=True, + now=dt.datetime.now(dt.timezone.utc), + ) is False + assert await session.scalar( + select(func.count(MigrationRunEvent.migration_run_event_uuid)).where( + MigrationRunEvent.migration_run_uuid == first.migration_run_uuid + ) + ) == 4 + assert await client.zrange(queue_key, 0, -1) == [] + assert await client.zrange(processing_key, 0, -1) == [] + assert await client.hlen(lease_token_key) == 0 + + target_connection = await session.get(DbConnection, connection_uuid) + assert target_connection is not None + model_revision = await session.get( + SchemaModelRevision, plan.schema_model_revision_uuid + ) + assert model_revision is not None + schema_model = await session.get( + SchemaModel, + model_revision.schema_model_uuid, + with_for_update=True, + ) + assert schema_model is not None + apply_intent = await create_migration_run( + session, + plan=plan, + run_kind="apply", + idempotency_key="real-postgres-apply-intent", + requested_by_user_uuid=user_uuid, + evidence={"request_source": "postgresql_matrix"}, + passed_dry_run=persisted_run, + connection=target_connection, + typed_connection_name="integration target", + destructive_acknowledged=False, + model_revision=model_revision, + schema_model=schema_model, + now=dt.datetime.now(dt.timezone.utc), + ) + await session.flush() + persisted_apply = await session.get( + MigrationRun, apply_intent.migration_run_uuid + ) + assert persisted_apply is not None + assert persisted_apply.run_kind == "apply" + assert persisted_apply.state == "queued" + assert persisted_apply.passed_dry_run_uuid == first.migration_run_uuid + assert len(persisted_apply.confirmation_digest or "") == 64 + assert persisted_apply.destructive_confirmation is False + assert await session.scalar( + select(func.count(MigrationRunDispatch.migration_run_uuid)).where( + MigrationRunDispatch.migration_run_uuid + == apply_intent.migration_run_uuid + ) + ) == 0 + + await session.commit() + + await outer_transaction.rollback() + + async with sessions() as session: + assert await session.scalar( + select(func.count(MigrationRun.migration_run_uuid)).where( + MigrationRun.migration_run_uuid == first.migration_run_uuid + ) + ) == 0 + assert await session.scalar( + select(func.count(MigrationRunDispatch.migration_run_uuid)).where( + MigrationRunDispatch.migration_run_uuid + == first.migration_run_uuid + ) + ) == 0 + assert await session.scalar( + select(func.count(MigrationRunAttempt.migration_run_uuid)).where( + MigrationRunAttempt.migration_run_uuid + == first.migration_run_uuid + ) + ) == 0 + finally: + if outer_transaction.is_active: + await outer_transaction.rollback() + await client.delete(queue_key, processing_key, lease_token_key) + await valkey_queue._close_client(client) + await connection.close() + await engine.dispose() diff --git a/backend/tests/test_request_validation.py b/backend/tests/test_request_validation.py new file mode 100644 index 000000000..3956947f1 --- /dev/null +++ b/backend/tests/test_request_validation.py @@ -0,0 +1,140 @@ +"""Secret-safe request validation response contracts.""" + +import uuid +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from app.api.connections import router as connections_router +from app.auth import CurrentUser, get_current_user +from app.db import get_session +from app.request_validation import request_validation_exception_handler +from app.schemas import ApplySqlIn, DbmlConvertIn + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +CONTROL_BOUNDARY_DOC = ( + REPOSITORY_ROOT / "docs/doctoring/multiline-sql-request-controls.md" +) + + +def _validation_app() -> FastAPI: + app = FastAPI() + app.add_exception_handler( + RequestValidationError, + request_validation_exception_handler, + ) + + @app.post( + "/api/connections/00000000-0000-4000-8000-000000000001/apply-sql" + ) + async def accept_sql(_: ApplySqlIn) -> dict[str, bool]: + return {"ok": True} + + @app.post("/api/dbml/convert") + async def accept_dbml(_: DbmlConvertIn) -> dict[str, bool]: + return {"ok": True} + + return app + + +def test_validation_response_omits_hostile_sql_input( + caplog: pytest.LogCaptureFixture, +) -> None: + """Never echo rejected SQL or secret-like literals in a 422 response.""" + + sensitive_marker = "password=do-not-reflect" + response = TestClient(_validation_app()).post( + "/api/connections/00000000-0000-4000-8000-000000000001/apply-sql", + json={"sql": f"CREATE\x00TABLE sample (note text DEFAULT '{sensitive_marker}')"}, + ) + + assert response.status_code == 422 + assert response.json() == {"detail": "request validation failed"} + assert sensitive_marker not in response.text + assert "CREATE" not in response.text + assert sensitive_marker not in caplog.text + + +def test_invalid_legacy_apply_body_stops_before_auth_or_session() -> None: + """Reject forbidden SQL transport controls before protected dependencies.""" + + dependency_calls: list[str] = [] + + def current_user() -> CurrentUser: + dependency_calls.append("auth") + return CurrentUser( + user_account_uuid=uuid.uuid4(), + subject="must-not-run", + display_name=None, + ) + + def session() -> object: + dependency_calls.append("session") + return object() + + app = FastAPI() + app.add_exception_handler( + RequestValidationError, + request_validation_exception_handler, + ) + app.include_router(connections_router) + app.dependency_overrides[get_current_user] = current_user + app.dependency_overrides[get_session] = session + + response = TestClient(app).post( + f"/api/connections/{uuid.uuid4()}/apply-sql", + json={"sql": "CREATE\x00TABLE secret_data (id bigint)"}, + ) + + assert response.status_code == 422 + assert response.json() == {"detail": "request validation failed"} + assert dependency_calls == [] + + +def test_production_app_registers_secret_safe_validation_handler() -> None: + """Keep the sensitive-body response handler active in production wiring.""" + + from app.main import app as production_app + + assert ( + production_app.exception_handlers[RequestValidationError] + is request_validation_exception_handler + ) + + +def test_legacy_apply_control_boundary_is_documented_without_overclaim() -> None: + """Keep request ordering, non-reflection, and parser authority explicit.""" + + document = CONTROL_BOUNDARY_DOC.read_text(encoding="utf-8") + normalized = " ".join(document.split()) + + for term in ( + "SecretSafeLegacyApplyRoute", + "before `get_current_user` and `get_session`", + "RequestValidationError.body", + "does not authorize SQL", + "Towards Secure Logging", + "https://arxiv.org/abs/2604.20211", + ): + assert term in normalized + + +def test_validation_response_omits_oversized_dbml_input( + caplog: pytest.LogCaptureFixture, +) -> None: + """Never echo rejected DBML from the bounded conversion endpoint.""" + + sensitive_marker = "dbml-secret-do-not-reflect" + response = TestClient(_validation_app()).post( + "/api/dbml/convert", + json={"dbml": sensitive_marker + ("x" * 524_288)}, + ) + + assert response.status_code == 422 + assert response.json() == {"detail": "request validation failed"} + assert sensitive_marker not in response.text + assert sensitive_marker not in caplog.text diff --git a/backend/tests/test_schema_validation.py b/backend/tests/test_schema_validation.py index 317292b86..1b6c3e806 100644 --- a/backend/tests/test_schema_validation.py +++ b/backend/tests/test_schema_validation.py @@ -1,9 +1,28 @@ from __future__ import annotations +import uuid + import pytest from pydantic import ValidationError -from app.schemas import ConnectionCreateIn, ProjectCreateIn, ProjectMemberAddIn +from app.schemas import ( + ApplySqlIn, + ConnectionCreateIn, + MigrationApplyRunCreateIn, + MigrationRunCancelIn, + MigrationRunCreateIn, + ProjectCreateIn, + ProjectMemberAddIn, +) + + +_DISALLOWED_MULTILINE_TEXT_CODE_POINTS = ( + *range(0x00, 0x09), + 0x0B, + 0x0C, + *range(0x0E, 0x20), + 0x7F, +) def test_project_name_length_is_bounded() -> None: @@ -25,6 +44,13 @@ def test_member_subject_rejects_control_or_whitespace() -> None: ProjectMemberAddIn(member_subject="dev:bad\x00user", project_role="viewer") +def test_deployer_is_an_assignable_non_owner_role() -> None: + payload = ProjectMemberAddIn( + member_subject="dev:release-engineer", project_role="deployer" + ) + assert payload.project_role == "deployer" + + def test_connection_payload_lengths_are_bounded() -> None: with pytest.raises(ValidationError): ConnectionCreateIn(conn_name="x" * 129, dsn="postgresql://localhost/db") @@ -37,3 +63,86 @@ def test_conn_name_rejects_control_characters() -> None: ConnectionCreateIn(conn_name="my\x00conn", dsn="postgresql://localhost/db") with pytest.raises(ValidationError): ConnectionCreateIn(conn_name="my\nconn", dsn="postgresql://localhost/db") + + +@pytest.mark.parametrize("code_point", _DISALLOWED_MULTILINE_TEXT_CODE_POINTS) +@pytest.mark.parametrize("position", ["beginning", "middle", "end"]) +def test_apply_sql_rejects_non_text_controls_at_every_position( + code_point: int, position: str +) -> None: + """Reject every disallowed C0 and DEL transport control position.""" + + ddl = "CREATE TABLE \"고객\" (note text DEFAULT 'safe');\n" + control = chr(code_point) + hostile = { + "beginning": control + ddl, + "middle": ddl[:12] + control + ddl[12:], + "end": ddl + control, + }[position] + + with pytest.raises(ValidationError) as exc_info: + ApplySqlIn(sql=hostile) + + assert exc_info.value.errors()[0]["type"] == "string_pattern_mismatch" + + +@pytest.mark.parametrize("allowed", ["\t", "\n", "\r", " ", "\u0080", "한"]) +def test_apply_sql_preserves_multiline_text_boundaries(allowed: str) -> None: + """Preserve text whitespace and Unicode at the transport boundary.""" + + sql = f"CREATE TABLE \"고객\" ({allowed}note text);\r\n-- 설명" + assert ApplySqlIn(sql=sql).sql == sql + + +def test_apply_sql_preserves_existing_length_limit() -> None: + """Keep the accepted 256-KiB character boundary exact.""" + + assert len(ApplySqlIn(sql="x" * 262_144).sql) == 262_144 + with pytest.raises(ValidationError): + ApplySqlIn(sql="x" * 262_145) + + +@pytest.mark.parametrize("version", [True, 0, -1, 1.5, "1"]) +def test_migration_run_cancel_requires_a_strict_positive_version( + version: object, +) -> None: + """CAS versions cannot be coerced from booleans, strings, or decimals.""" + + with pytest.raises(ValidationError): + MigrationRunCancelIn(expected_state_version=version) + + +@pytest.mark.parametrize("digest", ["", "a" * 63, "A" * 64, "g" * 64]) +def test_migration_run_create_rejects_invalid_plan_digest(digest: str) -> None: + """Public run creation accepts only a lowercase SHA-256 plan identity.""" + + with pytest.raises(ValidationError): + MigrationRunCreateIn(plan_digest=digest) + + +def test_apply_run_create_requires_exact_review_confirmation_shape() -> None: + """Apply intent input is typed and cannot omit explicit destructive intent.""" + + passed_uuid = uuid.uuid4() + value = MigrationApplyRunCreateIn( + plan_digest="a" * 64, + passed_dry_run_uuid=passed_uuid, + target_connection_name='Production "Primary"', + destructive_acknowledged=False, + ) + assert value.passed_dry_run_uuid == passed_uuid + with pytest.raises(ValidationError): + MigrationApplyRunCreateIn( + plan_digest="a" * 64, + passed_dry_run_uuid=passed_uuid, + target_connection_name="", + destructive_acknowledged=False, + ) + with pytest.raises(ValidationError, match="destructive_acknowledge"): + MigrationApplyRunCreateIn( + plan_digest="a" * 64, + passed_dry_run_uuid=passed_uuid, + target_connection_name="Production Primary", + destructive_acknowledged=False, + destructive_acknowledge=True, + ) diff --git a/backend/tests/test_security_headers.py b/backend/tests/test_security_headers.py index 3d21af212..03c675c2c 100644 --- a/backend/tests/test_security_headers.py +++ b/backend/tests/test_security_headers.py @@ -1,13 +1,13 @@ from __future__ import annotations -from fastapi import FastAPI +from fastapi import FastAPI, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.testclient import TestClient from starlette.requests import Request from app import security_headers from app.csrf import CSRF_HEADER_NAME -from app.main import CORS_ALLOW_HEADERS +from app.main import CORS_ALLOW_HEADERS, CORS_EXPOSE_HEADERS from app.security_headers import make_security_headers_middleware @@ -130,6 +130,94 @@ def create_project() -> dict[str, bool]: assert CSRF_HEADER_NAME.lower() in r.headers["Access-Control-Allow-Headers"].lower() +def test_cors_preflight_allows_if_match_for_schema_revisions() -> None: + """Cross-origin optimistic-concurrency updates must pass preflight.""" + app = FastAPI() + + @app.put("/api/schema-models/example") + def revise_schema_model() -> dict[str, bool]: + return {"ok": True} + + app.add_middleware( + CORSMiddleware, + allow_origins=["http://example.com"], + allow_credentials=False, + allow_methods=["PUT", "OPTIONS"], + allow_headers=CORS_ALLOW_HEADERS, + ) + + response = TestClient(app).options( + "/api/schema-models/example", + headers={ + "Origin": "http://example.com", + "Access-Control-Request-Method": "PUT", + "Access-Control-Request-Headers": "If-Match, Content-Type", + }, + ) + + assert response.status_code in (200, 204) + assert "if-match" in response.headers["Access-Control-Allow-Headers"].lower() + + +def test_cors_preflight_allows_dry_run_idempotency_key() -> None: + """Browser dry-run intent submission can carry its concurrency identity.""" + + app = FastAPI() + + @app.post("/api/migration-plans/example/dry-runs") + def create_dry_run() -> dict[str, bool]: + return {"ok": True} + + app.add_middleware( + CORSMiddleware, + allow_origins=["http://example.com"], + allow_credentials=False, + allow_methods=["POST", "OPTIONS"], + allow_headers=CORS_ALLOW_HEADERS, + ) + + response = TestClient(app).options( + "/api/migration-plans/example/dry-runs", + headers={ + "Origin": "http://example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Idempotency-Key, Content-Type", + }, + ) + + assert response.status_code in (200, 204) + assert ( + "idempotency-key" + in response.headers["Access-Control-Allow-Headers"].lower() + ) + + +def test_cors_exposes_strong_revision_etag_to_browser_clients() -> None: + """Cross-origin clients must be able to read the token used by If-Match.""" + app = FastAPI() + + @app.get("/api/schema-models/example") + def get_schema_model(response: Response) -> dict[str, bool]: + response.headers["ETag"] = '"revision-uuid"' + return {"ok": True} + + app.add_middleware( + CORSMiddleware, + allow_origins=["http://example.com"], + allow_credentials=False, + allow_methods=["GET"], + allow_headers=CORS_ALLOW_HEADERS, + expose_headers=CORS_EXPOSE_HEADERS, + ) + + response = TestClient(app).get( + "/api/schema-models/example", headers={"Origin": "http://example.com"} + ) + + assert response.headers["ETag"] == '"revision-uuid"' + assert "etag" in response.headers["Access-Control-Expose-Headers"].lower() + + def test_csp_not_applied_to_fastapi_docs_endpoints() -> None: """Swagger UI should not be broken by an overly strict CSP.""" app = FastAPI() # includes /docs by default diff --git a/backend/tests/test_valkey_queue.py b/backend/tests/test_valkey_queue.py index 7a729368c..7f37ef174 100644 --- a/backend/tests/test_valkey_queue.py +++ b/backend/tests/test_valkey_queue.py @@ -2,6 +2,9 @@ import datetime as dt import uuid +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock import pytest @@ -9,6 +12,12 @@ from app.settings import settings +def _enable_url_valkey(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "job_queue_backend", "valkey") + monkeypatch.setattr(settings, "valkey_url", "redis://127.0.0.1:6379/0") + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + + def test_valkey_queue_summary_uses_sentinel_without_secrets( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -27,6 +36,15 @@ def test_valkey_queue_summary_uses_sentinel_without_secrets( assert summary["mode"] == "sentinel" assert summary["sentinel_master"] == "mymaster" assert summary["sentinel_count"] == 2 + assert summary["migration_run_queue_key"] == ( + settings.valkey_migration_run_queue_key + ) + assert summary["migration_run_processing_key"] == ( + settings.valkey_migration_run_processing_key + ) + assert summary["migration_run_signal_lease_seconds"] == ( + settings.migration_run_signal_lease_seconds + ) assert "valkey-a.local:26379" not in str(summary) @@ -51,6 +69,587 @@ def missing_module(_name: str) -> object: assert ok is False +def test_valkey_modes_and_host_formatting(monkeypatch: pytest.MonkeyPatch) -> None: + """Configuration helpers cover disabled, URL, and empty sentinel entries.""" + + monkeypatch.setattr(settings, "job_queue_backend", "valkey") + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + monkeypatch.setattr(settings, "valkey_url", "redis://valkey:6379/0") + assert valkey_queue.valkey_queue_enabled() is True + assert valkey_queue.valkey_queue_mode() == "url" + + monkeypatch.setattr(settings, "valkey_url", None) + assert valkey_queue.valkey_queue_enabled() is False + assert valkey_queue.valkey_queue_mode() == "disabled" + assert valkey_queue._parse_sentinel_hosts(None) == [] + assert valkey_queue._parse_sentinel_hosts(" , valkey:26379, ") == [ + ("valkey", 26379) + ] + assert valkey_queue.format_sentinel_hosts([("a", 1), ("b", 2)]) == "a:1,b:2" + + monkeypatch.setattr(settings, "job_queue_backend", "database") + monkeypatch.setattr(settings, "valkey_url", "redis://valkey:6379/0") + assert valkey_queue.valkey_queue_enabled() is False + + +@pytest.mark.parametrize("raw", [":26379", "valkey:0", "valkey:65536"]) +def test_valkey_queue_rejects_invalid_sentinel_components(raw: str) -> None: + with pytest.raises(ValueError): + valkey_queue._parse_sentinel_hosts(raw) + + +def test_load_redis_module_success_and_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = object() + monkeypatch.setattr(valkey_queue.importlib, "import_module", lambda _name: module) + assert valkey_queue._load_redis_module() is module + + def missing(_name: str) -> object: + raise ModuleNotFoundError("redis") + + monkeypatch.setattr(valkey_queue.importlib, "import_module", missing) + with pytest.raises(valkey_queue.ValkeyQueueUnavailable, match="redis-py"): + valkey_queue._load_redis_module() + + +@pytest.mark.asyncio +async def test_client_supports_url_and_sentinel_modes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + redis_client = object() + redis_module = SimpleNamespace(from_url=MagicMock(return_value=redis_client)) + monkeypatch.setattr(valkey_queue, "_load_redis_module", lambda: redis_module) + _enable_url_valkey(monkeypatch) + assert await valkey_queue._client() is redis_client + redis_module.from_url.assert_called_once_with(settings.valkey_url) + + sentinel_client = object() + sentinel = SimpleNamespace(master_for=MagicMock(return_value=sentinel_client)) + sentinel_class = MagicMock(return_value=sentinel) + sentinel_module = SimpleNamespace(Sentinel=sentinel_class) + monkeypatch.setattr( + valkey_queue.importlib, + "import_module", + lambda name: sentinel_module if name == "redis.asyncio.sentinel" else object(), + ) + monkeypatch.setattr(settings, "valkey_url", None) + monkeypatch.setattr(settings, "valkey_sentinel_hosts", "sentinel:26379") + monkeypatch.setattr(settings, "valkey_sentinel_master", "primary") + assert await valkey_queue._client() is sentinel_client + sentinel_class.assert_called_once_with([("sentinel", 26379)]) + sentinel.master_for.assert_called_once_with("primary") + + +@pytest.mark.asyncio +async def test_client_rejects_incomplete_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(valkey_queue, "_load_redis_module", object) + monkeypatch.setattr(settings, "valkey_url", None) + monkeypatch.setattr(settings, "valkey_sentinel_hosts", "sentinel:26379") + monkeypatch.setattr(settings, "valkey_sentinel_master", None) + with pytest.raises(ValueError, match="VALKEY_SENTINEL_MASTER"): + await valkey_queue._client() + + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + with pytest.raises(ValueError, match="VALKEY_URL"): + await valkey_queue._client() + + +@pytest.mark.asyncio +async def test_close_client_supports_absent_sync_and_async_close() -> None: + await valkey_queue._close_client(SimpleNamespace()) + + close = MagicMock(return_value=None) + await valkey_queue._close_client(SimpleNamespace(close=close)) + close.assert_called_once_with() + + aclose = AsyncMock() + await valkey_queue._close_client(SimpleNamespace(aclose=aclose)) + aclose.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_migration_signal_contains_only_run_uuid_on_separate_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Migration signals cannot collide with generic job-queue identities.""" + + monkeypatch.setattr(settings, "job_queue_backend", "valkey") + monkeypatch.setattr(settings, "valkey_url", "redis://127.0.0.1:6379/0") + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + run_uuid = uuid.uuid4() + run_after = dt.datetime(2026, 8, 11, 6, tzinfo=dt.timezone.utc) + client = SimpleNamespace(zadd=AsyncMock(), aclose=AsyncMock()) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=client)) + + ok = await valkey_queue.enqueue_migration_run_signal(run_uuid, run_after) + + assert ok is True + client.zadd.assert_awaited_once_with( + settings.valkey_migration_run_queue_key, + {str(run_uuid): run_after.timestamp()}, + ) + assert settings.valkey_migration_run_queue_key != settings.valkey_queue_key + client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_migration_signal_is_disabled_without_valkey( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A disabled optional queue performs no client I/O.""" + + monkeypatch.setattr(settings, "job_queue_backend", "database") + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + + assert await valkey_queue.enqueue_migration_run_signal(uuid.uuid4()) is False + client_factory.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migration_signal_rejects_naive_schedule_before_client_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Queue scores require an unambiguous instant.""" + + monkeypatch.setattr(settings, "job_queue_backend", "valkey") + monkeypatch.setattr(settings, "valkey_url", "redis://127.0.0.1:6379/0") + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + + with pytest.raises(ValueError, match="timezone"): + await valkey_queue.enqueue_migration_run_signal( + uuid.uuid4(), dt.datetime(2026, 8, 11, 6) + ) + + client_factory.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migration_signal_failure_is_closed_and_reported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A queue write failure remains retryable and always releases the client.""" + + monkeypatch.setattr(settings, "job_queue_backend", "valkey") + monkeypatch.setattr(settings, "valkey_url", "redis://127.0.0.1:6379/0") + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + client = SimpleNamespace( + zadd=AsyncMock(side_effect=ConnectionError("queue unavailable")), + aclose=AsyncMock(), + ) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=client)) + + assert await valkey_queue.enqueue_migration_run_signal(uuid.uuid4()) is False + client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_migration_signal_claim_uses_exact_bounded_lease( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A due UUID moves to processing under one opaque lease token.""" + + _enable_url_valkey(monkeypatch) + run_uuid = uuid.uuid4() + now = dt.datetime(2026, 8, 11, 6, tzinfo=dt.timezone.utc) + client = SimpleNamespace( + eval=AsyncMock(return_value=str(run_uuid).encode()), + aclose=AsyncMock(), + ) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=client)) + monkeypatch.setattr(valkey_queue.uuid, "uuid4", lambda: uuid.UUID(int=7)) + + claim = await valkey_queue.claim_due_migration_run_signal( + now=now, lease_seconds=15.0 + ) + + assert claim == valkey_queue.MigrationRunSignalClaim( + migration_run_uuid=run_uuid, + lease_token=uuid.UUID(int=7), + ) + client.eval.assert_awaited_once_with( + valkey_queue._CLAIM_MIGRATION_RUN_SIGNAL_SCRIPT, + 3, + settings.valkey_migration_run_queue_key, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + now.timestamp(), + now.timestamp() + 15.0, + str(uuid.UUID(int=7)), + valkey_queue.MAX_EXPIRED_SIGNAL_RECLAIMS, + ) + client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_migration_signal_ack_and_release_require_exact_lease_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stale worker cannot acknowledge or reschedule a successor lease.""" + + _enable_url_valkey(monkeypatch) + claim = valkey_queue.MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + retry_at = dt.datetime(2026, 8, 11, 6, 1, tzinfo=dt.timezone.utc) + client = SimpleNamespace( + eval=AsyncMock(side_effect=[0, 1, 1]), + aclose=AsyncMock(), + ) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=client)) + + stale = replace(claim, lease_token=uuid.uuid4()) + assert await valkey_queue.ack_migration_run_signal(stale) is False + assert await valkey_queue.release_migration_run_signal(claim, retry_at) is True + assert await valkey_queue.ack_migration_run_signal(claim) is True + + assert client.eval.await_args_list[0].args == ( + valkey_queue._ACK_MIGRATION_RUN_SIGNAL_SCRIPT, + 2, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + str(claim.migration_run_uuid), + str(stale.lease_token), + ) + assert client.eval.await_args_list[1].args == ( + valkey_queue._RELEASE_MIGRATION_RUN_SIGNAL_SCRIPT, + 3, + settings.valkey_migration_run_queue_key, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + str(claim.migration_run_uuid), + str(claim.lease_token), + retry_at.timestamp(), + ) + assert client.aclose.await_count == 3 + + +@pytest.mark.asyncio +async def test_migration_signal_renewal_requires_exact_bounded_lease( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only the current claimant can extend one processing lease.""" + + _enable_url_valkey(monkeypatch) + claim = valkey_queue.MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + stale = replace(claim, lease_token=uuid.uuid4()) + now = dt.datetime(2026, 8, 11, 6, 2, tzinfo=dt.timezone.utc) + client = SimpleNamespace( + eval=AsyncMock(side_effect=[1, 0]), + aclose=AsyncMock(), + ) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=client)) + + assert await valkey_queue.renew_migration_run_signal( + claim, now=now, lease_seconds=15.0 + ) + assert not await valkey_queue.renew_migration_run_signal( + stale, now=now, lease_seconds=15.0 + ) + + assert client.eval.await_args_list[0].args == ( + valkey_queue._RENEW_MIGRATION_RUN_SIGNAL_SCRIPT, + 2, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + str(claim.migration_run_uuid), + str(claim.lease_token), + now.timestamp(), + now.timestamp() + 15.0, + ) + assert "tonumber(current_expiry) <= tonumber(ARGV[3])" in ( + valkey_queue._RENEW_MIGRATION_RUN_SIGNAL_SCRIPT + ) + assert client.aclose.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("lease_seconds", [0.0, -1.0, float("inf"), 3600.1]) +async def test_migration_signal_claim_rejects_invalid_lease_before_io( + monkeypatch: pytest.MonkeyPatch, + lease_seconds: float, +) -> None: + """Lease configuration cannot create a busy loop or unbounded claim.""" + + _enable_url_valkey(monkeypatch) + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + + with pytest.raises(ValueError, match="lease must be between"): + await valkey_queue.claim_due_migration_run_signal( + lease_seconds=lease_seconds + ) + with pytest.raises(ValueError, match="lease must be between"): + await valkey_queue.renew_migration_run_signal( + valkey_queue.MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()), + lease_seconds=lease_seconds, + ) + + client_factory.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migration_signal_rejects_colliding_keys_before_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ready, processing, lease-token, and generic keys must be isolated.""" + + _enable_url_valkey(monkeypatch) + monkeypatch.setattr( + settings, + "valkey_migration_run_processing_key", + settings.valkey_migration_run_queue_key, + ) + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + + with pytest.raises(ValueError, match="must be distinct"): + await valkey_queue.enqueue_migration_run_signal(uuid.uuid4()) + with pytest.raises(ValueError, match="must be distinct"): + await valkey_queue.claim_due_migration_run_signal() + + client_factory.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migration_signal_release_rejects_naive_retry_before_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Retry scheduling requires an unambiguous instant.""" + + _enable_url_valkey(monkeypatch) + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + + with pytest.raises(ValueError, match="timezone"): + await valkey_queue.release_migration_run_signal( + valkey_queue.MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()), + dt.datetime(2026, 8, 11, 6), + ) + + client_factory.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migration_signal_lease_operations_are_disabled_without_valkey( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The optional lease adapter performs no I/O when it is disabled.""" + + monkeypatch.setattr(settings, "job_queue_backend", "database") + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + claim = valkey_queue.MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + + assert await valkey_queue.claim_due_migration_run_signal() is None + assert await valkey_queue.ack_migration_run_signal(claim) is False + assert ( + await valkey_queue.release_migration_run_signal( + claim, dt.datetime.now(dt.timezone.utc) + ) + is False + ) + client_factory.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migration_signal_claim_handles_empty_text_and_invalid_members( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Empty queues are idle; hostile non-UUID members are quarantined.""" + + _enable_url_valkey(monkeypatch) + now = dt.datetime(2026, 8, 11, 6, tzinfo=dt.timezone.utc) + empty_client = SimpleNamespace( + eval=AsyncMock(return_value=None), aclose=AsyncMock() + ) + text_uuid = uuid.uuid4() + text_client = SimpleNamespace( + eval=AsyncMock(return_value=str(text_uuid)), aclose=AsyncMock() + ) + invalid_client = SimpleNamespace( + eval=AsyncMock(side_effect=[b"not-a-run-uuid", 1]), aclose=AsyncMock() + ) + client_factory = AsyncMock( + side_effect=[empty_client, text_client, invalid_client] + ) + monkeypatch.setattr(valkey_queue, "_client", client_factory) + + assert await valkey_queue.claim_due_migration_run_signal(now=now) is None + text_claim = await valkey_queue.claim_due_migration_run_signal(now=now) + assert text_claim is not None + assert text_claim.migration_run_uuid == text_uuid + assert await valkey_queue.claim_due_migration_run_signal(now=now) is None + + invalid_ack_args = invalid_client.eval.await_args_list[1].args + assert invalid_ack_args[:5] == ( + valkey_queue._ACK_MIGRATION_RUN_SIGNAL_SCRIPT, + 2, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + b"not-a-run-uuid", + ) + assert isinstance(uuid.UUID(invalid_ack_args[5]), uuid.UUID) + assert all( + client.aclose.await_count == 1 + for client in (empty_client, text_client, invalid_client) + ) + + +@pytest.mark.asyncio +async def test_migration_signal_claim_quarantines_non_utf8_members( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A byte-invalid member is removed from processing with its lease token.""" + + _enable_url_valkey(monkeypatch) + hostile_member = b"\xff\xfe-not-utf8" + client = SimpleNamespace( + eval=AsyncMock(side_effect=[hostile_member, 1]), aclose=AsyncMock() + ) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=client)) + + assert await valkey_queue.claim_due_migration_run_signal() is None + + cleanup_args = client.eval.await_args_list[1].args + assert cleanup_args[:5] == ( + valkey_queue._ACK_MIGRATION_RUN_SIGNAL_SCRIPT, + 2, + settings.valkey_migration_run_processing_key, + settings.valkey_migration_run_lease_token_key, + hostile_member, + ) + assert isinstance(uuid.UUID(cleanup_args[5]), uuid.UUID) + client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_migration_signal_lease_failures_use_fixed_non_secret_logs( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Adapter failures close clients without reflecting exception contents.""" + + _enable_url_valkey(monkeypatch) + marker = "forbidden-queue-log-marker-8a31" + clients = [ + SimpleNamespace( + eval=AsyncMock(side_effect=RuntimeError(marker)), aclose=AsyncMock() + ) + for _ in range(3) + ] + monkeypatch.setattr( + valkey_queue, "_client", AsyncMock(side_effect=clients) + ) + claim = valkey_queue.MigrationRunSignalClaim(uuid.uuid4(), uuid.uuid4()) + + assert await valkey_queue.claim_due_migration_run_signal() is None + assert await valkey_queue.ack_migration_run_signal(claim) is False + assert ( + await valkey_queue.release_migration_run_signal( + claim, dt.datetime.now(dt.timezone.utc) + ) + is False + ) + + assert "valkey_migration_signal_claim_failed" in caplog.text + assert "valkey_migration_signal_ack_failed" in caplog.text + assert "valkey_migration_signal_release_failed" in caplog.text + assert marker not in caplog.text + assert all(client.aclose.await_count == 1 for client in clients) + + +@pytest.mark.asyncio +async def test_generic_enqueue_disabled_success_and_client_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + job_uuid = uuid.uuid4() + run_after = dt.datetime(2026, 8, 11, 8, tzinfo=dt.timezone.utc) + monkeypatch.setattr(settings, "job_queue_backend", "database") + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + assert await valkey_queue.enqueue_job_signal(job_uuid, run_after) is False + client_factory.assert_not_awaited() + + _enable_url_valkey(monkeypatch) + good_client = SimpleNamespace(zadd=AsyncMock(), aclose=AsyncMock()) + monkeypatch.setattr( + valkey_queue, "_client", AsyncMock(return_value=good_client) + ) + assert await valkey_queue.enqueue_job_signal(job_uuid, run_after) is True + good_client.zadd.assert_awaited_once_with( + settings.valkey_queue_key, {str(job_uuid): run_after.timestamp()} + ) + good_client.aclose.assert_awaited_once() + + bad_client = SimpleNamespace( + zadd=AsyncMock(side_effect=ConnectionError("unavailable")), + aclose=AsyncMock(), + ) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=bad_client)) + assert await valkey_queue.enqueue_job_signal(job_uuid, run_after) is False + bad_client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_pop_due_signal_disabled_none_bytes_and_invalid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "job_queue_backend", "database") + client_factory = AsyncMock() + monkeypatch.setattr(valkey_queue, "_client", client_factory) + assert await valkey_queue.pop_due_job_signal() is None + client_factory.assert_not_awaited() + + _enable_url_valkey(monkeypatch) + now = dt.datetime(2026, 8, 11, 9, tzinfo=dt.timezone.utc) + job_uuid = uuid.uuid4() + for value, expected in ((None, None), (str(job_uuid).encode(), job_uuid), ("bad", None)): + client = SimpleNamespace(eval=AsyncMock(return_value=value), aclose=AsyncMock()) + monkeypatch.setattr(valkey_queue, "_client", AsyncMock(return_value=client)) + assert await valkey_queue.pop_due_job_signal(now) == expected + client.eval.assert_awaited_once_with( + valkey_queue._POP_DUE_JOB_SCRIPT, + 1, + settings.valkey_queue_key, + now.timestamp(), + ) + client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_pop_due_signal_default_clock_and_client_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _enable_url_valkey(monkeypatch) + success_client = SimpleNamespace(eval=AsyncMock(return_value=None), aclose=AsyncMock()) + monkeypatch.setattr( + valkey_queue, "_client", AsyncMock(return_value=success_client) + ) + assert await valkey_queue.pop_due_job_signal() is None + success_client.aclose.assert_awaited_once() + + failing_client = SimpleNamespace( + eval=AsyncMock(side_effect=ConnectionError("unavailable")), + aclose=AsyncMock(), + ) + monkeypatch.setattr( + valkey_queue, "_client", AsyncMock(return_value=failing_client) + ) + assert await valkey_queue.pop_due_job_signal() is None + failing_client.aclose.assert_awaited_once() + + monkeypatch.setattr( + valkey_queue, + "_client", + AsyncMock(side_effect=valkey_queue.ValkeyQueueUnavailable("missing")), + ) + assert await valkey_queue.pop_due_job_signal() is None + + def test_valkey_queue_rejects_invalid_sentinel_hosts( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/backend/tests/test_valkey_queue_integration.py b/backend/tests/test_valkey_queue_integration.py new file mode 100644 index 000000000..76e71dab1 --- /dev/null +++ b/backend/tests/test_valkey_queue_integration.py @@ -0,0 +1,124 @@ +"""Real Valkey acceptance for identifier-only queue signal separation.""" + +from __future__ import annotations + +import datetime as dt +import os +import uuid +from typing import Any + +import pytest + +from app.jobs import valkey_queue +from app.settings import settings + + +@pytest.mark.asyncio +async def test_real_valkey_keeps_migration_and_generic_signals_isolated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Dedicated sorted sets contain only their intended UUID identities.""" + + url = os.getenv("VALKEY_INTEGRATION_URL") + if not url: + pytest.skip("VALKEY_INTEGRATION_URL is not configured") + + suffix = uuid.uuid4().hex + generic_key = f"pg-erd-cloud:test:job:{suffix}" + migration_key = f"pg-erd-cloud:test:migration:{suffix}" + processing_key = f"pg-erd-cloud:test:migration-processing:{suffix}" + lease_token_key = f"pg-erd-cloud:test:migration-lease:{suffix}" + monkeypatch.setattr(settings, "job_queue_backend", "valkey") + monkeypatch.setattr(settings, "valkey_url", url) + monkeypatch.setattr(settings, "valkey_sentinel_hosts", None) + monkeypatch.setattr(settings, "valkey_queue_key", generic_key) + monkeypatch.setattr(settings, "valkey_migration_run_queue_key", migration_key) + monkeypatch.setattr( + settings, "valkey_migration_run_processing_key", processing_key + ) + monkeypatch.setattr( + settings, "valkey_migration_run_lease_token_key", lease_token_key + ) + + redis_asyncio: Any = valkey_queue._load_redis_module() + client: Any = redis_asyncio.from_url(url) + generic_uuid = uuid.uuid4() + migration_uuid = uuid.uuid4() + due_at = dt.datetime(2026, 8, 11, 3, tzinfo=dt.timezone.utc) + try: + await client.delete( + generic_key, migration_key, processing_key, lease_token_key + ) + + assert await valkey_queue.enqueue_job_signal(generic_uuid, due_at) is True + assert ( + await valkey_queue.enqueue_migration_run_signal(migration_uuid, due_at) + is True + ) + + assert await client.zrange(generic_key, 0, -1) == [ + str(generic_uuid).encode() + ] + assert await client.zrange(migration_key, 0, -1) == [ + str(migration_uuid).encode() + ] + assert await valkey_queue.pop_due_job_signal(due_at) == generic_uuid + assert await client.zrange(generic_key, 0, -1) == [] + assert await client.zrange(migration_key, 0, -1) == [ + str(migration_uuid).encode() + ] + + claim = await valkey_queue.claim_due_migration_run_signal( + now=due_at, lease_seconds=30.0 + ) + assert claim is not None + assert claim.migration_run_uuid == migration_uuid + assert await client.zrange(migration_key, 0, -1) == [] + assert await client.zrange(processing_key, 0, -1) == [ + str(migration_uuid).encode() + ] + + stale_claim = valkey_queue.MigrationRunSignalClaim( + migration_run_uuid=migration_uuid, + lease_token=uuid.uuid4(), + ) + renew_at = due_at + dt.timedelta(seconds=10) + assert await valkey_queue.renew_migration_run_signal( + claim, now=renew_at, lease_seconds=30.0 + ) + assert not await valkey_queue.renew_migration_run_signal( + stale_claim, now=renew_at, lease_seconds=30.0 + ) + assert await client.zscore(processing_key, str(migration_uuid)) == ( + renew_at.timestamp() + 30.0 + ) + expires_at = renew_at + dt.timedelta(seconds=30) + assert not await valkey_queue.renew_migration_run_signal( + claim, now=expires_at, lease_seconds=30.0 + ) + assert not await valkey_queue.renew_migration_run_signal( + claim, + now=expires_at + dt.timedelta(microseconds=1), + lease_seconds=30.0, + ) + assert await valkey_queue.ack_migration_run_signal(stale_claim) is False + assert await client.zrange(processing_key, 0, -1) == [ + str(migration_uuid).encode() + ] + + retry_at = due_at + dt.timedelta(seconds=1) + assert await valkey_queue.release_migration_run_signal(claim, retry_at) + second_claim = await valkey_queue.claim_due_migration_run_signal( + now=retry_at, lease_seconds=30.0 + ) + assert second_claim is not None + assert second_claim.migration_run_uuid == migration_uuid + assert second_claim.lease_token != claim.lease_token + assert await valkey_queue.ack_migration_run_signal(second_claim) + assert await client.zrange(processing_key, 0, -1) == [] + assert await client.hlen(lease_token_key) == 0 + finally: + await client.delete( + generic_key, migration_key, processing_key, lease_token_key + ) + await valkey_queue._close_client(client) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 000000000..71b19c337 --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,379 @@ +# Forward Engineering Data Model + +- **Document status:** Current physical model plus accepted planned extension +- **Runtime status:** Partially implemented; not production-ready +- **Last reconciled with ORM and Alembic:** 2026-08-14 + +Repository migrations, ORM definitions, and the Mermaid ERDs below are +authoritative. The +[FigJam companion board](https://www.figma.com/board/MLWimuWoOWhatQ239QihfP) +is non-authoritative and exists to support visual review. + +## Status legend + +| Label | Meaning | +|---|---| +| **Implemented** | Table and foreign key exist in `backend/app/models.py` and Alembic. | +| **Partially implemented** | Physical storage exists, but a stronger invariant is enforced only by application code or is not yet enforced. | +| **Planned** | Accepted logical entity or relationship; no table or ORM class exists. | +| **Rejected** | Deliberately not stored or not accepted in this boundary. | + +## Implemented metadata ERD + +The diagram shows the implemented forward-engineering ownership and provenance +slice. It intentionally omits unrelated saved views, annotations, shares, API +keys, revoked tokens, and the generic queue. + +```mermaid +erDiagram + USER_ACCOUNT { + uuid user_account_uuid PK + text oidc_subject UK + } + PROJECT_SPACE { + uuid project_space_uuid PK + uuid created_by_user_uuid FK + text project_name + } + PROJECT_MEMBER { + uuid project_space_uuid PK, FK + uuid user_account_uuid PK, FK + text project_role + } + DB_CONNECTION { + uuid db_connection_uuid PK + uuid project_space_uuid FK + text conn_name + bytes dsn_ciphertext + bytes dsn_nonce + } + SCHEMA_SNAPSHOT { + uuid schema_snapshot_uuid PK + uuid project_space_uuid FK + uuid db_connection_uuid FK + text status + text schema_filter + } + SCHEMA_SNAPSHOT_DATA { + uuid schema_snapshot_uuid PK, FK + jsonb snapshot_json + } + SCHEMA_MODEL { + uuid schema_model_uuid PK + uuid project_space_uuid FK + text model_name + int current_revision_number + uuid created_by_user_uuid FK + } + SCHEMA_MODEL_REVISION { + uuid schema_model_revision_uuid PK + uuid schema_model_uuid FK + int revision_number + text revision_digest + jsonb model_json + uuid base_schema_snapshot_uuid FK + uuid created_by_user_uuid FK + } + MIGRATION_PLAN { + uuid migration_plan_uuid PK + uuid project_space_uuid FK + uuid schema_model_revision_uuid FK + uuid db_connection_uuid FK + uuid base_schema_snapshot_uuid FK + text compiler_version + text base_digest + text target_digest + text statement_digest + jsonb plan_json + uuid created_by_user_uuid FK + timestamptz expires_at + } + MIGRATION_RUN { + uuid migration_run_uuid PK + uuid project_space_uuid FK + uuid migration_plan_uuid FK + uuid passed_dry_run_uuid FK + text run_kind + text state + int state_version + text idempotency_key_hash + text plan_digest + text request_digest + text confirmation_digest + boolean destructive_confirmation + text latest_event_digest + uuid requested_by_user_uuid FK + boolean cancellation_requested + text observed_base_digest + jsonb evidence_json + text error_code + timestamptz created_at + timestamptz updated_at + timestamptz started_at + timestamptz finished_at + } + MIGRATION_RUN_DISPATCH { + uuid migration_run_dispatch_uuid PK + uuid migration_run_uuid FK, UK + text dispatch_kind + text status + int attempt_count + timestamptz not_before + timestamptz created_at + timestamptz published_at + } + MIGRATION_RUN_ATTEMPT { + uuid migration_run_attempt_uuid PK + uuid migration_run_uuid FK + int attempt_number UK + int acquired_state_version + text status + text worker_identity_hash + text signal_lease_token_hash + timestamptz lease_expires_at + timestamptz acquired_at + timestamptz last_heartbeat_at + timestamptz finished_at + } + MIGRATION_RUN_EVENT { + uuid migration_run_event_uuid PK + uuid migration_run_uuid FK + int sequence_number + text event_type + text state_before + text state_after + jsonb evidence_json + text previous_event_digest + text event_digest + uuid actor_user_uuid FK + timestamptz created_at + } + + USER_ACCOUNT ||--o{ PROJECT_SPACE : creates + USER_ACCOUNT ||--o{ PROJECT_MEMBER : holds + PROJECT_SPACE ||--o{ PROJECT_MEMBER : authorizes + PROJECT_SPACE ||--o{ DB_CONNECTION : owns + DB_CONNECTION ||--o{ SCHEMA_SNAPSHOT : captures + PROJECT_SPACE ||--o{ SCHEMA_SNAPSHOT : scopes + SCHEMA_SNAPSHOT ||--o| SCHEMA_SNAPSHOT_DATA : has + PROJECT_SPACE ||--o{ SCHEMA_MODEL : owns + USER_ACCOUNT ||--o{ SCHEMA_MODEL : creates + SCHEMA_MODEL ||--o{ SCHEMA_MODEL_REVISION : versions + SCHEMA_SNAPSHOT o|--o{ SCHEMA_MODEL_REVISION : bases + USER_ACCOUNT ||--o{ SCHEMA_MODEL_REVISION : creates + PROJECT_SPACE ||--o{ MIGRATION_PLAN : scopes + SCHEMA_MODEL_REVISION ||--o{ MIGRATION_PLAN : compiles_to + DB_CONNECTION ||--o{ MIGRATION_PLAN : targets + SCHEMA_SNAPSHOT ||--o{ MIGRATION_PLAN : starts_from + USER_ACCOUNT ||--o{ MIGRATION_PLAN : creates + PROJECT_SPACE ||--o{ MIGRATION_RUN : scopes + MIGRATION_PLAN ||--o{ MIGRATION_RUN : attempts + USER_ACCOUNT ||--o{ MIGRATION_RUN : requests + MIGRATION_RUN ||--o{ MIGRATION_RUN_ATTEMPT : owns + MIGRATION_RUN ||--o| MIGRATION_RUN_DISPATCH : dispatches + MIGRATION_RUN ||--o{ MIGRATION_RUN_EVENT : records + USER_ACCOUNT o|--o{ MIGRATION_RUN_EVENT : acts +``` + +### Implemented key and deletion semantics + +| Child foreign key | Parent | Nullable | On parent delete | Implemented cardinality | +|---|---|---:|---|---| +| `project_member.project_space_uuid` | `project_space` | no | `CASCADE` | Each membership has exactly one project; a project has zero or more memberships at the database boundary. | +| `project_member.user_account_uuid` | `user_account` | no | `CASCADE` | Each membership has exactly one user; a user has zero or more memberships. | +| `db_connection.project_space_uuid` | `project_space` | no | `CASCADE` | Each connection belongs to one project; a project has zero or more connections. | +| `schema_snapshot.project_space_uuid` | `project_space` | no | `CASCADE` | Each snapshot is scoped to one project; a project has zero or more snapshots. | +| `schema_snapshot.db_connection_uuid` | `db_connection` | no | `CASCADE` | Each snapshot came from one connection; a connection has zero or more snapshots. | +| `schema_snapshot_data.schema_snapshot_uuid` | `schema_snapshot` | no; also PK | `CASCADE` | A snapshot has zero or one data row; each data row belongs to exactly one snapshot. | +| `schema_model.project_space_uuid` | `project_space` | no | `CASCADE` | Each model belongs to one project; a project has zero or more models. | +| `schema_model_revision.schema_model_uuid` | `schema_model` | no | `CASCADE` | Each revision belongs to one model; a model has zero or more revisions physically. Current APIs create the model with revision 1 atomically. | +| `schema_model_revision.base_schema_snapshot_uuid` | `schema_snapshot` | yes | `RESTRICT` | A revision has zero or one base snapshot; a snapshot can base zero or more revisions. | +| `migration_plan.project_space_uuid` | `project_space` | no | `CASCADE` | Each plan is scoped to one project; a project has zero or more plans. | +| `migration_plan.schema_model_revision_uuid` | `schema_model_revision` | no | `RESTRICT` | Each plan compiles one revision; a revision can produce zero or more plans. | +| `migration_plan.db_connection_uuid` | `db_connection` | no | `RESTRICT` | Each plan targets one connection; a connection can have zero or more plans. | +| `migration_plan.base_schema_snapshot_uuid` | `schema_snapshot` | no | `RESTRICT` | Each plan binds one base snapshot; a snapshot can base zero or more plans. | +| `migration_run.project_space_uuid` | `project_space` | no | `CASCADE` | Each durable run is scoped to one project; a project has zero or more runs. | +| `migration_run.migration_plan_uuid` | `migration_plan` | no | `RESTRICT` | Each durable run attempts one immutable plan; plan deletion is blocked while evidence remains. | +| `migration_run.passed_dry_run_uuid` | `migration_run` | nullable for dry runs; required for apply | `RESTRICT` | Each apply intent names one exact dry run; the writer additionally requires same project, plan, digest, passed state, no cancellation, and exact observed base. | +| `migration_run.requested_by_user_uuid` | `user_account` | no | `NO ACTION` | Each run records one requesting actor. | +| `migration_run_dispatch.migration_run_uuid` | `migration_run` | no; unique | `CASCADE` | The implemented writer adds one identifier-only dispatch intent for each new dry run; the database permits zero or one dispatch row per run. | +| `migration_run_attempt.migration_run_uuid` | `migration_run` | no | `CASCADE` | A run has numbered attempt history and at most one partial-indexed active owner. | +| `migration_run_event.migration_run_uuid` | `migration_run` | no | `CASCADE` | Each event belongs to one run; approved run deletion removes its event sequence atomically. | +| `migration_run_event.actor_user_uuid` | `user_account` | yes | `NO ACTION` | Worker events may be system-authored; human actions retain an actor. | + +All `created_by_user_uuid` columns shown are non-null foreign keys to +`user_account` with the database default delete behavior (`NO ACTION`). + +### Physical invariants and application invariants + +| Invariant | Enforcement | Status | +|---|---|---| +| Model name is unique inside a project. | Database unique constraint on `(project_space_uuid, model_name)`. | Implemented | +| Revision number is unique inside a model. | Database unique constraint on `(schema_model_uuid, revision_number)`. | Implemented | +| A model revision is immutable. | No update route; application convention. There is no database trigger preventing update. | Partially implemented | +| `current_revision_number` names an existing revision of the same model. | Current API transaction and row lock. No physical FK can express the composite pointer as modeled. | Partially implemented | +| Plan project, revision project, connection project, and snapshot project match; the snapshot came from that exact connection and succeeded. | `app.api.migration_plans.create_migration_plan` before insert. | Implemented in the API; not a database constraint | +| Plan SQL and execution fields cannot change. | No current update route. There is no database immutability trigger. | Partially implemented | +| Expired plans cannot start a run. | The public dry-run intent route delegates to the writer that verifies expiry before its conflict-winner insert; worker execution remains absent. | Partially implemented | +| A new run and genesis event are atomic; only dry runs receive a dispatch. | `create_migration_run` adds the run/event and, for dry runs only, one unique dispatch to the caller-owned transaction. Confirmed apply intents deliberately receive no dispatch. | Implemented | +| An apply intent cannot become executable by creation. | The API locks the schema-model row `FOR UPDATE`; the writer validates current exact revision UUID/number/digest plus plan/connection/passed-run/base/destructive bindings, persists a confirmation digest and genesis evidence, and creates no dispatch. Database checks require confirmation fields only for apply rows. | Implemented intent boundary; executor Planned | +| Dispatch and attempt storage carry no execution material. | Dispatch contains identifiers/timestamps only. Attempts contain numbered ownership timestamps plus SHA-256 worker/signal-token hashes—never raw identity, DSN, SQL, plan, or credential. Exact unexpired-owner acquire/renew/finish and expiry takeover are implemented. A separate repository-level live-preflight provider loads the exact plan-bound snapshot and connection and opens it through the guarded connector without persisting execution material. | Implemented persistence/signal/attempt and stored-target provider boundaries; attempt/provider wiring and execution Planned | +| Secrets or raw SQL never appear in run evidence. | `canonicalize_run_evidence` recursively rejects SQL, DSN, password, secret, token, and credential field tokens and bounds depth, items, strings, and total JSON bytes. | Implemented at the evidence-construction boundary; all writers must use it | +| Duplicate run requests select one durable identity. | Unique `(project_space_uuid, run_kind, idempotency_key_hash)` plus separately persisted `request_digest`; public dry-run/apply-intent routes delegate to the PostgreSQL conflict-winner writer, which reuses only the same effective request and rejects different reuse. | Implemented for dry-run and non-dispatched apply intent; workers Planned | +| Run/event state tokens, sequence numbers, digest links, and attempt ownership are valid. | Database checks constrain run/event state and SHA-256 shapes; exact transition/result CAS writers preserve plan/evidence identity. Attempt acquisition serializes on the run, one partial unique index permits at most one active owner, history numbering is unique, renewal requires an executable uncancelled run, and renew/finish require the exact unexpired hashed owner. | Implemented persistence/polling/result bridges, durable attempt primitives, and standalone guarded live-preflight provider; attempt/provider/startup binding and execution Planned | + +The existing result boundaries remain explicit: `complete_isolated_dry_run` +revalidates sandbox evidence and derives the fixed next CAS; +`execute_bound_live_preflight` binds fresh capture and checks to one read-only +repeatable-read transaction; `complete_live_preflight` validates that exact +result and derives the only terminal classification. Durable attempt ownership +does not replace or grant authority to any of these boundaries. + +`migration_plan.statement_digest` stores the compiler's current `plan_digest`. +It is provenance, not a database idempotency key: the same logical SQL may be +planned for different targets or recreated after expiry. + +`migration_plan` is a derived review artifact with a 24-hour execution lifetime. +Creation-time maintenance may delete it only after a further 30-day retention +window, only within the authorized project, and only when no `migration_run` +references it. Any plan with durable run evidence is retained by policy and by +the restrictive foreign key. + +`migration_plan.plan_json` separates executable `statements` from +`proposed_statements`. When any blocker exists, `statements` is empty and +`proposed_statements` retains independently supported deltas as review-only +evidence. `risk_summary` and `requires_destructive_confirmation` are computed +over all proposals, so a blocked plan can still disclose destructive risk. A +future executor must reject blocked plans and must never promote proposals to +execution input. + +## Physical run foundation — Implemented + +`migration_run`, `migration_run_dispatch`, `migration_run_event`, and +`migration_run_attempt` now exist in the ORM and Alembic revisions +`0010_migration_run`, `0011_migration_run_attempt`, +`0012_apply_intent_confirmation`, and `0013_migration_run_cancellation` with the fields +shown in the implemented ERD +above. Passed-dry-run, confirmation-digest, and destructive-confirmation +bindings are physical and Implemented. The logical diagram below retains the +verification-snapshot reference as a **Planned extension**; that field does not +exist physically and must not be inferred from the implemented tables. + +```mermaid +erDiagram + PROJECT_SPACE ||--o{ MIGRATION_RUN : scopes + USER_ACCOUNT ||--o{ MIGRATION_RUN : requests + MIGRATION_PLAN ||--o{ MIGRATION_RUN : attempts + MIGRATION_RUN o|--o{ MIGRATION_RUN : proves_apply + SCHEMA_SNAPSHOT o|--o{ MIGRATION_RUN : verifies + MIGRATION_RUN ||--o{ MIGRATION_RUN_EVENT : records + MIGRATION_RUN ||--o| MIGRATION_RUN_DISPATCH : dispatches + MIGRATION_RUN ||--o{ MIGRATION_RUN_ATTEMPT : owns + USER_ACCOUNT o|--o{ MIGRATION_RUN_EVENT : acts + + MIGRATION_RUN { + uuid migration_run_uuid PK + uuid project_space_uuid FK + uuid migration_plan_uuid FK + uuid requested_by_user_uuid FK + uuid passed_dry_run_uuid FK + uuid verification_snapshot_uuid FK + text run_kind + text bound_plan_digest + text idempotency_key + text request_digest + text confirmation_digest + boolean destructive_confirmation + text state + int state_version + text observed_base_digest + jsonb evidence_json + text error_code + timestamptz created_at + timestamptz started_at + timestamptz finished_at + } + MIGRATION_RUN_EVENT { + uuid migration_run_event_uuid PK + uuid migration_run_uuid FK + uuid actor_user_uuid FK + int event_sequence + text event_kind + text from_state + text to_state + jsonb evidence_json + timestamptz created_at + } + MIGRATION_RUN_DISPATCH { + uuid migration_run_dispatch_uuid PK + uuid migration_run_uuid FK + text dispatch_kind + text status + int attempt_count + timestamptz not_before + timestamptz created_at + timestamptz published_at + } + MIGRATION_RUN_ATTEMPT { + uuid migration_run_attempt_uuid PK + uuid migration_run_uuid FK + int attempt_number + int acquired_state_version + text status + text worker_identity_hash + text signal_lease_token_hash + timestamptz lease_expires_at + timestamptz acquired_at + timestamptz last_heartbeat_at + timestamptz finished_at + } +``` + +Target foreign-key and cardinality rules follow. The implemented foreign-key +table above is authoritative for current deletion behavior; this table marks +the implemented passed-dry-run relationship and the remaining planned field. + +| Child foreign key | Parent | Nullable / conditional rule | Target deletion and cardinality | +|---|---|---|---| +| `migration_run.migration_plan_uuid` | `migration_plan` | non-null | `RESTRICT`; every run attempts one immutable plan, and a plan has zero or more dry-run/apply attempts. | +| `migration_run.passed_dry_run_uuid` | `migration_run` | null for dry runs; required for apply and must reference a `passed` run for the same plan/digest/base | **Implemented:** `RESTRICT`; one passed dry run can prove zero or more non-dispatched apply intents while its exact plan remains unexpired. | +| `migration_run.verification_snapshot_uuid` | `schema_snapshot` | null until verification; required for `verified` | `RESTRICT`; a run has zero or one verification snapshot, and a snapshot can be referenced by zero or more runs physically. The service must create a dedicated snapshot per apply run. | + +Additional **Implemented and Planned** invariants: + +- **Implemented — scheduled relay lifecycle:** an explicit opt-in + application task. Each bounded publisher attempt runs in its own + caller-owned transaction; failure rolls back and empty/failure iterations + wait at a positive configured interval. +- **Implemented — execution-neutral queue consumer contract:** an injected + handler receives the exact signal claim (run UUID plus opaque lease-token) + and must complete before exact-lease acknowledgement; sanitized failure + releases only that lease at a bounded retry time. The ready payload remains + UUID-only. The contract never loads a plan, credential, SQL batch, or target + value. +- **Implemented — consumer-to-attempt binding:** the dual-lease adapter commits + exact attempt acquisition before invoking an injected handler, renews in + fresh metadata transactions, cancels on ownership loss, and records exact + completion before the signal may be acknowledged. It accepts no execution + material or credentials. +- **Planned — application startup wiring and worker execution:** no startup + task consumes migration signals or executes target SQL. +- One database uniqueness rule plus `request_digest` implements idempotency: + identical reuse returns the original run, while different effective input + returns `409`. +- State changes compare-and-swap `state_version`; events are append-only and + ordered uniquely by `(migration_run_uuid, event_sequence)`. +- Project, plan, dry-run evidence, target connection, and verification + snapshot tenancy must agree. Conditional state invariants require service + transactions and database constraints where PostgreSQL can express them. +- Event/evidence JSON contains bounded identifiers, digests, counts, durations, + and sanitized diagnostics only. DSNs, decrypted secrets, SQL batches, and + sampled row values are **Rejected**. + +## Related authority + +- [Forward-engineering v1 contract](contracts/forward-engineering-v1.md) +- [UML and state machines](UML.md) +- [ADR-0004: durable runs and recovery](adr/ADR-0004-durable-runs-and-recovery.md) +- [Threat model](security/forward-engineering-threat-model.md) +- [Operational runbook](runbooks/forward-engineering.md) diff --git a/docs/DOCUMENTATION_AUDIT.md b/docs/DOCUMENTATION_AUDIT.md new file mode 100644 index 000000000..1f43c1ab5 --- /dev/null +++ b/docs/DOCUMENTATION_AUDIT.md @@ -0,0 +1,284 @@ +# Forward Engineering Documentation Audit + +- **Audit status:** Reconciled with the 2026-08-11 working tree +- **Baseline:** `bcce75a64b9b658e14fe046ba4149aa8f53e94e2` +- **Runtime conclusion:** Phase 1 control plane is Partially implemented and not production-ready +- **Documentation conclusion:** Adequate to continue bounded implementation; insufficient to authorize production apply + +## Executive assessment + +Before this update, the repository did **not** contain a sufficient canonical +documentation set for the conversation's end-to-end forward-engineering goal. +It had a detailed approved-scope design at +`docs/superpowers/specs/2026-08-09-forward-engineering-design.md`, but no root +architecture, canonical PRD/TRD, ADR index, normative contract, current/planned +UML and ERD, feature threat model, test strategy, standards baseline, or +operational recovery runbook. The baseline README also described forward +engineering primarily as snapshot DDL export/diff and left safe workflow work +as a roadmap item. + +After this update, the repository has a coherent source-controlled set that: + +- distinguishes **Implemented**, **Partially implemented**, **Planned**, and + **Rejected** behavior; +- describes the current model/revision/plan vertical slice without claiming + sandbox, durable apply, convergence, or frontend support; +- records the key architecture decisions and rejected unsafe alternatives; +- separates implemented model/plan/run/event persistence from planned worker, + sandbox, preflight, apply, and convergence entities; +- traces normative invariants to current code, tests, and documents; and +- makes production blockers, security residuals, verification evidence, and + no-replay recovery explicit. + +That is sufficient documentation for Phase 1 review and sequenced +implementation. It is deliberately not sufficient production evidence. The +largest remaining gaps are runtime code, real PostgreSQL/fault-injection/E2E +tests, operations, accessibility, and legacy live-route containment—not missing +prose. + +## Audit method + +Artifacts were evaluated against five questions: + +1. **Discoverability:** Is there one indexed canonical location, and can a new + contributor find it from the README? +2. **Truthfulness:** Does it match current routes, models, migrations, tests, + support boundaries, and absence of runtime components? +3. **Decision completeness:** Are safety-critical choices, alternatives, + consequences, and implementation status recorded? +4. **Traceability:** Can each normative requirement be followed to code, test, + and operational or planned evidence? +5. **Release usefulness:** Does it define failure, drift, timeout, approval, + recovery, accessibility, and verification gates strongly enough to prevent a + premature “done” claim? + +Adequacy labels in this audit mean: + +- **Adequate:** Sufficient for its current design/implementation purpose and + explicitly bounded. +- **Partial:** Useful but missing runtime evidence, cross-link, or settled + contract detail. +- **Stale:** Contradicts or materially predates current implementation truth. +- **Missing:** No scoped repository artifact existed at the audit point. +- **N/A:** Not applicable to this product boundary. + +## Before/after adequacy matrix + +| Artifact | Baseline assessment | Repository artifact after update | After assessment | Remaining limitation | +|---|---|---|---|---| +| Product outcome and requirements | **Partial:** UI product spec covered the existing editor, not safe model-to-verification workflow. | [PRD](PRD.md) | **Adequate for target scope** | Forward UI and production metrics are Planned, so acceptance has no runtime evidence yet. | +| Technical requirements and support contract | **Missing** as a canonical current-vs-target TRD. | [TRD](TRD.md) and [v1 contract](contracts/forward-engineering-v1.md) | **Adequate for Phase 1** | Planned run schema/API/error envelope must be frozen with implementation. | +| Architecture | **Missing** at repository root; detailed design was not a current component index. | [Architecture](../ARCHITECTURE.md) | **Adequate** | Runtime sandbox/worker/network topology remains Planned and needs deployment evidence. | +| Architecture decisions | **Missing:** safety decisions existed inside one design narrative, without indexed ADR status. | [ADR index](adr/README.md) and ADR-0001–0005 | **Adequate** | Future non-transactional execution, secret-manager/key separation, and any exception need new ADRs. | +| UML/component/sequence/state views | **Missing** | [UML](UML.md) | **Adequate** | Durable-run persistence and authenticated polling are Implemented; planned executor state machines have no worker implementation yet. Repository Mermaid is authoritative; FigJam is companion only. | +| Metadata ERD | **Missing** | [Data model](DATA_MODEL.md) | **Adequate for current physical schema** | Planned execution bindings, indexes, and retention need migration review. | +| API and invariant contract | **Partial:** design route names and desired shapes were not separated from current routes. | [v1 contract](contracts/forward-engineering-v1.md) | **Adequate for current truth** | Public route spelling, RFC 9457 problem details, and planned run routes remain unresolved. | +| Security/threat model | **Partial:** general API checklist and vulnerability reporting existed, not a DDL-specific trust/abuse model. | [Forward threat model](security/forward-engineering-threat-model.md) | **Adequate for design review** | Several high-risk controls are Planned; no production risk acceptance is granted. | +| Operational/runbook | **Missing** | [Forward runbook](runbooks/forward-engineering.md) | **Partial by design** | Run states, alerts, kill switch, timeouts, sandbox, and evidence bundle are not implemented or drilled. | +| Test strategy and release evidence | **Missing** as a scoped strategy; scattered tests existed. | [Test strategy](TEST_STRATEGY.md) | **Adequate as strategy; evidence incomplete** | Real PostgreSQL 14–18, fault injection, composed E2E, accessibility, and forward coverage enforcement remain blockers. | +| Standards and research baseline | **Partial:** sources were scattered and version/status distinctions were absent. | [Standards](STANDARDS.md) | **Adequate** | This is a baseline, not compliance certification; scoped ASVS evidence must be produced per release. | +| Frontend functional/accessibility specification | **Stale for forward engineering:** existing UI spec does not include the five-stage safe workflow. | PRD, design spec, UML and test strategy define target behavior. | **Partial** | No component-level forward UI spec, implementation, screenshots, Figma component design, or E2E evidence exists. | +| Discoverability/indexing | **Partial:** no canonical forward document index. | README “정본 설계 문서”, architecture references, ADR index | **Adequate** | Required-document and core-link presence now has a contract test; full Markdown/Mermaid parsing remains a gate. | +| Code-to-doc traceability | **Missing** | This audit, TRD traceability tables, and `test_documentation_contract.py` | **Adequate for current slice** | Exact release-result links and broader semantic drift enforcement remain Planned. | + +## Implementation inventory used by this audit + +### Implemented in the current working tree + +- `schema_model`, `schema_model_revision`, and `migration_plan` ORM/Alembic + resources with project, actor, revision, snapshot, connection, digest, and + expiry provenance. +- Canonical PostgreSQL 14–18 model validation/digest and exact identifier + preservation for the admitted subset. Safe aliases are normalized to + PostgreSQL catalog spelling; non-convergent serial pseudo-types are rejected. +- Snapshot adapter handling for real introspection keys used by defaults and + primary-key deferrability, acceptance of represented primary-key backing + indexes, current capability-version enforcement, repeatable-read capture, + and rejection of dropped slots or unsupported constraints/catalog features. +- Deterministic structured plan compilation for the admitted create/drop/add, + type, and nullability subset, including explicit risk, privilege, + preconditions, and blockers. +- Explicit blockers—rather than silent omission—for schema removal, table and + column comments, existing-column reordering, non-appended new columns, and + existing primary-key changes. Any blocker makes executable `statements` + empty, while independently supported deltas remain review-only + `proposed_statements`; the risk summary includes those proposals. +- Every admitted `ALTER COLUMN ... TYPE` is conservatively classified + destructive with possible rewrite, scan, and data-loss risk. +- Current schema-model create/get/revise routes and plan-create route with role, + tenancy, snapshot/connection, size, and strong revision-UUID ETag + optimistic-concurrency checks. +- Immutable plan retrieval with persisted-plan digest verification, plus durable + `migration_run`/`migration_run_event` persistence, internal idempotent dry-run + creation, optimistic state/cancellation writers, tamper-evident event chains, + and authenticated integrity-checked run polling. +- `deployer` between editor and owner, plus deployer gating of persistent legacy + `apply-sql` using a primary-session authorization read. +- Existing general controls for CSRF, credentialed CORS (allowing `If-Match` + and exposing `ETag`), + rate limiting, encrypted DSNs, DSN redaction, target allowlisting, restricted + address rejection, DNS resolution/IP pinning, and optional verified-hostname + TLS. + +### Implemented and planned execution boundaries + +- **Implemented — scheduled relay lifecycle and UUID-only publication:** the + opt-in application task publishes only `migration_run_uuid` from one fresh + caller-owned metadata transaction per bounded attempt. Public dry-run + creation, atomic identifier-only outbox, lock-scoped claim/publish-state CAS, + and cancellation intent are also implemented. +- **Implemented — UUID-only signal lease safety:** ready-to-processing claim, + bounded expiry reclaim, exact lease renewal, acknowledgement, and retry + release require an exact lease-token; an expired signal owner cannot renew, + renewal cannot shorten the current expiry, and stale claimants cannot extend + or complete successor leases. +- **Implemented — execution-neutral queue consumer contract:** one injected + handler receives the exact signal claim (run UUID plus opaque lease-token) + and must succeed before exact-lease acknowledgement; sanitized failure + releases only that lease at a bounded retry score, and lease loss is + non-success. The ready payload remains UUID-only. The contract loads no plan, + credential, SQL, or target value. +- **Implemented — automatic heartbeat:** the consumer renews the exact claim + while its handler runs, cancels and retrieves that task on renewal loss, and + cannot acknowledge the lost lease as success. +- **Implemented — DB-durable attempt ownership primitives:** acquisition locks + an executable uncancelled dry run, stores only worker/signal-token hashes, + permits one active owner, reclaims only expiry, and issues monotonic attempt + numbers. Renew/finish require the exact unexpired owner. +- **Implemented — consumer-to-attempt binding:** the execution-neutral adapter + commits acquire/renew/finish in fresh metadata transactions, cancels injected + work on durable lease loss, and cannot acknowledge a signal before exact + completion. It accepts no credentials or execution material. +- **Planned — application startup wiring, worker execution, failover, and retention:** + no startup task consumes migration signals, accesses a target, or executes + SQL. An exact deployer-confirmed apply-intent route exists without dispatch; + sandbox/preflight/apply workers and live apply execution remain absent. +- Isolated version-compatible sandbox execution and live read-only preflight. +- Target fingerprint revalidation, advisory and object lock acquisition, + apply-time data-precondition execution, stored-plan executor, and explicit + transactional segment recovery. The signed-plan revalidation manifest and + deterministic object-lock targets are implemented target-free inputs only. +- Crash/restart recovery and no-replay apply reconciliation. Atomic outbox + persistence, lock-scoped claim/publish-state CAS, UUID-only publication, + internal idempotency, compare-and-swap transitions, cancellation intent, + and append-only event evidence are implemented. +- Verification snapshot, residual diff, convergence classification, alerts, + kill switch, retention, and tested incident procedure. +- Forward browser/API client, modal workflow, polling, accessibility, and every + honest terminal-state presentation. + +## Requirement → code → test → document traceability + +The invariant IDs come from the +[forward-engineering v1 contract](contracts/forward-engineering-v1.md). A dash +means the runtime artifact does not yet exist; a design document is not counted +as code or test evidence. + +The concrete live-preflight provider now has machine-tested post-connect exact +metadata revalidation before any target read. A changed second lookup closes +the guarded connection without capture authority; exact attempt leasing still +bounds concurrent change after that check. + +| Invariant | Current code | Current tests | Authoritative documents | Status / unresolved proof | +|---|---|---|---|---| +| FE-INV-001: browser intent; server SQL authority | `backend/app/forward/schema_model.py`; `backend/app/forward/migration_plan.py`; schema-model/plan APIs | `test_forward_schema_model.py`; `test_forward_migration_plan.py`; forward API tests | [ADR-0001](adr/ADR-0001-server-authoritative-planning.md), [TRD](TRD.md) | **Partially implemented:** plan path is server-owned; executor/UI absent and legacy browser SQL endpoint remains transitional. | +| FE-INV-002: canonical append-only revision | `models.py`; migration `0008`; `api/schema_models.py` | `test_forward_schema_model.py`; `test_api_schema_models.py` | [Contract §3–5](contracts/forward-engineering-v1.md), [Data model](DATA_MODEL.md) | **Implemented through API;** database update-prevention trigger absent. | +| FE-INV-003: exact plan provenance | `models.py`; migration `0009`; `api/migration_plans.py` | `test_api_migration_plans.py` | [TRD](TRD.md), [Data model](DATA_MODEL.md) | **Implemented control plane;** execution-time expiry/digest enforcement is Planned. | +| FE-INV-004: every difference is operation or blocker | `forward/migration_plan.py`; `forward/snapshot_adapter.py` | compiler comment/order/schema-removal/PK blocker and review-only proposal tests; snapshot default/constraint/index/partition tests | [Contract §6–7](contracts/forward-engineering-v1.md), [ADR-0001](adr/ADR-0001-server-authoritative-planning.md) | **Implemented for current canonical subset:** blocked plans have no executable statements but retain supported deltas as non-executable proposals with risk; exhaustive real-catalog dependency proof is missing, so the release invariant remains Partial. | +| FE-INV-005: isolated DDL and live read-only preflight | Exact-plan sandbox, same-transaction read-only preflight/result bridges, a query-only PostgreSQL snapshot callback that requires caller-owned connection/transaction authority before catalog or Citus access, DB-durable hashed attempt ownership, execution-neutral consumer binding, a single-query exact-attempt lookup that binds encrypted target material to succeeded snapshot scope, a concrete guarded PostgreSQL provider with in-memory decryption, same-connection capture, fixed failures and cleanup, and an explicit durable/provider composition requiring the same metadata session factory | Unit rollback/cancellation/result/attempt CAS, secret-safe target/snapshot lookup, provider acquisition/capture identity/cancellation/cleanup, same-factory composition/divergence rejection, missing/caller-owned snapshot lifecycle, and dual-lease tests plus PostgreSQL 14–18 sandbox and concrete-provider metadata/decryption/same-connection capture through an explicit test-only loopback connector; each matrix cell composes digest-pinned Valkey 8 failure→release→retry→ack and real one-second signal/attempt expiry→successor takeover with PostgreSQL abandon→complete state | [ADR-0002](adr/ADR-0002-isolated-dry-run-and-preflight.md), [ADR-0004](adr/ADR-0004-durable-runs-and-recovery.md), [Threat model](security/forward-engineering-threat-model.md) | **Partially implemented release blocker.** Sandbox lifecycle/isolation, unmodified guarded-route integration, deployed credential/network constraints, application startup wiring, process/container restart, target audit, and worker execution remain absent; legacy rollback-on-live is not evidence. | +| FE-INV-006: fingerprint revalidation for dry run/apply | Isolated execution checks a strict materialized-base digest before DDL and strict target digest after commit; `complete_isolated_dry_run` binds that exact result to stored-plan provenance and a fixed next CAS; `execute_bound_live_preflight` binds caller-owned fresh capture and checks to one read-only repeatable-read transaction; `complete_live_preflight` validates that exact result and server-derives terminal CAS classification | `test_forward_isolated_dry_run.py`; PostgreSQL 14–18 integration; isolated/live-preflight result classification, durable attempt CAS, dual-lease binding, match/drift, rollback, and cancellation tests | [TRD](TRD.md), [Runbook](runbooks/forward-engineering.md) | **Partially implemented release blocker.** Deployed sandbox lifecycle, target credential/routing isolation, application startup wiring, worker execution, and in-lock apply revalidation remain Planned. | +| FE-INV-007: in-lock data preconditions | Compiler emits precondition metadata; `forward/apply_lock_plan.py` emits deterministic existing-table locks; `forward/pre_apply_revalidation.py` binds the signed plan/version/digests to lock-covered checks, re-derives fixed parameterized privilege probes, provides fail-closed positional assessment, and captures a strict snapshot plus every privilege/precondition row in one caller-owned read-only repeatable-read transaction | Compiler metadata/risk tests, signed-plan/probe/observation/capture failure tests, plus PostgreSQL 14–18 owner/denied-role, same-connection capture, and compiled-lock/concurrent-insert/check/rollback acceptance | [ADR-0003](adr/ADR-0003-plan-execution-segmentation.md), [Runbook](runbooks/forward-engineering.md) | **Partially implemented capture boundary with ephemeral database-semantics evidence;** apply-attempt credential binding, advisory/object lock orchestration, in-lock repetition, transaction execution, and deployed concurrency proof remain absent. | +| FE-INV-008: one transactional segment | Current compiler marks admitted statements transactional; isolated executor rejects other kinds and executes one transaction; the target-free pre-apply manifest emits zero segments for no-op work or exactly one ordered all-transactional segment | Compiler/manifest structured-plan tests; isolated rollback/cancellation/fixed-error tests; PostgreSQL 14–18 round trip | [ADR-0003](adr/ADR-0003-plan-execution-segmentation.md), [TRD](TRD.md) | **Partially implemented for isolated dry run and the apply input boundary.** Live apply transaction execution, timeout/postcondition enforcement, rollback proof, and recovery remain Planned. | +| FE-INV-009: durable idempotent run, no apply replay | `models.py`; migrations `0010`/`0012`; `forward/migration_run.py`; `jobs/migration_dispatch_relay.py`; run APIs | `test_forward_migration_run.py`; `test_migration_dispatch_relay.py`; `test_migration_dispatch_lifecycle.py`; `test_api_migration_runs.py`; PostgreSQL 14–18 integration | [ADR-0004](adr/ADR-0004-durable-runs-and-recovery.md), [Data model](DATA_MODEL.md), [Runbook](runbooks/forward-engineering.md) | **Partially implemented:** durable identity, dry-run dispatch, non-dispatched apply-intent confirmation, opt-in UUID-only publication, CAS/cancellation writers, event chain, and polling exist; deployment failover, execution, and apply no-replay recovery remain blockers. | +| FE-INV-010: deployer and evidence-bound approval | `permissions.py`; `api/migration_plans.py`; `forward/migration_run.py`; migration `0012` | Role, exact current revision/plan/passed-run/base/typed-target/destructive, idempotency, and PostgreSQL 14–18 persistence tests | [ADR-0005](adr/ADR-0005-authority-approvals-and-convergence.md), [PRD](PRD.md) | **Partially implemented:** exact confirmed deployer apply intent locks the model row, rejects `stale_revision`, and persists without dispatch; independent approval policy, apply-time target revalidation, and execution remain Planned. | +| FE-INV-011: no DSN/secret/raw SQL in queue/events/browser | Encrypted connection/redaction boundaries; `migration_run_dispatch` identifier-only schema; `forward/migration_run.py`; `jobs/migration_dispatch_relay.py`; sanitized worker failure codes | run/outbox schema, UUID-only publication against digest-pinned real Valkey, evidence, and worker dispatch leakage regressions; DSN guard/redaction and snapshot error tests | [Threat model](security/forward-engineering-threat-model.md), [Data model](DATA_MODEL.md) | **Partially implemented:** dispatch storage and the real Valkey signal contain no execution material, while durable evidence and generic worker failures reject secret-bearing content; future consumer, sandbox/apply payloads, and browser surfaces remain unproved. | +| FE-INV-012: only matching verification snapshot is verified | — | — | [ADR-0005](adr/ADR-0005-authority-approvals-and-convergence.md), [UML](UML.md), [Runbook](runbooks/forward-engineering.md) | **Planned release blocker.** | +| FE-INV-013: uniform cross-project masking | Current model/plan/connection routes | focused model/plan/apply tests | [Contract §9](contracts/forward-engineering-v1.md), [Threat model](security/forward-engineering-threat-model.md) | **Partially implemented:** full HTTP role/IDOR matrix and future resources absent. | +| FE-INV-014: unknown fields/kinds/versions fail closed | Canonicalizer, snapshot/compiler boundary, isolated executor dispatch, and exact run/event state contracts | unknown-field/unsupported-feature/version/operation and invalid run/event tests | [Contract §2, §4, §7](contracts/forward-engineering-v1.md), [Test strategy](TEST_STRATEGY.md) | **Partially implemented:** current model/plan/isolated-run boundaries fail closed; live apply dispatch remains absent. | + +## Unresolved gaps and priority + +### P0 — production release blockers + +| Gap | Why documentation cannot close it | Required evidence | +|---|---|---| +| Application startup wiring, worker execution, and live apply dispatch | Atomic identifier-only outbox persistence, authorized dry-run creation/cancellation, scheduled bounded UUID-only queue publication, execution-neutral consumer and dual-lease binding contracts, CAS writers, integrity-checked polling, and exact non-dispatched apply-intent creation exist. Application consumer lifecycle, credential-bound worker execution, and live apply dispatch remain unavailable. | Deployment relay failover, consumer restart/cancellation integration tests, approval-bound dispatch and executor evidence | +| Isolated sandbox and read-only preflight | Exact-plan/read-only cores, durable attempt primitives, and execution-neutral consumer binding still have no startup wiring, provisioning, deployed isolation, cleanup, production credential, or worker execution authority. CI-only evidence does not establish deployment controls. | Network/credential isolation and cleanup proof, dependency materialization, application lifecycle binding, and live target audit evidence | +| Drift-safe executor | Stored plan metadata alone does not acquire locks, enforce preconditions, bound time, or roll back. | Versioned stored-plan dispatch, lock/timeout/concurrency/rollback integration tests | +| Idempotency and uncertain-commit recovery | A lease retry can duplicate destructive DDL unless apply is never replayed after the boundary. | Crash/fault injection and reconciliation to `verified`, `not_applied`, or `outcome_unknown` | +| Post-apply convergence | Commit acknowledgement is not desired-state proof. | Dedicated verification snapshot and exact/third-digest E2E assertions | +| Product UI and accessibility | Users can review plans, request dry runs, observe verified run evidence, and request cancellation, but cannot complete apply, recovery, or convergence through the frontend. | Complete forward modal orchestration, apply/recovery controls, browser E2E, and WCAG 2.2-oriented automation and manual evidence | +| Real PostgreSQL/version evidence | The 14–18 matrix separates migrated metadata, DDL sandbox, and preflight target databases while proving exact-plan convergence, run/outbox, and preflight reads through an ephemeral restricted login that lacks database CREATE/TEMP and is denied DDL; it does not prove deployed lifecycle, production credentials, audit, concurrency, failures, or representative sizes. | Adversarial catalogs, deployed least-privilege identities, external writers, cleanup/fault injection, representative sizes | +| Kill switch, alerts, recovery drill | Operators cannot contain or classify a live incident using design prose. | Implemented gate, metrics/alerts, backup/restore posture, non-production game day | +| Legacy live-route retirement/containment | The transitional path bypasses immutable plan, dry-run, evidence, and convergence authority. | Disable/retire decision, ingress/app gate, regression tests and operator procedure | + +### P1 — contract and maintainability gaps + +- Decide whether `POST /api/schema-models/by-project/{project_space_uuid}` is + the public v1 route or whether the project-nested design alias will be added. +- Standardize forward errors using the selected RFC 9457-compatible contract; + current routes commonly return string `detail` values. +- Add database-enforced immutability or equivalent privileged write controls + for model revisions, plans, and future events. +- Add forward modules/APIs to explicit statement and branch coverage scope and + enforce the 100% owned-code policy in CI. +- Extend the implemented required-document/core-link/route contract test to + validate every internal link, status label, Mermaid parse/render, ADR index + consistency, and stale implementation claims. +- Create a frontend component/accessibility specification when implementation + starts; the existing `docs/ui-ux/product-spec.md` remains useful for the + current ERD editor but does not describe forward engineering. +- Define outbox semantics, same-tenant enforcement, retention, deletion policy, + and an independently anchored audit sink before enabling workers. Run/event + columns, idempotency, sequencing, indexes, and the in-database digest chain + are implemented in ORM/Alembic and remain subject to reviewed migrations. +- Produce a release-scoped ASVS 5.0.0 applicability/evidence matrix; the + standards document intentionally makes no certification claim. + +## Documentation ownership and drift rules + +| Change | Documents that must change in the same PR | +|---|---| +| User outcome, role, or release scope | PRD, contract, traceability audit; ADR if authority changes | +| API route, request/response, error, state, or status meaning | TRD, contract, UML sequence/state, tests | +| ORM/Alembic entity, FK, uniqueness, retention, or delete behavior | Data model/ERD, TRD, runbook, migration tests | +| Compiler grammar, operation kind, risk, blocker, or PostgreSQL version | Contract support matrix, TRD, ADR if recovery changes, test matrix | +| Sandbox/network/credential topology | Architecture, UML, threat model, runbook, deployment tests | +| Executor transaction, lock, timeout, retry, cancellation, or recovery | ADR, contract, UML states, threat model, runbook, fault tests | +| Frontend review/approval/recovery flow | PRD, UML, UI spec, test strategy and accessibility evidence | +| Standards version or compliance statement | Standards, threat model, test/release evidence; never update by unsupported claim | + +Implementation status must change only with a code/test evidence link. An +accepted ADR means the direction is approved; it never means the runtime is +complete. Figma/FigJam may aid review, but repository Mermaid, code, +migrations, tests, and versioned documents remain authoritative. + +## Final sufficiency decision + +| Question | Decision | +|---|---| +| Are ADR, PRD, TRD, Architecture, UML, and ERD now present and internally coherent for Phase 1? | **Yes — Adequate**, subject to normal PR review and link/diagram validation. | +| Do they distinguish current implementation from the accepted target? | **Yes.** Model/revision/plan and a bounded isolated execution core are current; deployed sandbox lifecycle/workers/apply/UI remain Planned; browser arbitrary SQL and production rollback-as-dry-run are Rejected target behavior. | +| Is code/test/document traceability sufficient to select the next implementation slice? | **Yes.** P0 work and invariant gaps are explicitly mapped. | +| Is the feature production-ready or safe to enable because the documentation is now extensive? | **No.** Runtime, PostgreSQL, fault, security, accessibility, and operational gates remain open. | +| Can a future “done” claim rely on these documents alone? | **No.** Exact-head machine and operational evidence is mandatory. | + +The correct next exit is not more unbounded documentation. It is to implement +the highest-risk P0 vertical slice under these contracts, update status and +traceability with real evidence, and repeat the audit before production +enablement. + +## Canonical document index + +- [Architecture](../ARCHITECTURE.md) +- [PRD](PRD.md) +- [TRD](TRD.md) +- [ADR index](adr/README.md) +- [Forward-engineering v1 contract](contracts/forward-engineering-v1.md) +- [UML](UML.md) +- [Data model and ERD](DATA_MODEL.md) +- [Threat model](security/forward-engineering-threat-model.md) +- [Operational runbook](runbooks/forward-engineering.md) +- [Test strategy](TEST_STRATEGY.md) +- [Standards baseline](STANDARDS.md) +- [Detailed approved design](superpowers/specs/2026-08-09-forward-engineering-design.md) diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 000000000..4f6d089ab --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,182 @@ +# Product Requirements: Safe Forward Engineering + +## Document control + +- **Product:** pg-erd-cloud +- **Status:** Approved product direction; Phase 1 control plane partially implemented +- **Date:** 2026-08-09 +- **Authoritative scope:** This PRD defines outcomes and release gates. The + [TRD](TRD.md), [v1 contract](contracts/forward-engineering-v1.md), and + [ADRs](adr/README.md) define technical behavior. +- **Visual companion:** [Figma FigJam board](https://www.figma.com/board/MLWimuWoOWhatQ239QihfP). + Repository Markdown and Mermaid remain authoritative. + +## Product outcome + +Data architects can turn a reverse-engineered PostgreSQL schema into a reviewed +desired model, prove the exact immutable migration plan outside production, +authorize it with least privilege, and receive durable evidence that the live +schema converged. A missing proof, unsupported construct, stale target, or +uncertain outcome must stop the workflow or be reported honestly; it must never +be converted into apparent success. + +The current repository implements the control-plane foundation plus partial +validation/recovery primitives: canonical model revisions, immutable plans, +fail-closed subset handling, a `deployer` role, isolated execution and bounded +preflight cores, durable run/event/outbox identity, and hashed lease-bound +worker-attempt ownership. Consumer-to-attempt binding is **Implemented** as an +execution-neutral dual-lease adapter. A concrete stored-PostgreSQL preflight +provider is **Partially implemented** for guarded lookup, in-memory decryption, +DNS/SSRF/TLS-pinned connection, post-connect revalidation before any target +read, same-connection capture, fixed failures, and +cleanup. Application startup wiring, deployed credential/network isolation, +sandbox lifecycle, worker execution, durable apply, post-apply convergence, +and the frontend workflow are **Planned** release blockers. Typed browser +transport is **Partially implemented** for the current plan/run endpoints and +accepts only identifiers, digests, typed confirmation, and optimistic state +versions. The plan review panel is **Partially implemented** as an accessible, +read-only view of provenance, risk, blockers, executable statements, and +review-only proposals. Fixed loading/error/retry behavior and stale-response +suppression is **Partially implemented** for exact plan retrieval; Forward UI +remains **Planned**. The Forward Engineering modal shell is **Partially +implemented** as a dedicated accessible container. The dry-run intent control +is **Partially implemented**: it follows the server's `can_dry_run` and blocker +decision, submits only the exact plan identity/digest, prevents concurrent +submits, and retains one bounded idempotency key across an ambiguous retry. +Within an open modal, an accepted dry run replaces any supplied audit run so +only one status/polling surface remains active. Closing and reopening restores +the caller-supplied run instead of reusing that modal-session override. +The apply intent control is **Partially implemented**: only an exact passed +dry-run for the reviewed plan/digest/base enables a deployer confirmation form; +the operator must type the exact target connection name and acknowledge any +destructive plan, and ambiguous retries preserve both one idempotency key and +the first submitted confirmation. The accepted result is a non-dispatched +intent, not live DDL authority. Graph/model adapters, apply execution controls, +and broader orchestration remain absent. +The run status and audit +panel is **Partially implemented** as an optional read-only exact-run view. It +announces the bounded state meaning, pending cancellation intent, terminal +`cancelled` acknowledgement, and sanitized error +code, and renders hash-chain event metadata without exposing generic evidence +payloads. Sequential terminal-aware polling is **Partially implemented**: a +new request is scheduled only after the preceding response and stops at the +first terminal state. The cancellation intent control is **Partially +implemented**: it appears only before a terminal state and before a recorded +intent, submits the exact current state version once, refreshes after acceptance, +and never replays an ambiguous write automatically. The durable dry-run +consumer now persists terminal cancellation acknowledgement and settles +already-terminal redelivery without replay. Apply/recovery controls and +browser E2E remain absent; deployed in-flight process cancellation is Planned. + +## Actors and authority + +| Actor | Job | Maximum forward authority | +|---|---|---| +| Viewer | Inspect models, plans, risks, and evidence | Read | +| Editor | Save desired revisions and request plans/dry runs | No production DDL | +| Deployer | Authorize a reviewed exact plan for one target | Live apply | +| Owner | Manage membership and deployment authority | Deployer + administration | +| Operator/auditor | Diagnose durable states and preserve evidence | No implicit product role | + +The API is authoritative. Hiding or disabling a UI control never substitutes +for server authorization. + +## Required user journey + +1. Select a succeeded snapshot from the intended connection. +2. Edit the desired schema and save an immutable successor revision under + optimistic concurrency. +3. Review the exact semantic diff, ordered SQL, dependencies, privileges, + blockers, reversibility, and lock/scan/rewrite/data-loss risks. +4. Execute the exact plan in an isolated compatible PostgreSQL sandbox, then + obtain bounded read-only live preflight evidence. +5. Resolve drift or validation failure. A stale model, plan, snapshot, or target + cannot proceed. +6. As a deployer, type the exact target name and separately acknowledge + destructive work. +7. Queue one durable apply using an idempotency key; closing the UI does not + cancel accepted work. +8. Observe recovery and verification states until a persisted post-apply + snapshot proves exact convergence or reports a truthful non-success state. + +## Functional requirements + +| ID | Requirement | Current status | Release evidence | +|---|---|---|---| +| FE-PRD-001 | Persist a project-scoped desired model as immutable numbered revisions; reject stale saves. | **Implemented** in backend | API concurrency and authorization tests | +| FE-PRD-002 | Compile one exact revision against one exact connection and succeeded snapshot; the browser supplies intent, not executable SQL. | **Implemented** for a narrow PostgreSQL subset | Model/plan API and compiler tests | +| FE-PRD-003 | Every admitted target difference becomes an operation or blocker; a blocked plan contains no executable statements while retaining independent supported deltas as review-only proposals. | **Implemented** for the current canonical subset | Per-field mutation tests and realistic snapshot fixtures | +| FE-PRD-004 | Show immutable plan provenance, executable or review-only proposed SQL, risk, preconditions, blockers, digest, and expiry. | **Partially implemented**; API and standalone read-only review panel exist, while workflow orchestration and browser E2E are absent | Typed API contract and UI tests | +| FE-PRD-005 | Dry run executes exact stored-plan DDL only in an isolated compatible sandbox; production receives bounded reads only. | **Partially implemented:** execution core, provider-neutral durable handler with cooperative cancellation deadlines, concrete guarded stored-PostgreSQL preflight provider, and dedicated ephemeral PostgreSQL 14–18 databases exist. The matrix stores the encrypted target, composes the concrete provider, resumes after an expired attempt without sandbox replay, and proves same-connection read-only capture. It replaces only the private CI target connector with an explicit test-only loopback seam because the production DNS/SSRF guard correctly rejects that address. Deployed provisioning, unmodified guarded-route integration, credential/network isolation, process isolation/kill, lifecycle, startup, process restart, and worker operation remain Planned | Network/egress-isolation, cleanup, deployment identity, provider-kill evidence, and live no-DDL evidence | +| FE-PRD-006 | Detect base drift before dry run and again under apply-time locks before DDL. | **Partially implemented:** the isolated-dry-run core validates the materialized base digest before DDL; a target-free manifest binds the signed plan/base/target/PostgreSQL version to deterministic lock targets, structured database `CREATE`/schema `CREATE`/table `OWNER` requirements, and structured checks while rejecting compiler-v1 privilege-label drift. Fixed parameterized privilege probes re-derive that manifest from the exact signed plan. A caller-owned capture primitive now re-derives the manifest, captures one strict snapshot, and observes every privilege/precondition position in one bounded read-only repeatable-read transaction before pure assessment. It does not bind the connection to the stored target/attempt or hold apply locks. Credential/target identity binding, lock acquisition, in-lock repetition, and live apply remain **Planned** | Injected-drift, privilege-denial, and concurrency tests | +| FE-PRD-007 | Require deployer authority, exact current model revision, plan/dry-run digests, typed target confirmation, and destructive acknowledgement. | **Partially implemented**; the non-dispatched apply-intent route locks the model row, rejects `stale_revision`, and persists those exact bindings, while apply-time target revalidation/execution remain Planned | Role/tamper/race tests | +| FE-PRD-008 | Persist idempotent dry-run/apply resources and append-only evidence; never auto-replay an ambiguous apply. | **Partially implemented**; dry-run and non-dispatched apply intents/resources/evidence, a PostgreSQL 14–18 same-key apply-intent race, DB-durable hashed attempt CAS, exact consumer-to-attempt binding, terminal cancellation acknowledgement, terminal redelivery settlement without sandbox/preflight replay, and pre-live-read attempt-expiry takeover without sandbox replay exist; application startup wiring, process/container recovery, commit-uncertainty reconciliation, and apply execution remain absent | Live-executor crash/no-replay, state-machine, and exact-owner lease tests | +| FE-PRD-009 | Re-introspect after known commit and compare a persisted verification snapshot to the desired digest. | **Planned** | End-to-end empty-residual-diff assertion | +| FE-PRD-010 | Provide a keyboard-operable five-stage review/dry-run/apply/verification journey without reusing the export modal. | **Planned** | Accessibility, component, and browser E2E tests | + +## Non-functional requirements + +| ID | Requirement | Gate | +|---|---|---| +| FE-NFR-001 Safety | No arbitrary browser SQL on the graphical model-to-apply path; unsupported semantics fail closed. | Mandatory | +| FE-NFR-002 Integrity | Revision, plan, approval, dry run, run, and verification evidence bind exact digests and immutable IDs. | Mandatory | +| FE-NFR-003 Tenancy | Cross-project and unauthorized resource identities are uniformly masked; roles are enforced server-side. | Mandatory | +| FE-NFR-004 Operability | Timeouts, cancellation boundaries, recovery, `outcome_unknown`, cleanup, and kill-switch procedures are documented and tested. | Mandatory | +| FE-NFR-005 Privacy | DSNs, credentials, row values, complete desired JSON, and raw SQL batches do not enter logs, events, queue payloads, or metrics. | Mandatory | +| FE-NFR-006 Accessibility | The workflow meets WCAG 2.2 AA interaction/status/error expectations and completes by keyboard. | Mandatory | +| FE-NFR-007 Compatibility | PostgreSQL 14–18 capability is explicit; unknown contract versions and operation kinds are rejected. | Mandatory | +| FE-NFR-008 Verification | Exact release-head backend/frontend tests, typing, build, security checks, PostgreSQL integration, and browser E2E pass. | Mandatory | + +## Success measures and release gates + +The following are binary release gates, not aspirational dashboards: + +- zero paths from graphical intent to execution that accept browser SQL; +- zero admitted canonical changes without an operation or blocker; +- zero live DDL during dry run; +- zero DDL after detected stale revision, expired plan, failed dry run, or drift; +- one effective run for concurrent identical idempotency submissions; +- zero automatic replay after execution reaches an ambiguous commit boundary; +- empty semantic residual diff for every supported successful round trip; +- all documented exact-head quality and accessibility checks pass. + +Production baselines for plan volume, stage duration, timeout rate, drift rate, +and failure classes do not yet exist. Operators must establish them during a +non-production pilot; this document does not invent numeric SLOs before runtime +evidence exists. + +## UX acceptance + +- The UI labels current support as partial and never calls legacy rollback + validation an isolated dry run. +- Plan SQL is read-only. A user changes intent by editing and saving a successor + model, not by editing SQL text. +- Blockers name the unsupported object and prevent dry run/apply actions. +- Risk is conveyed by text and structure, not color alone. +- Progress and all terminal states use accessible names/live regions; closing a + modal never misrepresents or silently cancels durable work. +- `outcome_unknown`, `verification_failed`, `failed_rolled_back`, and + `applied_with_drift` remain visually and semantically distinct. + +## Non-goals for the first production slice + +- arbitrary SQL editing or execution; +- heuristic rename inference; +- DML or automated backfills; +- scheduled or automatic production apply; +- automatic rollback generation; +- Snowflake or MySQL live apply; +- non-transactional/online operations such as `CREATE INDEX CONCURRENTLY`; +- claims of compliance certification based only on repository controls. + +## Delivery phases + +| Phase | Scope | Status | +|---|---|---| +| 1. Plan authority | Model revisions, canonical digest, snapshot adapter, structured plan persistence, deployer role | **Partially implemented in this branch** | +| 2. Validation | Plan retrieval, isolated sandbox, live read-only preflight, drift evidence | **Partial:** plan retrieval, signed-plan sandbox execution core, strict convergence, bounded live-read primitive, durable attempt ownership, consumer-to-attempt binding, an exact metadata/lease target lookup, and a concrete guarded stored-PostgreSQL provider with post-connect revalidation before any target read exist. Provider-backed PostgreSQL-version acceptance exists through a test-only loopback connector; sandbox lifecycle, application startup wiring, deployed credential/network isolation, and worker execution remain **Planned**. | +| 3. Apply/recovery | Durable runs/events, approval, locks/timeouts, idempotency, reconciliation | **Partial foundation:** run/event/outbox identity, cancellation intent and terminal acknowledgement, exact-owner attempt leases, terminal dry-run redelivery settlement, and an exact non-dispatched apply intent exist; live dispatch/execution/recovery remain Planned | +| 4. Convergence UI | Post-apply snapshot/diff plus accessible frontend workflow | **Partially implemented:** review, dry-run intent, non-dispatched apply intent, run status/audit, polling, and cancellation surfaces exist; apply execution/recovery/convergence and composed E2E remain Planned | + +No phase may describe the end-to-end feature as production-ready before every +release gate for phases 1–4 is satisfied. diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md new file mode 100644 index 000000000..12b49c87a --- /dev/null +++ b/docs/STANDARDS.md @@ -0,0 +1,238 @@ +# Forward Engineering Standards and Evidence Baseline + +- **Document status:** Active engineering baseline +- **Runtime status:** Partially implemented; no compliance certification claimed +- **Last reviewed:** 2026-08-11 + +This document selects primary standards and one directly relevant research +paper for design and verification. It does not assert PostgreSQL compatibility, +WCAG conformance, ASVS verification, OWASP “compliance,” NIST conformance, or +any third-party certification. Those claims require scoped evidence against an +identified release. + +Status labels are normative: **Implemented**, **Partially implemented**, +**Planned**, and **Rejected**. + +## Baseline and precedence + +| Source | How pg-erd-cloud uses it | Normative status for this project | +|---|---|---| +| PostgreSQL 18 official documentation | Lock/transaction behavior, `ALTER TABLE`, index transaction capability, and timeout semantics; behavior is verified separately on supported majors 14–18. | Normative technical reference, plus version-matrix tests | +| Valkey 8 official release and container image | Queue-signal sorted-set semantics, UUID-only ready/processing isolation, monotonic exact lease renewal, expired-owner renewal rejection, stale-token rejection, retry release, and acknowledgement are verified through the production adapter against a digest-pinned official image. Every PostgreSQL 14–18 matrix cell also composes Valkey with PostgreSQL-backed hashed attempt ownership and the execution-neutral consumer, proving durable abandon/retry/complete ordering, real one-second dual-lease expiry/takeover, stale-owner rejection, and exact signal cleanup across both stores. This does not prove production topology, startup wiring, process/container restart, failover, credentials, or worker recovery. | Integration-test runtime reference; scheduled publisher, signal lease/consumer contract, DB attempt primitives, dual-lease binding, and composed in-process ephemeral-store expiry/recovery evidence Implemented; application startup/deployment failover/worker evidence Planned | +| W3C WCAG 2.2 Recommendation | Keyboard, focus, labels, status/error communication, and target Level AA acceptance for the forward UI. | Normative product accessibility target; conformance not yet demonstrated | +| OWASP ASVS 5.0.0 | Verification requirements for architecture, authentication, access control, validation, API, data protection, logging, and secure communication. | Normative security verification baseline selected by the project; not certification | +| OWASP Top 10:2025 | Web-application risk taxonomy used to check design and test coverage. | Threat-model checklist, not a control catalog or certification | +| OWASP API Security Top 10:2023 | API authorization, resource consumption, SSRF, misconfiguration, inventory, and unsafe downstream consumption review. | API threat checklist | +| NIST SP 800-218, SSDF 1.1 | Secure-development practices for prepare/protect/produce/respond activities and release evidence. | Normative secure-development process baseline | +| NIST SP 800-218 Rev. 1, SSDF 1.2 initial public draft | Future-update watchlist. It was an initial public draft as of this review. | **Non-normative** until NIST publishes a final revision and the project adopts it | +| RFC 9457 | Target media model for consistent machine-readable API problem details. | Planned API error-contract baseline; current errors are not yet uniform RFC 9457 responses | +| Rae et al. (2013) | Research evidence that online schema change requires controlled intermediate states, sequencing, and verification. | Informative only; F1-specific mechanisms are not PostgreSQL prescriptions | + +If a source conflicts with observed PostgreSQL behavior on a supported major, +the operation fails closed and requires an ADR/compiler contract update. A +research paper never overrides official PostgreSQL behavior, a security +requirement, or repository evidence. + +## PostgreSQL engineering rules + +### Locks, scans, and rewrites + +- Treat `ALTER TABLE` as capable of acquiring `ACCESS EXCLUSIVE` unless the + exact command documentation states a weaker level. Compiler risk metadata + and review UI must not imply that a quick catalog change is harmless. +- PostgreSQL locks are normally held until transaction end. A transaction that + performs multiple DDL statements can therefore accumulate blocking impact; + bounded lock and statement timeouts remain mandatory even when rollback is + available. +- Type changes and constraint validation can scan or rewrite data and indexes. + Compiler v1 classifies every actual `ALTER COLUMN ... TYPE` as destructive + with possible rewrite, table scan, and data-loss risk. Isolated execution and + live data-aware preflight provide separate evidence; they do not downgrade + that approval classification. +- `CREATE INDEX CONCURRENTLY` cannot execute inside a transaction block and can + leave recovery work after failure. It and all non-transactional operations + are **Rejected for v1**, not mixed into an executable partial plan. +- `lock_timeout`, `statement_timeout`, and transaction timeout policy must be + finite and scoped to the worker session/transaction. A timeout establishes a + bounded wait or execution failure; it does not by itself prove rollback or + non-commit. + +### Version support + +The model contract accepts PostgreSQL majors 14–18. PostgreSQL 18 documentation +is the current design reference, but no operation ships on older supported +majors solely by inference. The [test strategy](TEST_STRATEGY.md) requires real +catalog, syntax, lock, privilege, transaction, and convergence tests on each +major. + +### Project application + +| Rule | Repository application | Status | +|---|---|---| +| SQL authority | Canonical server model and structured compiler; dialect-correct identifier quoting. | Partially implemented | +| Lock/rewrite disclosure | Each current statement has risk severity, declared lock, scan/rewrite/data-loss fields. | Implemented control-plane metadata; runtime measurement Planned | +| Live preconditions | Table-empty, no-NULL, and castability preconditions are represented. | Implemented plan metadata; bounded live-preflight execution and completion CAS are Implemented, as are the signed-plan lock-covered manifest, fixed parameterized privilege probes, and caller-owned same-connection read-only snapshot/privilege/precondition capture. Stored-target/attempt binding, target lock acquisition, durable apply-worker binding, and apply-time in-lock repetition remain Planned | +| Transaction capability | Current emitted statements declare `transactional: true`; blockers set executable `statements=[]`. Supported deltas may remain as review-only `proposed_statements`, with their risks included. | Implemented compiler subset; bounded all-transactional isolated executor core is Implemented, as is the target-free zero/no-op-or-one ordered apply-segment input; deployed sandbox worker and live apply transaction/rollback execution remain Planned | +| Drift control | Plans store base/target digests and bind a succeeded snapshot. | Implemented provenance, target-free signed manifest, fail-closed pure assessment, and bounded caller-owned same-connection strict snapshot capture; stored-target/attempt identity proof, lock proof, and in-lock pre-apply repetition remain Planned | +| Completion evidence | Exact post-apply target digest from a persisted verification snapshot. | Planned | + +## Security verification baseline + +The project uses ASVS 5.0.0 as a requirements source and OWASP Top 10/API Top +10 as threat-discovery views. Control identifiers must be pinned during a +release verification pass rather than guessed in this document; the release +artifact records the exact ASVS requirement IDs, applicability, evidence, and +exceptions. + +| Security concern | Required project evidence | Current status | +|---|---|---| +| Architecture and trust boundaries | Versioned ADRs, architecture/UML, threat model, separate metadata/sandbox/live authority. | Partially implemented; dedicated integration databases prove the code boundary, while deployed sandbox/run isolation remains Planned | +| Authentication and session integrity | Existing authentication, CSRF for state changes, credentialed CORS, revocation/rate-limit tests. | Implemented general controls; forward HTTP matrix Planned | +| Object-level and function-level authorization | Uniform other-project 404; `viewer < editor < deployer < owner`; server checks on every resource/action. | Partially implemented | +| Input and execution safety | Unknown fields fail closed; server-rendered SQL; known structured executor kinds only; no browser SQL authority. | Partially implemented; legacy endpoint remains | +| SSRF and outbound target control | Explicit host allowlist, restricted-address rejection, DNS resolution and IP pinning, deployment egress verification. | Application guard Implemented; deployment evidence Planned | +| Cryptography and secret handling | AEAD at rest, in-memory decryption after authorization, redaction, rotation/recovery/key-separation policy. | Partially implemented | +| Resource consumption | Payload/statement bounds, API rate limits, worker concurrency, sandbox quota, finite target timeouts. | Partially implemented; worker controls Planned | +| Logging and audit | Correlation identifiers, append-only run events, bounded redacted diagnostics, alertable terminal states. | General request observability exists; run audit Planned | +| Supply chain and secure release | Hash-locked backend dependencies, npm lockfile, pinned CI actions, type/test/build/SAST and exact-head evidence. | Partially implemented; forward coverage/integration gates Planned | + +Relevant threat categories include broken access control/object authorization, +security misconfiguration, injection, cryptographic failures, software/data +integrity failures, logging/alerting failures, unrestricted resource +consumption, SSRF, improper inventory, and unsafe consumption of target-driver +diagnostics. Mapping is for coverage; it is not a declaration that a category +has been eliminated. + +## NIST SSDF application + +NIST SSDF 1.1 is the adopted final process baseline: + +| SSDF practice group | pg-erd-cloud evidence | Status | +|---|---|---| +| Prepare the Organization (PO) | Named architecture/security/operator owners; standards, threat model, release gates, and training/operating assumptions. | Partially implemented | +| Protect the Software (PS) | Protected source/CI, lockfiles, pinned actions, secret boundaries, provenance and review. | Partially implemented; branch/release evidence is external to this document | +| Produce Well-Secured Software (PW) | Server-authoritative design, ADRs, tests, code review, SAST, fail-closed compiler and planned fault injection. | Partially implemented | +| Respond to Vulnerabilities (RV) | `SECURITY.md`, dependency/SAST workflows, threat-model updates, incident evidence and runbook closure. | Partially implemented | + +SSDF 1.2 (NIST SP 800-218 Rev. 1 initial public draft, published December 17, +2025) is explicitly **not normative** for this release baseline. Maintainers may +track its changes, but must not cite draft alignment as final NIST conformance. + +## Accessibility baseline + +The planned forward modal and all recovery views target WCAG 2.2 Level AA. At +minimum, design and tests must address: + +- complete keyboard operation without timing-dependent traps; +- visible focus, logical focus order, modal containment, Escape behavior only + where cancellation is safe, and focus restoration; +- programmatic names, headings, instructions, error association, and risk table + semantics; +- status/progress/error live regions that do not collapse distinct recovery + outcomes; +- adequate contrast, non-color risk indicators, target size, reflow/zoom, and + no obscured focus; and +- authentication/confirmation interactions that remain understandable and do + not depend on memory or inaccessible puzzle behavior. + +Automated checks are necessary but insufficient. Manual keyboard and +representative assistive-technology evidence must be attached to the release. +The current repository does not contain the forward UI, so no WCAG conformance +claim is made. + +## API problem details + +RFC 9457 is the **Planned** uniform error envelope. Before public v1, the API +must choose and test `application/problem+json` responses with stable problem +type URIs or an explicitly documented compatible media contract. At minimum, +forward errors need machine-stable codes/types for stale revision/plan, +expired plan, idempotency conflict, invalid model/binding, unsupported schema, +drift, timeout, authorization, and unavailable evidence. + +Current FastAPI routes commonly return `{"detail": ...}` strings. Those are +implementation truth, not RFC 9457 conformance. Cross-project identities must +remain uniformly masked regardless of the future error shape, and problem +details must never include DSNs, raw SQL batches, row values, or +credential-derived driver text. + +## Research use and limits + +Rae et al. describe an online, asynchronous schema-change system for Google's +F1 database. The paper supports the architectural need for explicit +intermediate states, controlled ordering, compatibility, and verification. +pg-erd-cloud does not infer that F1 algorithms, distributed guarantees, or +operational performance apply to PostgreSQL. The project instead uses official +PostgreSQL semantics plus its own integration and fault-injection evidence. + +## Change control + +- Review stable baseline versions at least for each planned release and when a + referenced body publishes a final replacement. +- A major PostgreSQL operation/version expansion, non-transactional executor, + changed approval authority, or changed recovery claim requires an ADR and + contract version. +- Update the threat model, test strategy, runbook, traceability matrix, and + citations in the same change that alters the baseline. +- Record standard version, scoped applicability, evidence location, exceptions, + owner, and expiry in the release artifact. Do not use a green CI badge as a + substitute for the scoped evidence. + +## References + +Internet Engineering Task Force. (2023). *Problem details for HTTP APIs* +(RFC 9457). https://www.rfc-editor.org/rfc/rfc9457.html + +National Institute of Standards and Technology. (2022). *Secure software +development framework (SSDF) version 1.1: Recommendations for mitigating the +risk of software vulnerabilities* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2025, December 17). *Secure +software development framework (SSDF) version 1.2* (NIST SP 800-218 Rev. 1, +initial public draft). https://csrc.nist.gov/pubs/sp/800/218/r1/ipd + +OWASP Foundation. (2023). *OWASP API Security Top 10 – 2023*. +https://owasp.org/API-Security/editions/2023/en/0x11-t10/ + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard +5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ + +OWASP Foundation. (2025). *OWASP Top 10:2025*. +https://owasp.org/Top10/2025/ + +PostgreSQL Global Development Group. (n.d.). *PostgreSQL 18 documentation: +ALTER TABLE*. Retrieved August 9, 2026, from +https://www.postgresql.org/docs/18/sql-altertable.html + +PostgreSQL Global Development Group. (n.d.). *PostgreSQL 18 documentation: +CREATE INDEX*. Retrieved August 9, 2026, from +https://www.postgresql.org/docs/18/sql-createindex.html + +PostgreSQL Global Development Group. (n.d.). *PostgreSQL 18 documentation: +Client connection defaults*. Retrieved August 9, 2026, from +https://www.postgresql.org/docs/18/runtime-config-client.html + +PostgreSQL Global Development Group. (n.d.). *PostgreSQL 18 documentation: +Explicit locking*. Retrieved August 9, 2026, from +https://www.postgresql.org/docs/18/explicit-locking.html + +Valkey Project. (2026, July 21). *Valkey 8.1.9*. +https://valkey.io/download/releases/v8-1-9/ + +Valkey Project. (2026). *valkey/valkey official container image*. Docker Hub. +Retrieved August 11, 2026, from https://hub.docker.com/r/valkey/valkey/ + +Rae, I., Rollins, E., Shute, J., Sodhi, S., & Vingralek, R. (2013). Online, +asynchronous schema change in F1. *Proceedings of the VLDB Endowment, 6*(11), +1045–1056. https://doi.org/10.14778/2536222.2536230 + +World Wide Web Consortium. (2024, December 12). *Web Content Accessibility +Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +## Related authority + +- [Forward-engineering v1 contract](contracts/forward-engineering-v1.md) +- [Threat model](security/forward-engineering-threat-model.md) +- [Test strategy](TEST_STRATEGY.md) +- [Operational runbook](runbooks/forward-engineering.md) +- [Documentation audit](DOCUMENTATION_AUDIT.md) diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md new file mode 100644 index 000000000..fc4545c8f --- /dev/null +++ b/docs/TEST_STRATEGY.md @@ -0,0 +1,466 @@ +# Forward Engineering Test Strategy + +- **Strategy status:** Active +- **Runtime status:** Partially implemented; production release gates remain +- **Scope:** PostgreSQL 14–18 model, plan, dry-run, apply, recovery, and UI +- **Last reconciled with the working tree:** 2026-08-09 + +This strategy separates tests that exist in the repository from evidence that +must still be produced. A test file's presence is not a claim that a particular +commit passed CI. Release evidence must identify the exact commit SHA, command, +environment, PostgreSQL version, and result. + +Status labels are normative: **Implemented**, **Partially implemented**, +**Planned**, and **Rejected**. + +## Quality contract + +The forward-engineering release must prove all of these properties: + +- every admitted model difference becomes a structured operation or blocker; +- unknown or unsupported semantics fail closed, suppress all executable + statements, and retain independently supported deltas only as non-executable + proposals whose risks remain visible; +- the server, not the browser, owns model canonicalization and SQL rendering; +- tenant, actor, model, plan, connection, snapshot, digest, and evidence binding + cannot be crossed or raced; +- dry run executes exact DDL only in an isolated sandbox and uses read-only live + preflight; +- apply revalidates after deterministic locks and executes one transactional + segment without automatic replay; +- fault and timeout outcomes are classified honestly; +- only post-commit re-introspection equal to `target_digest` yields `verified`; +- keyboard and assistive-technology users receive equivalent risk, approval, + progress, error, and recovery information; and +- production-owned backend and frontend code meets the repository's 100% + statement and branch coverage policy on the exact release head. + +## Test layers + +```mermaid +flowchart TB + Operational["Operational and fault-injection drills — Planned"] + E2E["Browser and composed-service E2E — Planned"] + Integration["Real PostgreSQL 14–18 integration — Partial"] + Contract["API, persistence and authorization contracts — Partial"] + Unit["Canonicalizer, adapter and compiler units — Implemented"] + Operational --> E2E --> Integration --> Contract --> Unit +``` + +| Layer | Purpose | Current evidence | Release expectation | +|---|---|---|---| +| Deterministic unit/property | Prove canonical JSON, quoting, digest stability, complete blocker behavior, plan ordering, risk and snapshot adaptation. | Focused forward unit tests exist. | Exact statement/branch coverage plus property/fuzz cases for every admitted grammar and unknown-field boundary. | +| API/service contract | Prove authorization, tenancy, optimistic concurrency, immutable persistence, size limits, expiry/idempotency/error semantics. | Model and plan route functions are tested mainly with faked sessions and mocks. | HTTP-level tests against migrated PostgreSQL, full role/IDOR/CSRF/CORS/error matrix, and database constraint races. | +| PostgreSQL integration | Prove real catalog mapping, executable SQL, locks, timeouts, transactions, privileges, fingerprinting, and convergence. | PostgreSQL 14–18 create distinct metadata, sandbox, and restricted-target databases. Every matrix cell drives the production durable dry-run handler through a test-owned sandbox and the concrete stored-target provider: exact signed DDL converges in the sandbox; the first attempt validates its exact metadata/lease handoff and is interrupted before live reads; its one-second lease then fails closed at expiry; and a successor loads encrypted target metadata, decrypts it, acquires one restricted connection, and captures through that same connection without reopening the sandbox. Only the provider connector is replaced by an explicit test-only loopback seam because the production DNS/SSRF guard correctly rejects the private CI target. The live target remains base-matching, the run reaches `passed` with four hash-chained events, and the attempts persist as abandoned/completed. Every cell also starts digest-pinned Valkey 8 and composes the production UUID-only signal consumer with PostgreSQL-backed attempt acquisition/finish: a sanitized handler failure durably abandons attempt 1 and releases only its exact signal lease, retry completes attempt 2, then an intentionally unacknowledged one-second signal/attempt pair expires and a successor reclaims both stores, abandons attempt 3, completes attempt 4, reaches `passed`, and empties ready/processing/token state. The matrix races two committed transactions with the same non-dispatched apply-intent key, observes the losing PostgreSQL backend wait on the uniqueness winner, and proves one apply run/event with no dispatch. It additionally covers run/outbox persistence, standalone exact signed-plan convergence, same-transaction preflight, privilege/DDL/SELECT denial, lock timeout, forced disconnect, sanitized failures, and rollback cleanup. Provisioning/materialization/cleanup/egress, unmodified guarded-route integration, production credentials, deployed consumer lifecycle, actual process/container restart, and live-executor concurrency remain absent. | Ephemeral PostgreSQL 14, 15, 16, 17, and 18 plus digest-pinned Valkey 8 matrix, then separate deployed sandbox lifecycle, production privilege, live-executor concurrency, cancellation, process/container crash, and cleanup evidence. | +| Browser E2E/accessibility | Prove editor-to-verified workflow, tamper resistance, state recovery, focus, keyboard, names, and live regions. | Existing ERD UI tests do not implement the forward workflow. | Composed backend/frontend/worker/sandbox/target E2E, automated accessibility checks, and manual keyboard/screen-reader evidence. | +| Operational/fault injection | Prove no-replay recovery, kill switch, alerts, runbook, retention, backup/restore, and uncertain commit handling. | No forward run worker or drills exist. | Controlled crash/network/lock/commit-acknowledgement tests and a recorded non-production game day. | + +The concrete stored-PostgreSQL preflight provider has focused unit evidence for +exact guarded lookup, in-memory decryption, guarded connector invocation, +post-connect revalidation before any target read, same-acquired-connection +capture, fixed non-reflecting failures, cancellation, and cleanup. A changed +second lookup closes the acquired connection without capture authority. The +PostgreSQL 14–18 matrix now composes the stored metadata, +decryption, same-connection capture, and cleanup path. It substitutes an +explicit test-only loopback connector because the production DNS/SSRF guard +correctly rejects the private CI target. Unmodified guarded-route integration, +deployed least-privilege credentials, and network identity remain Planned. + +## Current repository evidence + +| Area | Source/tests in the working tree | What they demonstrate | Status / limitation | +|---|---|---|---| +| Canonical model | `app/forward/schema_model.py`; `tests/test_forward_schema_model.py` | Identifier/type bounds, canonical order, stable digests, catalog-spelling type aliases, serial pseudo-type rejection, hostile type/default rejection, duplicates, unknown fields. | Implemented unit boundary; no browser/model round trip or broad generative grammar corpus. | +| Structured compiler and isolated executor core | `app/forward/migration_plan.py`, `app/forward/isolated_dry_run.py`; `tests/test_forward_migration_plan.py`, `tests/test_forward_isolated_dry_run.py`, `tests/test_postgres_migration_run_integration.py` | Determinism, quoted identifiers, create/drop/alter subset, risk/preconditions, blocker suppression, signed plan/version/base validation, rollback, fixed errors, cancellation, and real PostgreSQL 14–18 target-digest convergence. | Implemented bounded core; no deployed sandbox lifecycle, dependency-closure service, worker recovery, or live apply authority. | +| Snapshot adapter | `app/forward/snapshot_adapter.py`; `tests/test_forward_snapshot_adapter.py` | Capability-version recapture gate, OID removal, dropped-slot rejection, primary-key order/deferrability, actual default keys, PK backing index handling, constraint/partition/tablespace rejection. | Partially implemented; fixtures are in-memory and do not prove exhaustive real snapshot compatibility. | +| Model APIs | `app/api/schema_models.py`; `tests/test_api_schema_models.py` | Strong revision-UUID ETag, weak/stale `If-Match`, base-only successor protection, idempotent identical revision, non-member masking. | Mostly direct function tests with faked sessions; database transactions and concurrent writers need integration proof. | +| Plan API | `app/api/migration_plans.py`; `tests/test_api_migration_plans.py` | Revision/connection/snapshot binding, cross-project masking, statement cap, plan digest not misused as DB idempotency. | Mostly direct function tests; expiry is stored but no run gate exists. | +| Roles/legacy apply | `app/permissions.py`, `app/api/connections.py`, `app/request_validation.py`; `tests/test_permissions.py`, `tests/test_api_apply_sql.py`, `tests/test_schema_validation.py`, `tests/test_request_validation.py` | `deployer` ordering, default-deny persistent legacy apply before credential access, explicit opt-in compatibility, exact multiline-text control boundaries, non-reflecting validation responses, conservative SQL rejection and DSN-redacted errors. See [multiline SQL request controls](doctoring/multiline-sql-request-controls.md). | Implemented default-deny transitional path; character validation protects transport/log integrity and is not the SQL authorization boundary or target-workflow evidence. | +| DBML identifier export | `app/spec/dbml_import.py`, `app/ddl/export.py`, `app/api/dbml.py`; `tests/test_dbml_import.py`, `tests/test_api_dbml.py`, `tests/test_fuzz_properties.py`, `tests/test_postgres_migration_run_integration.py` | Decode-once quoted identifiers, NUL/malformed/ambiguous/UTF-8/resource bounds, fixed non-reflecting errors, dialect-owned quote escaping, deterministic bounded constraint names, optional property round trips, and hostile-looking names executed on PostgreSQL 14–18. See [DBML identifier-to-DDL boundary](doctoring/dbml-identifier-ddl-boundary.md). | Implemented export boundary; exact-head matrix results remain required and do not grant live apply authority. | +| PostgreSQL catalog query | `app/pg_introspect/queries.py`; `tests/test_pg_introspect_queries.py` | Query text includes current PK deferrability fields and catalog shape assertions. | Static query assertions; no multi-version catalog execution. | +| Network/secret boundary | `app/pg_introspect/dsn_guard.py`, `app/security.py`, `app/dsn_redaction.py`; related guard/security/redaction/fuzz tests | Host allowlist, restricted-range rejection, IP pinning, AES-GCM at rest, redaction robustness. | Application-level tests; deployment egress/TLS/key-separation evidence is absent. | +| Browser headers | `app/main.py`; `tests/test_security_headers.py` | `If-Match` is allowed and response `ETag` is CORS-exposed alongside auth/content/CSRF controls. | Header configuration evidence only; no complete credentialed browser workflow. | + +The default `[tool.coverage.run]` include list in `backend/pyproject.toml` does +not currently include the new `app.forward` or forward API modules. CI runs +`pytest -q` without an explicit `--cov-fail-under` gate. Therefore the +repository's 100% production statement/branch policy is **not currently +enforced for this slice**. Closing that configuration gap is a release blocker; +passing the focused tests alone is insufficient. + +## Required PostgreSQL integration matrix + +Run the same accepted contract against ephemeral PostgreSQL majors 14, 15, 16, +17, and 18. Record exact server version and extension set. + +### Catalog and canonicalization + +- Empty database, empty schema, ordinary tables, nullable/non-null columns, and + single/composite primary keys including deferrability and order. +- Lowercase, mixed-case, reserved-word, whitespace, embedded-quote, and Unicode + identifiers at the 63-byte boundary. +- Actual `pg_catalog` rows for defaults, identity/generated columns, unique and + check constraints, foreign keys, primary/secondary/expression/partial + indexes, partitions, tablespaces, views, materialized views, triggers, + functions, RLS/policies, grants, domains, enums, extensions, and Citus + metadata when present. Every unrepresented class must produce a blocker. +- Snapshot canonicalization repeated across capture timestamps and changing + OIDs must produce the same digest when semantics are unchanged. + +### Executability and convergence + +- Every admitted create/drop/add/type/nullability operation executes from the + exact stored plan and re-introspects to the exact target digest. +- Catalog-equivalent aliases produce no false semantic diff; serial pseudo-types + fail before planning; every actual type alteration remains destructive in the + review and confirmation contract. +- Quoting and type rendering match PostgreSQL on every supported major. +- A blocked model exposes zero executable statements and produces zero + execution calls, even when `proposed_statements` is non-empty; proposal risks + remain visible and included in size bounds. +- A deliberately failing statement rolls back earlier operations in the v1 + segment and leaves the base digest. +- Unsupported or non-transactional work, including + `CREATE INDEX CONCURRENTLY`, never enters the v1 executor. + +### Concurrency, risk, and privileges + +- `lock_timeout`, `statement_timeout`, and transaction timeout paths terminate + within their bounds and produce redacted classified evidence. +- External `INSERT`/`UPDATE` attempts cannot invalidate NULL, table-empty, or + castability preconditions between the in-lock check and commit. +- External DDL drift before dry run, before queue, before lock, and after dry + run causes no plan DDL. +- Pre-apply lock planning consumes structured object references rather than + rendered SQL, sorts and deduplicates existing tables, preserves quoted + mixed-case/Unicode identifiers, skips not-yet-existing schema/table targets, + and rejects missing/unknown compiler versions, unknown kinds, + non-transactional statements, lock-mode tampering, + invalid identifiers, blockers, and oversized statement sets. This is compiler + evidence only. A test-only PostgreSQL 14–18 acceptance acquires the compiled + quoted table lock, observes a concurrent insert time out, runs the bound + table-empty check while holding the lock, rolls back, and then observes the + insert succeed. Production target connection/lock orchestration and complete + in-lock revalidation remain Planned acceptance families. In particular, the + real target lock acquisition by a production worker remains unimplemented. +- Pre-apply revalidation-manifest tests bind the exact persisted plan digest, + PostgreSQL major, base/target digests, deterministic lock targets, and + structured read checks. They reject contract drift, tampering, unsupported + versions, review-only proposals, cross-table preconditions, and any + precondition without an existing table lock. They also require zero segments + for a no-op plan and exactly one ordered all-transactional segment for + non-empty compiler-v1 work. They map compiler-v1 operations to exact + database `CREATE`, schema `CREATE`, or table `OWNER` requirements and reject + weaker, unknown, reordered, or duplicated privilege labels. This remains + target-free compiler evidence; it + does not prove that locks are held, that same-connection revalidation + or privilege observation occurred, or that a target transaction rolled back + after failure. The complete positional observation-assessment tests reject + missing, extra, renamed, differently targeted, and non-boolean privilege or + precondition rows and prove negative results remain explicit facts rather + than execution authority. +- Same-connection capture tests require exact signed-plan re-derivation, one + caller-owned read-only repeatable-read transaction, strict snapshot digest + capture, ordered privilege/precondition observations, explicit negative + facts, fixed secret-safe failure, rollback cleanup, and timeout rejection + before target access. PostgreSQL 14–18 acceptance executes the primitive as + the fixture owner and observes a matching base, satisfied owner privilege, + and failed table-empty precondition. It proves no stored-target/attempt + binding, advisory/object lock, in-lock repetition, or apply authority. +- Parameterized privilege-probe tests require exact ordered database `CREATE`, + schema `CREATE`, and table `OWNER` scopes, keep identifiers in data parameters, + re-derive them from the exact signed plan, and reject a redirected target under + its stale digest. The PostgreSQL 14–18 matrix executes the + table-owner probe as the owner and the independently constrained read-only + role, proving `true`/`false` semantics without a production worker. +- Least-privilege roles demonstrate required privilege success and predictable + denial; live preflight credentials cannot execute DDL. +- Large tables exercise scan/rewrite warnings and timeout behavior without + using production data. + +## API, database, and security matrix + +For every current and planned model/plan/run/evidence route, cross product: + +- missing resource, same-project resource, and other-project UUID; +- viewer, editor, deployer, owner, unauthenticated, public-share, and revoked + session/API key as applicable; +- valid/missing/expired CSRF token and permitted/disallowed CORS header; +- valid, missing, weak, quoted, stale, or tampered ETag/digest/idempotency key; +- current/superseded revision, unexpired/expired plan, and + passed/failed/drifted/wrong-plan dry run; and +- destructive/non-destructive plan with correct, missing, or altered typed + confirmation. + +Assertions must cover response status and structured error code, uniform IDOR +masking, zero target calls, zero secret/SQL leakage, and unchanged metadata for +rejected requests. + +Database integration must prove: + +- concurrent model revisions have one compare-and-swap winner; +- concurrent identical run submissions produce one effective run; +- idempotency-key reuse with different effective input returns `409`; +- run creation and queue/outbox insertion are atomic; +- state-version compare-and-swap admits only legal transitions; +- run events are append-only and uniquely ordered; and +- FK, uniqueness, expiry, retention, and conditional same-tenant invariants are + enforced or fail atomically in the service transaction. + +The current CI runs the migration-run/outbox acceptance against real +PostgreSQL 14–18 services. Each official image is pinned by multi-platform +index digest. The focused test applies every Alembic revision, verifies the +actual server major, creates a run/genesis/outbox transaction through the +production writer, proves identical-key reuse produces one run/event/dispatch, +asserts the dispatch schema has no execution payload, publishes the exact +dispatch attempt, then drives the real CAS/event writer through sandbox and +preflight states. The terminal transition verifies and persists the plan base +digest on the run and fourth chained event before the transaction is rolled +back and no partial identity survives. The same digest-pinned +matrix creates a quoted mixed-case/Unicode target fixture as the database +owner, grants only fixture-scoped USAGE/SELECT to an ephemeral preflight login, +and executes the production live-preflight primitive through that login. It +asserts that database CREATE/TEMP are absent, proves DDL denial, and proves +non-empty/NULL failures, +successful empty-table evidence, failing cast classification without database +detail propagation, read-transaction cleanup, and fixture cleanup on every +supported major. Until exact-head CI passes, these are acceptance requirements, +not completed evidence. + +Focused relay tests prove that the bounded publisher claims and acknowledges +one exact attempt with a single caller clock, publishes only the run UUID on a +dedicated Valkey key, performs no transaction control, closes failed clients, +and leaves failed publication unacknowledged for caller rollback. Lifecycle +tests prove explicit opt-in/startup validation, one fresh transaction per +claim, commit/rollback context behavior, bounded empty/failure polling, fixed +non-secret failure logging, and cancellation of every application-owned task. +CI also runs +the production adapter against a digest-pinned real Valkey 8 service, proving +generic and migration UUIDs occupy separate sorted sets and that popping a +generic signal cannot consume the migration signal. Deployment restart/failover, +consumer restart, and worker execution remain release-blocking evidence. +The same real-service test moves a due UUID from ready to processing under an +exact lease-token, performs monotonic exact lease renewal, rejects expired-owner +and stale-token renewal plus stale acknowledgement, releases for retry, +reclaims with a new token, and acknowledges cleanly. Focused consumer tests +prove handler-before-ack ordering, exact-lease retry release, heartbeat +renewal, terminal cancellation acknowledgement, terminal redelivery without +sandbox/preflight replay, active-attempt abandonment, handler cancellation and task retrieval on lease +loss, bounded timing, and fixed non-secret lifecycle logs. A deterministic +non-cooperative-provider test proves that the in-process stage deadline requests +cancellation but leaves the handler and capability open until a provider that +suppresses cancellation returns; deployed process isolation or an external kill +boundary remains required evidence. The +durable-attempt unit contract proves run-row serialization, one-active-owner +uniqueness, monotonic numbering, expired-owner abandonment, hashed identity +storage, executable/cancellation checks, monotonic exact-owner renewal, and +unexpired exact-owner finish. PostgreSQL 14–18 acceptance applies the attempt +migration and proves stale-token rejection, terminal-run renewal denial, +exact-owner completion, migration `0013` cancellation-state checks, and +restrictive-FK/rollback cleanup. The same matrix composes both +stores at the consumer boundary: failure abandons the exact PostgreSQL attempt +and reschedules the exact Valkey claim; retry creates the next monotonic +attempt, completes it, acknowledges the signal, and leaves no ready, +processing, or lease-token entry. It then leaves an exact one-second signal and +database attempt unacknowledged, waits for real expiry, and proves the next +consumer reclaims both stores, abandons the expired attempt, advances the run +to `passed`, completes its successor attempt, and rejects the stale signal. +This remains in-process ephemeral topology evidence; process/container restart +orchestration, deployed consumer lifecycle, and credential-bound execution +remain unproved. The same PostgreSQL 14–18 transaction then persists an exact +confirmed apply intent referencing the passed dry run and asserts its +confirmation-digest/destructive fields plus the deliberate absence of an apply +dispatch. This is control-plane evidence only; it does not exercise target +apply DDL. The +live-preflight unit contract proves exact quoting for mixed/quoted identifiers, +the three admitted structured preconditions, fail-closed unknown fields/types, +the 1,000-query ceiling, a single read-only repeatable-read transaction, +prepared-statement-only execution, parameter-bound transaction-local server +timeout using the exact unitless decimal millisecond bind value, client timeout +bounds across transaction start, timeout configuration, prepare/fetch, commit, +and rollback cleanup, boolean-only +evidence, rollback, fixed non-secret +database failures (including transaction creation/start, commit, and rollback +cleanup), no rollback before a successful start, cleared exception +cause/context, and strict +snapshot-to-plan-base canonical digest comparison. +`complete_isolated_dry_run` tests reject missing/extra fields, invalid bounds, +non-canonical digests, false convergence, expired or integrity-invalid plans, +and any result that differs from the stored plan. They prove the only success +classification is a fixed `live_preflight_running` CAS with aggregate evidence. +`execute_bound_live_preflight` tests require a caller-owned capture callback to +run after the read-only repeatable-read transaction starts and before it +commits, under the same client timeout as the structured checks. They prove +exact digest match, explicit drift, invalid capture rejection, fixed non-secret +capture failure, rollback, and cancellation propagation. PostgreSQL 14–18 +acceptance invokes that primitive through the restricted preflight login, +proves an ungranted-table SELECT failure, forces a real relation-lock wait past +its bounded statement timeout, clears any transaction-cached statistics +snapshot before observing each lock wait, terminates the backend during another wait, and +verifies sanitized failures plus the appropriate reusable-or-closed connection +state. It also re-captures the catalog on the same connection/transaction as +data checks. +`complete_live_preflight` tests reject missing/extra result fields, malformed or +duplicate check positions, non-canonical digests, and forged aggregate flags; +they also reject missing, extra, or kind-mismatched checks against the exact +persisted plan precondition set, recheck plan/run integrity and expiry, and +prove server-derived passed/drifted/failed classification with bounded +check-count evidence. PostgreSQL integration uses this bridge rather than a +caller-selected terminal transition. +Durable-run unit tests require that observed digest for terminal preflight CAS, +revalidate the immutable plan, persist it on the run and chained event, and +reject missing, malformed, `passed`-mismatched, `drifted`-matched, or unrelated +transition injection. Worker evidence cannot pre-author the reserved observed +digest field through snake-, camel-, kebab-case, or nested aliases. +The privilege proof is CI-local rather than deployed production evidence. +Target audit-log evidence, credential binding around the durable attempt, and +the caller-owned same-transaction primitive remain release blockers. The +execution-neutral consumer contract is **Implemented** and consumer-to-attempt +binding is **Implemented** with success, sanitized failure, heartbeat-loss +cancellation, and unsafe-timing tests. Application startup wiring and worker +execution remain **Planned**. A focused composition contract requires the +durable handler and credential-bearing stored-target provider to share the +same session factory, rejects a divergent consumer factory before I/O, and +keeps sandbox provisioning injected. Deployment consumer lifecycle, +crash/restart orchestration, and worker execution remain release blockers. + +## Fault-injection and recovery matrix + +| Injection point | Expected evidence | Forbidden behavior | +|---|---|---| +| Before sandbox allocation | Retryable dry-run failure or queued lease recovery | Any live target access | +| During sandbox execution | `failed`, bounded diagnostics, sandbox cleanup | Marking live preflight passed | +| During live read-only preflight | `failed` or `drifted` | Live DDL or reuse of incomplete evidence | +| Before target locks | Non-success with no DDL proof | `failed_rolled_back` without a transaction | +| After locks, before first DDL | Rollback/release evidence | Automatic apply replay without new intent | +| Between plan statements | `failed_rolled_back` only when full rollback is proven | Partial-success or verified claim | +| Immediately before/after commit acknowledgement | Reconciliation by re-introspection | Blind queue retry or DDL replay | +| After known commit, before verification | `verification_failed` until read-only verification resumes | Reporting unchanged/rolled back | +| Verification finds target digest | Persisted snapshot and `verified` | Success without snapshot provenance | +| Verification finds third digest after known commit | `applied_with_drift` plus residual diff | Coercing result to verified | +| Reconciliation unavailable or finds third digest after uncertain commit | `outcome_unknown` and alert | Applied/not-applied claim or replay action | +| Kill switch during queued/applying states | Queued work cannot start; applying work reconciles | Process kill described as rollback proof | + +## Browser and accessibility acceptance + +Typed browser transport is **Partially implemented** with unit coverage for +credentialed plan/run reads, CSRF-protected exact dry-run/apply intent creation, +idempotency headers, optimistic cancellation, and the absence of a browser SQL +parameter. The plan review panel is **Partially implemented** with component +tests for immutable provenance, risk, executable versus review-only SQL, +blockers, hostile markup text rendering, and absence of action controls on a +blocked plan. Fixed loading/error/retry states and stale-response suppression +is **Partially implemented** and covered for both late success and late failure. +The Forward Engineering modal shell is **Partially implemented** with closed +no-fetch, labelled dialog, focus entry/trap/restoration, Escape, explicit close, +no apply intent before an exact passed dry run, one active run surface after +dry-run acceptance, and restoration of the supplied run after close/reopen. The dry-run intent +control is **Partially implemented** with component coverage for server blocker gating, exact-digest +and no-SQL submission, synchronous single-flight exclusion, fixed secret-safe +errors, same-key ambiguous retry, reused-run reporting, and stale-response +suppression after plan identity changes. The run status and audit panel is +**Partially implemented** with exact-run loading, fixed error/retry, +stale-response suppression, bounded terminal-state semantics, pending versus +acknowledged cancellation and sanitized error alerts, hostile event text +rendering, digest-chain metadata, +and non-rendering of generic evidence payloads. Sequential terminal-aware +polling is **Partially implemented** and tested to issue one request at a time +and stop after the first terminal response. The cancellation intent control is +**Partially implemented** with component coverage for terminal/existing-intent +suppression, exact optimistic state-version submission, synchronous +single-flight exclusion, accepted-state refresh, fixed secret-safe ambiguous +errors, refresh-only recovery without mutation replay, and retention of the +single-flight guard while polling advances a non-terminal state version. +The apply intent control is **Partially implemented** with component coverage +for exact passed-dry-run/plan/digest/observed-base gating, typed target +confirmation, conditional destructive acknowledgement, no-SQL request shape, +synchronous single-flight submission, fixed secret-safe errors, and immutable +confirmation plus same-key ambiguous retry. Its accepted result remains a +non-dispatched intent and is not apply execution evidence. +Forward UI remains **Planned**. +These unit tests are not browser E2E or complete accessibility evidence. Tests +must cover the complete user-observable state model, not only a happy-path +button click: + +- dirty/save/saved model state and `409` optimistic-concurrency recovery; +- exact connection/snapshot selection, plan supersession, blocker and risk + review, read-only SQL, and digest display; +- editor versus deployer controls with server-side denial even when the client + is tampered; +- dry-run evidence source labels, polling, reconnect/page reload, cancellation, + drift, timeout, and redacted diagnostics; +- typed connection confirmation, separate destructive acknowledgement, and + double-submit prevention; +- every terminal state, especially `failed_rolled_back`, + `verification_failed`, `applied_with_drift`, and `outcome_unknown`; +- keyboard-only entry, logical focus order, trapped modal focus, Escape/cancel + only where safe, restored focus, accessible names, headings, error + association, and progress/status live regions; and +- automated WCAG 2.2 AA-oriented checks plus manual keyboard and representative + screen-reader verification. Automation alone is not a conformance claim. + +## Commands and evidence capture + +The repository CI currently runs: + +```bash +cd backend +PYTHONPATH=. mypy app +PYTHONPATH=. pytest -q + +cd ../frontend +npm ci +npm run typecheck +npm run test +npm run build +``` + +Organization-required pull-request workflows additionally run `osv-scan`, +`dependency-review`, `trivy-fs`, OpenSSF Scorecard, and SAST Semgrep. Those +centrally operated workflows are authoritative dependencies: this leaf +repository must require their exact-head results and must not duplicate their +implementation. A queued, skipped, stale-head, or absent job is not passing +evidence. + +The release workflow must add explicit statement and branch coverage commands +covering every forward production module, migrated PostgreSQL integration, +frontend forward-flow coverage, and composed browser E2E. It must also retain +the centrally operated security results for the exact release head. Do not copy +a historical pass count into documentation; attach machine output for the exact +release head. + +An acceptable evidence record contains: + +- commit SHA and clean/declared worktree state; +- tool and dependency lockfile versions; +- commands, exit codes, test/coverage totals, and skipped/xfail list; +- PostgreSQL image digests and server versions; +- sandbox/network/credential topology used for integration and E2E; +- failure-injection cases and observed terminal states; and +- accessibility automation output plus manual verification notes. + +## Release exit criteria + +Forward engineering remains not production-ready while any item is missing: + +- all FE-INV and FE-AC rows in the + [v1 contract](contracts/forward-engineering-v1.md) are Implemented with linked + evidence; +- no unsupported semantic difference is silently omitted; +- real PostgreSQL 14–18 round-trip, concurrency, privilege, timeout, rollback, + and commit-uncertainty suites pass; +- API/DB/browser/security coverage meets the exact owned-code 100% + statement-and-branch policy; +- the composed accessible UI reaches every honest terminal state; +- the [threat model](security/forward-engineering-threat-model.md) has no + unowned high-risk release blocker; and +- the [operational runbook](runbooks/forward-engineering.md), kill switch, + alerts, backup/recovery, and no-replay drill have current evidence. + +## Related authority + +- [Forward-engineering v1 contract](contracts/forward-engineering-v1.md) +- [Standards baseline](STANDARDS.md) +- [Documentation audit and traceability](DOCUMENTATION_AUDIT.md) +- [Threat model](security/forward-engineering-threat-model.md) +- [Operational runbook](runbooks/forward-engineering.md) diff --git a/docs/TRD.md b/docs/TRD.md new file mode 100644 index 000000000..5a19fa0b9 --- /dev/null +++ b/docs/TRD.md @@ -0,0 +1,370 @@ +# Technical Requirements: Safe Forward Engineering + +## Document control + +- **Status:** Approved target architecture; Phase 1 control plane partially implemented +- **Date:** 2026-08-09 +- **Normative detail:** [Forward Engineering v1 contract](contracts/forward-engineering-v1.md) +- **Decisions:** [ADR index](adr/README.md) +- **Architecture:** [Root architecture](../ARCHITECTURE.md), [UML](UML.md), and + [data model](DATA_MODEL.md) + +This TRD separates repository truth from target design. **Implemented** means +code exists in the current branch. **Partially implemented** means a bounded +fail-closed subset exists. **Planned** means no runtime claim is permitted. + +## Technical outcome + +The graphical workflow must convert untrusted semantic intent into a +server-owned, immutable, target-bound plan and later execute that exact plan +through an isolated validation and durable apply plane. The browser never owns +executable SQL, safety classification, approval truth, or recovery state. + +## Current architecture and implementation boundary + +### Implemented in Phase 1 + +- `app.forward.schema_model`: PostgreSQL 14–18 canonical model validation, + deterministic JSON, and SHA-256 digest. +- `SchemaModel` and `SchemaModelRevision`: project identity plus immutable + numbered revision rows; save uses row locking and a strong revision-UUID + `ETag`/`If-Match` token distinct from the model content digest. +- `app.forward.snapshot_adapter`: strict translation of the proven live + introspection subset; unsupported semantics return sanitized `422`. +- `app.forward.migration_plan`: deterministic structured transactional plans, + risks, privileges, dependencies, preconditions, blockers, review-only + proposals for supported deltas in a blocked plan, and plan digest. +- `MigrationPlan`: immutable plan JSON bound to project, revision, connection, + succeeded base snapshot, actor, compiler version, and 24-hour expiry. +- Role order `viewer < editor < deployer < owner`; persistent legacy + `apply-sql` requires deployer authority and an explicit operator opt-in that + defaults to disabled. + +### Partially implemented foundation + +- `MigrationRun`, identifier-only `MigrationRunDispatch`, and + `MigrationRunEvent` ORM/Alembic persistence with database + state/idempotency/dispatch/sequence constraints; +- a deterministic dry-run/apply transition contract, bounded hashed + idempotency keys, and recursive rejection of SQL/credential-bearing event + fields; +- an optimistic compare-and-swap transition writer that updates one exact + `(state, state_version)` and appends the same-version event atomically in the + caller-owned transaction; +- an internal dry-run creation writer using the database idempotency constraint + as the concurrency winner, rejecting same-key/different-request reuse, + expired/tampered/blocked plans, and every apply request; +- an editor-authorized dry-run creation HTTP boundary binding the exact + reviewed plan digest, bounded `Idempotency-Key`, actor, and request + correlation identity while atomically creating the run, genesis event, and + dispatch outbox without signaling a worker; +- due-order outbox claim and publish-state CAS primitives using + `FOR UPDATE SKIP LOCKED`; the relay owns one transaction across claim, + identifier-only publication, and acknowledgement, and rollback restores a + failed attempt; the bounded publisher places only `migration_run_uuid` on a + dedicated Valkey key and never commits, loads a plan, or executes SQL; +- an opt-in scheduled relay lifecycle uses one fresh transaction per claim, + bounded polling after empty/failure iterations, fixed non-secret failure + logging, startup validation of the Valkey backend, and cooperative shutdown; +- bounded UUID-only ready-to-processing claim, expiry reclaim, exact lease + renewal, acknowledgement, and retry-release primitives use an exact + lease-token so a stale claimant cannot extend or complete a successor lease. + An expired signal owner cannot renew, and renewal never shortens the current + expiry. The execution-neutral consumer + contract is **Implemented**: an injected handler receives the exact signal + claim (run UUID plus opaque lease-token) and must succeed before exact-lease + acknowledgement; sanitized failure releases only that lease at a bounded + retry score. The queue payload remains UUID-only. Automatic heartbeat is + **Implemented**: exact renewal runs while the injected handler is active; + renewal loss cancels and retrieves the handler task and is never + acknowledged as success. DB-durable attempt acquisition, renewal, expired + takeover, and exact-owner finish are **Implemented** with hashed worker and + signal-token identities. Consumer-to-attempt binding is **Implemented**: an + execution-neutral adapter commits acquisition, renews through fresh metadata + transactions, cancels on ownership loss, and finishes the exact owner before + signal acknowledgement. Acquisition rejection now locks and inspects the run: + a persisted dry-run or queued-apply cancellation intent becomes terminal + `cancelled`, while an already-terminal redelivery is acknowledged without + replay; either path marks a surviving active attempt `abandoned` in the same + metadata transaction. The provider-neutral durable handler binds the exact + attempt to injected sandbox and read-only capabilities in deterministic + order; whole-stage deadlines request cancellation, await cooperative + capability cleanup, and expose fixed non-secret errors. The in-process + boundary cannot forcibly terminate a provider that suppresses cancellation; + process isolation and an external kill boundary remain Planned. A concrete + stored-PostgreSQL live-preflight provider is implemented but unwired. It + repeats the exact guarded metadata lookup after connection acquisition and + before any target read; concurrent change after that post-connect + revalidation remains bounded by the attempt lease rather than claimed + impossible. Its guarded connection-acquisition timeout is injected through + the durable-attempt and UUID-only consumer factories, restricted to finite + `(0, 60]`-second values, and validated before metadata or target I/O. The + PostgreSQL 14–18 matrix now composes its guarded metadata lookup, encrypted + target decryption, same-connection capture, and cleanup while substituting an + explicit test-only loopback connector for the correctly rejecting production + DNS/SSRF guard. Application startup, deployed credential/network isolation, + unmodified guarded-route integration, and worker execution remain **Planned**; +- idempotent cancellation intent that increments the shared state version and + appends a same-state event, preventing a stale worker transition from winning; +- `complete_isolated_dry_run` revalidates an exact successful executor result + against the stored plan and derives the fixed `live_preflight_running` CAS + with bounded aggregate evidence rather than caller-selected transition data; +- `complete_live_preflight` validates the exact bounded preflight result, + derives `drifted`, `failed`, or `passed` without caller-selected state, and + delegates only aggregate check counts plus the server-authoritative observed + digest to the existing durable CAS; +- an editor-authorized cancellation HTTP boundary with strict state-version + input, IDOR masking, stable sanitized error codes, and request correlation. +- versioned canonical event digests covering run/sequence/type/state/evidence, + actor, UTC timestamp, and predecessor; the run stores the latest digest and + polling verifies the complete chain before returning evidence; +- a bounded live-preflight query primitive accepts only the three structured + compiler preconditions, validates PostgreSQL identifiers and target types, + prepares every server-owned query before execution, runs boolean-only reads + in one read-only repeatable-read transaction, binds the transaction-local + server timeout as a unitless decimal string whose PostgreSQL default unit is + milliseconds, applies a bounded client timeout, + replaces transaction creation/start, query, commit, and rollback-cleanup + failures with fixed diagnostics, and rolls back only after transaction startup + succeeds while preserving cancellation and process-exit signals. +- exact deployer-confirmed apply-intent creation, deterministic structured + existing-table lock-plan compilation, and signed-plan revalidation-manifest + compilation are **Implemented** as execution-free boundaries. The manifest + binds exact plan/base/target/version metadata to lock-covered structured + checks, structured database `CREATE`/schema `CREATE`/table `OWNER` + requirements, and zero/no-op or one ordered all-transactional segment. Its + fixed parameterized privilege probes re-derive their manifest from the exact + signed plan, while the positional assessor fails closed on scope/evidence + drift and derives only non-authorizing facts. A bounded caller-owned capture + primitive observes a strict snapshot, role privileges, and data preconditions + on one read-only repeatable-read connection. It owns no credential/attempt + binding and acquires no advisory/object lock; +- frontend graph/model adapters and `ForwardEngineeringModal` workflow + orchestration are **Partially implemented** through the accessible modal, + read-only plan review, exact-digest dry-run intent, verified run status/audit, + polling, exact-version cancellation, and execution-free apply-intent controls. + Apply/recovery authority and composed browser E2E remain absent. + +### Planned and release-blocking + +- queue consumption, worker execution, durable retry backoff/max-attempt + policy, and dispatch retention; +- deployed in-flight worker/process cancellation and apply cancellation; +- isolated disposable PostgreSQL provisioning, complete dependency + materialization, deployed isolation proof, cleanup, and worker binding (the + signed-plan execution/convergence core is Partially implemented); +- live-preflight worker wiring, separately constrained deployed credentials, + credential binding around the durable attempt and implemented caller-owned + `execute_bound_live_preflight` same-transaction capture/check primitive, and + apply-time drift revalidation; +- stored-plan apply dispatch/executor, target identity binding, target lock + acquisition, in-lock repetition, transaction execution/rollback proof, + apply-time approval revalidation, cancellation propagation, reconciliation, + and post-apply verification; +- real PostgreSQL integration, fault-injection, accessibility, and browser E2E. + +The legacy `POST /api/connections/{db_connection_uuid}/apply-sql` remains a +transitional compatibility surface. Its `dry_run=true` runs DDL on the live +target and rolls it back; it is not the planned isolated dry run and is not used +by the graphical target architecture. Persistent `dry_run=false` requests fail +closed unless `LEGACY_PERSISTENT_APPLY_ENABLED=true` is explicitly configured. + +## Required invariants + +| ID | Invariant | Current status | +|---|---|---| +| FE-TRD-001 | Browser requests contain model intent or plan/run IDs and expected digests, never graphical-workflow SQL. | **Implemented for plan creation; executor Planned** | +| FE-TRD-002 | Canonicalization preserves exact identifier semantics and rejects unknown or lossy fields. | **Implemented for current subset** | +| FE-TRD-003 | Every admitted base→target difference yields operations or blockers; any blocker suppresses executable statements while supported independent deltas remain in `proposed_statements` for review. | **Implemented for current canonical subset** | +| FE-TRD-004 | A plan binds exact project, model revision, connection, succeeded snapshot, compiler version, digests, actor, and expiry. | **Implemented** | +| FE-TRD-005 | Cross-project/missing/unauthorized identities do not reveal another tenant's resource existence. | **Partially implemented; full matrix gate remains** | +| FE-TRD-006 | Dry-run DDL executes only in a disposable isolated PostgreSQL environment; the metadata DB is never a sandbox. | **Partially implemented:** signed-plan/version/base/transaction/convergence execution core, `complete_isolated_dry_run` server-derived success CAS, PostgreSQL 14–18 round trip, and test-owned durable-handler binding over a separate sandbox connection exist; an expired successor attempt resumes live preflight without replaying committed sandbox DDL. Provisioning, materialization, deployed isolation/egress proof, cleanup, startup, process restart, and worker operation remain Planned | +| FE-TRD-007 | Live preflight is read-only evidence; apply repeats fingerprint/data preconditions after locks on the execution connection. | **Partially implemented:** bounded structured boolean reads, strict snapshot comparison, and durable hashed attempt ownership exist; `execute_bound_live_preflight` binds capture/checks to one read-only repeatable-read transaction and completion matches every persisted precondition. The identifier-only live-reader request carries its exact refreshed run state version. `guard_live_preflight_handoff` atomically matches the exact run/plan/project/target/active-attempt tuple, cancellation, version, lease, digest and expiry; `load_guarded_live_preflight_target` binds encrypted target material to exact succeeded base-snapshot scope. The concrete stored-PostgreSQL provider then performs in-memory decryption, bounded guarded DNS/SSRF/TLS-pinned connection acquisition, same-connection scoped capture, fixed error handling, and cleanup. `make_stored_postgres_durable_dry_run_attempt_handler` binds provider and durable orchestration to the same session factory and rejects a divergent consumer factory before I/O while leaving sandbox lifecycle injected. PostgreSQL 14–18 acceptance enters through that composition and exercises the provider's metadata/decrypt/capture lifecycle, but retains an explicit predecessor-crash wrapper and test-only loopback connector because the production guard rejects the private CI target. The remaining observation-to-target-open gap, unmodified guarded-route integration, deployed credential/network constraints, startup/worker operation, and in-lock apply repetition remain Planned. | +| FE-TRD-008 | V1 apply contains one transaction-capable segment; non-transactional operations block the whole plan. | **Partially implemented:** the plan subset, isolated-dry-run transaction core, and deterministic structured lock-target compiler enforce transactional known kinds and exact risk metadata. Target lock acquisition and the live apply executor remain Planned | +| FE-TRD-009 | Queue payload contains only `migration_run_uuid`; secrets, DSNs, SQL batches, and row values are excluded. | **Partially implemented:** identifier-only `migration_run_dispatch`, due-order `SKIP LOCKED` publication, exact lease-token claim/renew/ack/release primitives, exact signal claim, exact lease renewal, the execution-neutral consumer contract with automatic heartbeat, DB-durable hashed worker-attempt CAS, exact consumer-to-attempt binding, and metadata-only cancellation/terminal-redelivery settlement are **Implemented**. Application startup wiring, credential binding, and worker execution remain **Planned** | +| FE-TRD-010 | Idempotency and compare-and-swap select one run; apply is never automatically replayed after an ambiguous boundary. | **Partially implemented:** dry-run and non-dispatched apply-intent creation HTTP, transition, cancellation CAS/HTTP, terminal cancellation acknowledgement, terminal redelivery without sandbox/preflight replay, and real-PostgreSQL pre-live-read attempt takeover without sandbox replay exist; deployed queue consumption, process/container recovery, commit-uncertainty reconciliation, and apply execution remain Planned | +| FE-TRD-011 | Known commit is followed by re-introspection; only exact target digest becomes `verified`. | **Planned** | +| FE-TRD-012 | Unknown versions/kinds, expired plans, incomplete evidence, and timeout are non-success states. | **Partially implemented:** internal run creation enforces expiry, 30-day cleanup excludes plans with run history, and the preflight primitive bounds query count/time and rejects unknown kinds/non-boolean evidence; worker lifecycle enforcement remains Planned | + +## Current persistence model + +| Entity | Purpose | Mutability | +|---|---|---| +| `ProjectSpace` / `ProjectMember` | Tenant and role boundary | Existing product contract | +| `DbConnection` | Encrypted target connection record | Existing product contract | +| `SchemaSnapshot` / `SchemaSnapshotData` | Succeeded base/introspection evidence | Immutable capture payload | +| `SchemaModel` | Project-scoped desired-model identity/current revision pointer | Pointer and timestamps update | +| `SchemaModelRevision` | Canonical desired JSON, digest, base snapshot, actor | Append-only through API | +| `MigrationPlan` | Target-bound compiler output and expiry | No update route; immutable through API | +| `MigrationRun` / `MigrationRunDispatch` / `MigrationRunAttempt` / `MigrationRunEvent` | Durable run, identifier-only outbox, lease-bound hashed attempt ownership, and append-only evidence | **Partially implemented:** tables, hash-chain integrity, atomic creation/CAS writers, observed-base binding, dispatch/UUID-only signal/consumer contracts, exact-owner attempt acquire/renew/finish, consumer-to-attempt binding, dry-run creation/cancellation acknowledgement, terminal no-replay settlement, current-revision-locked non-dispatched apply-intent confirmation, and polling exist; application startup wiring, credentials, and workers are absent | + +Database schema truth is defined in `backend/app/models.py` and Alembic revisions +`0008_schema_model_revision`, `0009_migration_plan`, `0010_migration_run`, +`0011_migration_run_attempt`, `0012_apply_intent_confirmation`, and +`0013_migration_run_cancellation`. See +[DATA_MODEL.md](DATA_MODEL.md) for actual and planned ERDs. + +## Current HTTP contract + +Typed browser transport is **Partially implemented** in `frontend/src/api.ts` +for immutable plan retrieval, exact dry-run and non-dispatched apply-intent +creation, durable run polling, and exact-version cancellation. It exposes no +arbitrary SQL request field. The plan review panel is **Partially implemented** +in `frontend/src/components/forward/PlanReviewPanel.tsx`; it renders immutable +provenance, risk, blockers, structured statements, and review-only proposals +without buttons or execution authority. Its `PlanReviewSurface` wrapper exposes +fixed loading/error/retry states and stale-response suppression is **Partially +implemented** when a requested plan changes. The Forward Engineering modal +shell is **Partially implemented** with focus entry/trap/restoration, Escape, +and explicit close behavior. The dry-run intent control is **Partially +implemented**: it exposes an action only for a server-runnable unblocked plan, +submits only its UUID and exact digest, admits one request at a time, preserves +the bounded idempotency key across ambiguous retry, ignores stale responses, +and hands the durable run UUID to the read-only polling surface. It has no SQL, +credential, target-selection, worker, or apply authority. The run +identity is modal-session scoped: an accepted dry run replaces the supplied +audit surface while open, and a close/reopen cycle restores the caller-supplied +run so stale session state cannot restart an obsolete audit surface. The run +status and audit panel is **Partially implemented** as an optional exact-run +loader with fixed loading/error/retry behavior and stale-response suppression. +It announces state, pending cancellation intent, terminal `cancelled` +acknowledgement, and sanitized error codes, and shows +only integrity-checked event-chain metadata; generic run/event evidence is not +rendered. Sequential terminal-aware polling is **Partially implemented** with +one outstanding request, cleanup on identity/unmount, and no polling after a +terminal response. The cancellation intent control is **Partially +implemented** for non-terminal runs without an existing intent. It submits one +exact optimistic state version, refreshes the verified run after `202`, and on +an ambiguous result exposes only a fixed error plus explicit status refresh; +it never automatically repeats the mutation. Forward UI remains **Planned**, so these unit-tested +components are not browser E2E or complete accessibility evidence. The apply +intent control is **Partially implemented** for an exact `passed` dry run whose +plan/digest/observed base match the reviewed plan. It requires a typed target +connection name and conditional destructive acknowledgement, freezes the first +submitted confirmation across same-key ambiguous retries, and replaces the +modal audit identity with the accepted non-dispatched apply intent. It does not +dispatch a worker, resolve credentials, or execute DDL. + +| Method and route | Authority | Behavior | Status | +|---|---|---|---| +| `POST /api/schema-models/by-project/{project_space_uuid}` | editor+ | Create identity and revision 1 | **Implemented** | +| `GET /api/schema-models/{schema_model_uuid}` | member | Current immutable revision, IDOR-masked | **Implemented** | +| `PUT /api/schema-models/{schema_model_uuid}` + strong revision-UUID `If-Match` | editor+ | Idempotent no-op for identical content/base or append successor; stale/weak token `409`; responses expose `ETag` through CORS | **Implemented** | +| `POST /api/schema-model-revisions/{revision_uuid}/migration-plans` | editor+ | Validate exact tenant/connection/snapshot binding, compile, bound, persist | **Implemented** | +| `GET /api/migration-plans/{plan_uuid}` | member | Immutable preview with project/revision/connection/snapshot/capability/actor/time bindings, IDOR-masked | **Implemented** | +| `POST /api/migration-plans/{plan_uuid}/dry-runs` | editor+ | Exact-digest, `Idempotency-Key`-bound durable queued intent; `202`; does not signal a worker | **Implemented** | +| `POST /api/migration-plans/{plan_uuid}/apply-runs` | deployer+ | Exact passed evidence + typed/destructive confirmation; persists a queued intent with no dispatch | **Implemented intent boundary; executor Planned** | +| `GET /api/migration-runs/{run_uuid}` | member | IDOR-masked bounded state/evidence view; verifies count, canonical genesis, exact transition graph, one-to-one cancellation flag/event consistency, chronology, event digests, run anchor, and secret-safe evidence before returning | **Implemented** | +| `POST /api/migration-runs/{run_uuid}/cancel` | editor+ | Exact-version CAS cancellation intent; `202`; nonmembers masked, viewers rejected, correlated stable error envelope | **Implemented** | + +Implemented limits: model input is at most 2 MiB; a persisted plan is at most +1,000 executable plus proposed statements and 4 MiB; plans expire 24 hours +after creation. Read-only endpoints retain sanitized FastAPI +`{"detail": ...}` errors. The mutating dry-run creation and cancellation +endpoints fix the v1 run-action envelope as +`{"detail":{"code","detail","correlation_id"}}`; the apply-intent route +reuses it without exposing credentials. + +## Compiler v1 capability matrix + +| Construct/change | Current behavior | Runtime execution status | +|---|---|---| +| Create schema/table; table drop | Structured statement; schema removal blocks | Control plane **Implemented** | +| Add/drop column | Structured statement; required add has `table_is_empty` precondition | Control plane **Implemented** | +| Type/nullability change | Type aliases normalize to catalog spelling; serial pseudo-types reject; type changes carry conservative destructive/data-loss/scan/rewrite evidence | Control plane **Implemented** | +| Primary key on new table | Preserves name, order, and deferrability | Control plane **Implemented** | +| Nullable primary-key column | Rejected before planning to preserve catalog convergence | **Implemented boundary** | +| Existing primary-key change | Explicit blocker | **Implemented blocker** | +| Table/column comments | Explicit blocker; no silent omission | **Implemented blocker** | +| Existing/non-append column order | Explicit blocker | **Implemented blocker** | +| Defaults, identity, generated columns | Fail closed at model/snapshot boundary | **Rejected for v1 subset** | +| Unique/check/foreign-key constraints | Fail closed at current subset boundary | **Rejected for v1 subset** | +| Secondary/expression/partial indexes | Fail closed; only verified PK backing index is filtered | **Rejected for v1 subset** | +| Views, triggers, partitions, tablespaces, RLS, domains, extensions, distributed tables | Fail closed before planning | **Rejected for v1 subset** | +| Concurrent/non-transactional DDL and DML | Not admitted | **Rejected for v1** | + +Failing the snapshot/model boundary currently returns `422` rather than a +persisted plan blocker because no trustworthy target model can be constructed. +The UI must present that as unsupported input, not as success. + +Only snapshots carrying the current capability-contract version may plan. +Catalog reads share one read-only repeatable-read transaction; dropped column +slots are captured and rejected until ordinal semantics can be preserved. + +## Planned validation and execution plane + +```mermaid +flowchart TD + P["Immutable plan"] --> S["Isolated PostgreSQL"] + S --> L["Live read-only preflight"] + L --> A{"Digest and approval valid?"} + A -->|no| X["No DDL"] + A -->|yes| E["Locked transactional apply"] + E --> V["Re-introspect and verify"] +``` + +- Sandbox and live-preflight evidence bind the same plan/base digest but are + separate evidence classes. +- Apply obtains a deterministic target advisory lock and sorted object locks, + sets bounded lock/statement/transaction timeouts, then repeats drift and data + preconditions on the same connection. +- V1 executes one transaction-capable segment. A statement or postcondition + failure rolls the entire segment back. +- Loss of commit acknowledgement enters reconciliation. The worker never + retries DDL automatically; fresh introspection decides `verified`, + `not_applied`, or `outcome_unknown`. + +Exact state tokens, confirmation inputs, and terminal claims are normative in +the [v1 contract](contracts/forward-engineering-v1.md) and +[ADR-0004](adr/ADR-0004-durable-runs-and-recovery.md). + +## Security and operational requirements + +- Reuse guarded DNS resolution, pinned IP, TLS hostname verification, encrypted + DSN handling, CSRF, rate limiting, and project authorization. +- Sandbox has no target credentials or route to production; live-preflight has + no DDL authority; metadata PostgreSQL is never the sandbox. +- Log/event fields are bounded identifiers, digest prefixes, counts, states, + durations, and sanitized error classes. High-cardinality IDs do not become + metric labels. +- Apply has an operator kill switch for new run creation. Cancellation before + apply is compare-and-swap; after execution starts it becomes reconciliation, + not a success or blind retry. +- `outcome_unknown` requires operator evidence collection and blocks automatic + successors on the same target until reconciled. + +See the [threat model](security/forward-engineering-threat-model.md) and +[runbook](runbooks/forward-engineering.md). + +## Requirement-to-evidence traceability + +| Requirement | Current implementation | Current tests | Remaining gate | +|---|---|---|---| +| FE-TRD-001–004 | `app/forward/*`, schema-model/plan APIs, models, migrations | `test_forward_*`, `test_api_schema_models.py`, `test_api_migration_plans.py` | Real PostgreSQL round trip | +| FE-TRD-005 | Uniform masking in schema-model/plan routes; project roles | API/permission tests | Full HTTP IDOR matrix | +| FE-TRD-006–008 | Isolated dry-run execution/convergence core and bound read-only live preflight | `test_forward_isolated_dry_run.py`, `test_forward_live_preflight.py`, `test_forward_apply_lock_plan.py`, `test_forward_pre_apply_revalidation.py`, and PostgreSQL 14–18 acceptance | Sandbox provisioning/materialization/isolation/cleanup, credential-bound worker execution, apply-time revalidation, and live apply | +| FE-TRD-009–012 | Durable run/event/outbox/attempt persistence, UUID-only dispatch, exact signal/attempt leases, and dry-run/apply-intent/cancellation APIs | `test_migration_run_consumer.py`, `test_postgres_migration_run_integration.py`, and run/API contract suites | Application startup/credentials/deployed worker, crash recovery/no-replay reconciliation, and live apply/convergence | +| FE-NFR-006 | Existing modal accessibility utilities only | Existing dialog tests | Forward workflow accessibility/E2E | + +The detailed per-file matrix and open gaps are maintained in +[DOCUMENTATION_AUDIT.md](DOCUMENTATION_AUDIT.md) and +[TEST_STRATEGY.md](TEST_STRATEGY.md). + +## Exact-head release gates + +Before the end-to-end feature is production-complete, the exact release commit +must pass: + +1. backend formatting/lint, mypy, full tests, owned production coverage, and + Alembic upgrade/downgrade/integration checks; +2. frontend typecheck, unit/component tests, owned production coverage, and + production build; +3. ephemeral PostgreSQL execution, drift, lock, timeout, rollback, crash, + reconciliation, and convergence tests; +4. keyboard/accessibility and composed browser E2E for success and every + material failure/uncertainty path; +5. repository security, dependency, and secret scans; +6. documentation contract and link/diagram validation. + +Passing Phase 1 unit tests does not satisfy gates 2–6 and must not be described +as a production-ready live migration workflow. diff --git a/docs/UML.md b/docs/UML.md new file mode 100644 index 000000000..44a0279dd --- /dev/null +++ b/docs/UML.md @@ -0,0 +1,267 @@ +# Forward Engineering UML + +- **Document status:** Current implementation and accepted target design +- **Runtime status:** Partially implemented; not production-ready +- **Last reconciled with the working tree:** 2026-08-12 + +The repository Mermaid diagrams in this document are authoritative. The +[FigJam companion board](https://www.figma.com/board/MLWimuWoOWhatQ239QihfP) +is useful for review workshops, but it does not override code, migrations, +ADRs, or these diagrams. + +## Status legend + +| Label | Meaning | +|---|---| +| **Implemented** | The component or transition exists in source and has repository tests. | +| **Partially implemented** | A bounded subset exists, while documented release gates remain. | +| **Planned** | Accepted design only; no runtime claim is permitted. | +| **Rejected** | Deliberately excluded from the target workflow. | + +## Current component view + +**Status: Partially implemented.** The model/revision/plan control plane and +snapshot worker exist. The target is contacted by guarded introspection and by +the transitional `apply-sql` compatibility endpoint. A bounded isolated +validator core now verifies and executes a signed plan on a caller-owned +sandbox connection and requires strict target-digest convergence. There is no +sandbox lifecycle, structured live-apply executor, or migration-run worker yet. + +```mermaid +flowchart TB + Browser["Browser ERD editor"] --> API["FastAPI control plane"] + API --> Authority["Canonicalizer and plan compiler"] + API --> Metadata[("Metadata PostgreSQL")] + API --> Guard["Guarded PostgreSQL connection"] + Authority -. signed plan .-> SandboxCore["Partial: isolated execution core"] + SnapshotWorker["Snapshot job worker"] --> Metadata + SnapshotWorker --> Guard + Guard --> Target[("Target PostgreSQL")] +``` + +Current authority boundaries: + +- The browser submits model JSON. It is not trusted as executable SQL + authority for the model-to-plan path. +- `app.forward.schema_model` validates, canonicalizes, and digests the model. +- `app.forward.snapshot_adapter` rejects snapshots it cannot represent + losslessly in compiler v1. +- `app.forward.migration_plan` renders quoted SQL and structured risk data on + the server. `migration_plan.plan_json` is immutable through the current API. +- `app.forward.isolated_dry_run` accepts only that digest-bound plan plus a + caller-owned sandbox connection, validates version/base/transaction + contracts, executes one bounded transaction, and requires a strict fresh + target snapshot to converge. It does not provision or clean the sandbox. +- `app.pg_introspect.dsn_guard` applies a configured host allowlist, rejects + restricted addresses, resolves DNS, and pins the validated IP used to + connect. Verified-hostname TLS is applied when the DSN requests + `sslmode=verify-full`. +- `POST /api/connections/{uuid}/apply-sql` still accepts a conservative + allow-listed SQL subset. It is **Implemented legacy compatibility**, not the + accepted production workflow. + +Components deliberately absent from this current diagram are **Planned**: +isolated sandbox provisioning/materialization/cleanup, deployed live-preflight +worker execution and application startup wiring, live plan execution, reconciliation, +and post-apply convergence verification. A repository-level stored-target +provider now loads the plan-bound succeeded snapshot and exact connection, +decrypts only that guarded credential, opens the target through the DNS/SSRF/TLS +guard, repeats the exact guarded lookup after connection acquisition and before +any target read, and scopes capture to the same acquired connection. It is not deployed +worker authority and has no apply path. `execute_bound_live_preflight` binds a +caller-owned fresh-capture callback and checks to one read-only repeatable-read +transaction; `complete_live_preflight` strictly derives its terminal durable +CAS classification. `complete_isolated_dry_run` similarly revalidates exact +sandbox success against the stored plan and derives only the fixed next CAS. +The metadata layer now provides hashed, lease-bound durable attempt ownership. +Consumer-to-attempt binding is **Implemented** by an execution-neutral adapter, +and PostgreSQL 14–18 acceptance composes the stored-target provider behind an +explicit test-only loopback connector. Application startup wiring, unmodified +guarded-route composition, deployed least-privilege credentials/network +identity, and worker execution remain **Planned**. +Durable `migration_run`/event/outbox persistence and this execution-neutral, +bounded read-only primitive are **Partially implemented**; neither constitutes +worker execution or dry-run success evidence. + +## Current model-revision-plan sequence + +**Status: Implemented control-plane slice.** Planning reads a persisted +succeeded snapshot; it does not contact the target during this request. + +```mermaid +sequenceDiagram + actor Client as Browser client + participant API as FastAPI + participant Auth as Model/plan authority + participant DB as Metadata PostgreSQL + + Client->>API: POST model or PUT model with strong revision ETag in If-Match + API->>API: Require editor and validate base snapshot + API->>Auth: Canonicalize model and compute digest + API->>DB: Insert model revision and advance current revision + DB-->>Client: Revision UUID ETag, number, content digest, canonical model + + Client->>API: POST revision migration-plans + API->>DB: Load revision, connection, succeeded snapshot and data + API->>API: Require same project and exact snapshot connection + API->>Auth: Adapt snapshot, compile and digest structured plan + API->>DB: Insert expiring immutable migration_plan + DB-->>Client: Plan UUID, executable/proposed statements, risks and blockers +``` + +The create route is currently +`POST /api/schema-models/by-project/{project_space_uuid}`. The older design +spelling `POST /api/projects/{project_uuid}/schema-models` is **Planned**, not +an implemented alias. A blocked plan is persisted and returned with +`can_dry_run=false`; the compiler sets executable `statements=[]` when a +blocker exists. Independently supported deltas remain visible only in +`proposed_statements` for review, and `risk_summary` includes those proposals. +Proposals are never executor input while the plan is blocked. +Within each statement, structured `object_ref` and `dependency_refs` are +authoritative; joined target/dependency labels are display-only. + +## Target dry-run sequence + +**Status: Partially implemented.** Authorized idempotent intent creation, +durable run/event/dispatch storage, exact signal and attempt leases, and +provider-neutral durable worker orchestration are Implemented. The exact-plan +sandbox executor and same-transaction read-only live-preflight cores are +Partially implemented. The stored-target live-preflight provider factory is +Implemented at the repository boundary; it binds the exact plan snapshot, +guarded credential, connection, and schema scope through the DNS/SSRF/TLS guard +without wiring a worker. PostgreSQL 14–18 acceptance composes its stored +metadata, decryption, same-connection capture, and cleanup using an explicit +test-only loopback connector because the production guard correctly rejects the +private CI target. +Focused unit evidence also proves post-connect revalidation closes the acquired +connection without capture authority when exact guarded metadata changes. +The repository composition binds the durable handler and stored-target +provider to the same metadata session factory and rejects a divergent consumer +factory before metadata or target I/O; sandbox lifecycle remains injected. +PostgreSQL 14–18 recovery acceptance enters through this production +composition while retaining explicit test-only crash and loopback seams. +Concrete sandbox provisioning and cleanup, unmodified guarded-route +composition, application startup wiring, deployed least-privilege target +identity, and deployed worker isolation remain **Planned**. A successful dry run +requires two separately identified +evidence classes: the sandbox executes the exact stored plan, while the live +target receives read-only introspection and bounded precondition queries only. + +```mermaid +sequenceDiagram + actor Client as Editor client + participant API as FastAPI + participant Worker as Dry-run worker + participant Sandbox as Isolated PostgreSQL + participant Target as Live target + + Client->>API: POST dry-run with idempotency key and plan digest + API->>API: Validate editor, expiry, digest and current revision + API-->>Client: 202 migration_run UUID + Worker->>Sandbox: Materialize dependency closure and execute stored plan + Worker->>Sandbox: Re-introspect and require target digest + Worker->>Target: Read-only fingerprint and bounded preconditions + Target-->>Worker: Redacted, bounded evidence + Worker->>Worker: Persist passed, drifted, or failed with events +``` + +**Rejected:** running DDL on the production target and rolling it back as +proof of dry-run safety. The rollback-only mode of legacy `apply-sql` is +transitional and does not satisfy this sequence. + +## Target apply and verification sequence + +**Status: Partially implemented.** The API-side confirmed apply intent is +Implemented and deliberately non-dispatched. The worker/executor sequence is +Planned and must consume only the stored structured plan; it must not accept a +replacement SQL string from the browser or queue payload. + +```mermaid +sequenceDiagram + actor Client as Deployer client + participant API as FastAPI + participant Worker as Apply worker + participant Target as Live target + participant DB as Metadata PostgreSQL + + Client->>API: POST apply-run with exact plan and dry-run evidence + API->>API: Verify role, confirmations, expiry and idempotency + API->>DB: Persist queued intent and confirmation digest; no dispatch + API-->>Client: 202 migration_run UUID + Note over API,DB: Implemented boundary ends here + Worker->>Target: Lock, recheck fingerprint and data preconditions + Worker->>Target: Execute one transactional segment and commit + Worker->>Target: Re-introspect target after commit + Worker->>DB: Persist verification snapshot, result and events + DB-->>Client: Polled terminal evidence +``` + +If commit acknowledgement is lost, the worker re-introspects instead of +replaying: target digest means `verified`, unchanged base digest means +`not_applied`, and unavailable or third-state evidence means +`outcome_unknown`. + +## Target run state machines + +### Dry run + +**Status: Partially implemented.** Durable persistence, authorized creation, +integrity-checked polling, cancellation intent, terminal `cancelled` +acknowledgement, terminal redelivery without sandbox/preflight replay, and +bounded transition cores exist. Deployed sandbox/preflight worker execution and +in-flight process cancellation remain **Planned**. + +```mermaid +stateDiagram-v2 + [*] --> queued + queued --> sandbox_running + queued --> cancelled: cancellation wins + sandbox_running --> live_preflight_running: sandbox converged + sandbox_running --> cancelled: cancellation wins + sandbox_running --> failed: sandbox failed + live_preflight_running --> passed: live evidence passed + live_preflight_running --> drifted: base mismatch + live_preflight_running --> cancelled: cancellation wins + live_preflight_running --> failed: incomplete or failed evidence + passed --> [*] + drifted --> [*] + failed --> [*] + cancelled --> [*] +``` + +### Apply and recovery + +**Status: Partially implemented.** Confirmed queued intent persistence exists; +all executor transitions remain Planned. Cancellation may stop a queued run. After `applying` +begins, a cancellation request cannot produce a claim that execution stopped; +reconciliation and verification continue. + +```mermaid +stateDiagram-v2 + [*] --> queued + queued --> cancelled: cancellation wins + queued --> applying: gates and revalidation pass + queued --> drifted_no_apply: fingerprint mismatch + applying --> failed_rolled_back: transaction rollback proven + applying --> reconciling: commit acknowledgement uncertain + applying --> verifying: commit acknowledged + reconciling --> verified: target digest observed + reconciling --> not_applied: base digest observed + reconciling --> outcome_unknown: evidence unavailable or third state + verifying --> verified: target digest observed + verifying --> applied_with_drift: residual diff observed + verifying --> verification_failed: verification unavailable +``` + +Only `verified` asserts that a persisted verification snapshot equals the +approved `target_digest`. `applied_with_drift`, `verification_failed`, and +`outcome_unknown` must never be rendered as success or “unchanged.” + +## Related authority + +- [Architecture](../ARCHITECTURE.md) +- [Forward-engineering v1 contract](contracts/forward-engineering-v1.md) +- [Data model and ERD](DATA_MODEL.md) +- [ADR index](adr/README.md) +- [Threat model](security/forward-engineering-threat-model.md) +- [Operational runbook](runbooks/forward-engineering.md) diff --git a/docs/adr/ADR-0001-server-authoritative-planning.md b/docs/adr/ADR-0001-server-authoritative-planning.md new file mode 100644 index 000000000..1d1fea1c1 --- /dev/null +++ b/docs/adr/ADR-0001-server-authoritative-planning.md @@ -0,0 +1,135 @@ +# ADR-0001: Server-authoritative planning + +- **Decision status:** Accepted +- **Implementation status:** Partially implemented +- **Date:** 2026-08-09 +- **Owners:** pg-erd-cloud maintainers +- **Supersedes:** none +- **Related:** [ADR-0002](ADR-0002-isolated-dry-run-and-preflight.md), + [ADR-0003](ADR-0003-plan-execution-segmentation.md), + [forward-engineering v1 contract](../contracts/forward-engineering-v1.md) + +## Context + +The existing product has three contracts that cannot safely be connected as a +production workflow: + +1. the React canvas is an editing view, not an execution authority; +2. export and snapshot-diff code can render SQL that the legacy + `POST /api/connections/{db_connection_uuid}/apply-sql` validator rejects; +3. the legacy endpoint accepts browser-supplied SQL and has no immutable link + between what a person reviewed and what a worker executes. + +A plan must also explain unsupported semantics. Omitting a desired change while +claiming that the target digest is reachable would be a false safety signal. + +## Decision + +The server owns the complete desired-model-to-plan boundary. + +- The browser submits semantic schema intent as model JSON, never executable + SQL, for the graphical workflow. +- `app.forward.schema_model.canonicalize_schema_model` validates and + canonicalizes that untrusted intent. A SHA-256 revision digest identifies the + exact canonical content. +- `schema_model_revision` rows are append-only through the API. Saving a + successor uses `If-Match` against the strong revision-UUID `ETag`; the model + content digest alone is not a concurrency token because the base snapshot + may change independently. +- `app.forward.migration_plan.compile_migration_plan` compiles one exact model + revision against one exact succeeded snapshot and connection. The resulting + `migration_plan` is immutable through the API and binds compiler version, + base digest, target digest, structured statements, risks, blockers, actor, + target, and expiry. +- One structured statement/operation representation is authoritative for SQL + rendering, dependencies, privileges, transaction capability, preconditions, + risk, execution, and audit. An executor must consume the stored plan and + expected digest; it must not re-parse browser SQL to recover intent or risk. +- `object_ref` and `dependency_refs` are the identifier authority. Dot/colon + joined `target` and `dependencies` strings are display-only because quoted + PostgreSQL identifiers may contain those delimiters. +- Every semantic difference must produce either an executable operation or a + blocker. A plan containing a blocker has no executable statements and cannot + enter dry run. +- The graphical workflow will execute by plan UUID plus digest. It will not use + or broaden `apply-sql`. The legacy endpoint remains a transitional, + separately authorized compatibility surface. + +Repository Markdown and Mermaid diagrams are authoritative. The +[FigJam board](https://www.figma.com/board/MLWimuWoOWhatQ239QihfP) is a +non-authoritative visual companion. + +## Consequences + +### Positive + +- Review, approval, dry-run evidence, execution, and verification can be bound + to one immutable hash. +- PostgreSQL identifier quoting and compiler support are enforced once on the + server. +- A narrow compiler can fail closed without granting the browser SQL authority. +- Auditors can trace a live action back to an actor, model revision, base + snapshot, compiler version, and target. + +### Costs and risks + +- The backend needs a versioned canonical model contract and migrations for + every compatible format change. +- The frontend needs explicit graph/model adapters; React Flow node IDs and + labels cannot act as database identities. +- Canonical fields that affect the target digest but do not yet compile are + rejected or surfaced as blockers. Compiler v1 now blocks schema removal, + table/column comment changes, and non-append column ordering rather than + advertising false convergence. +- A blocked plan exposes no executable statements. Independent supported + deltas remain digest-bound in `proposed_statements` for review, so one + unsupported change does not hide other risk-bearing work. +- Type aliases normalize to PostgreSQL catalog spelling; serial pseudo-types + are rejected, and admitted type changes are conservatively classified as + destructive until a proven widening matrix exists. +- The snapshot adapter admits only the proven subset. It filters verified + primary-key backing indexes, preserves primary-key deferrability, and returns + a sanitized `422` for unsupported defaults, non-primary indexes, + unique/check/foreign-key constraints, partition metadata, or tablespaces. + +## Alternatives rejected + +- **Connect canvas/export SQL directly to `apply-sql`.** Rejected because the + generator and validator support different grammars and the browser would + control executable text. +- **Broaden a SQL allow/deny-list parser.** Rejected because parsing text after + the fact cannot reliably reconstruct schema intent, dependencies, provenance, + or risk across PostgreSQL grammar versions. +- **Let the browser classify risk while the server only executes.** Rejected + because review and enforcement could disagree. +- **Silently skip unsupported objects.** Rejected because a successful result + would not mean the desired model converged. + +## Repository evidence + +### Implemented + +- `app.forward.schema_model` canonicalization and revision digests. +- `SchemaModel`, `SchemaModelRevision`, and `MigrationPlan` persistence models + plus Alembic revisions `0008_schema_model_revision` and + `0009_migration_plan`. +- Current model routes under `/api/schema-models` and current plan creation at + `POST /api/schema-model-revisions/{schema_model_revision_uuid}/migration-plans`. +- `app.forward.migration_plan` structured compiler with deterministic plan + digest, risk summary, and blocker suppression. + +### Planned before production release + +- plan retrieval, dry-run, apply-run, and run polling APIs; +- a structured executor that consumes only persisted plans; +- extension of the deliberately narrow fail-closed subset without semantic + loss; +- frontend model adapters and review workflow; +- integration evidence that the stored statement plan executes and converges. + +## Acceptance evidence + +This decision is implemented only when contract tests prove that every admitted +model difference becomes an operation or blocker, no graphical-workflow request +contains executable SQL, and the exact persisted plan digest is the value used +by dry run, approval, execution, and verification. diff --git a/docs/adr/ADR-0002-isolated-dry-run-and-preflight.md b/docs/adr/ADR-0002-isolated-dry-run-and-preflight.md new file mode 100644 index 000000000..8f26fcb91 --- /dev/null +++ b/docs/adr/ADR-0002-isolated-dry-run-and-preflight.md @@ -0,0 +1,183 @@ +# ADR-0002: Isolated dry run and live read-only preflight + +- **Decision status:** Accepted +- **Implementation status:** Partially implemented +- **Date:** 2026-08-09 +- **Owners:** pg-erd-cloud maintainers and operators +- **Supersedes:** none +- **Related:** [ADR-0001](ADR-0001-server-authoritative-planning.md), + [ADR-0003](ADR-0003-plan-execution-segmentation.md), + [forward-engineering v1 contract](../contracts/forward-engineering-v1.md), + [durable dry-run worker v1](../contracts/durable-dry-run-worker-v1.md) + +## Context + +Running DDL inside `BEGIN` and then rolling it back does not make production a +safe dry-run environment. PostgreSQL can still acquire strong locks, scan or +rewrite tables, block application traffic, and consume material resources. +Rollback also cannot provide a universal contract for non-transactional +operations. Conversely, sandbox execution alone cannot prove that the live +target still matches the reviewed base or that its current data satisfies +operation-specific preconditions. + +The current legacy `apply-sql` endpoint has a rollback-only mode. That mode is +useful for compatibility validation but is explicitly not the dry-run contract +for the model-to-plan workflow. + +## Decision + +One successful dry run consists of two independent evidence classes bound to +the same immutable plan digest and base digest. + +1. **Disposable execution.** A `forward_dry_run` worker provisions or leases an + isolated PostgreSQL database compatible with the target major version, + materializes the complete operation-relevant dependency closure, executes + the exact stored plan, re-introspects it, and requires the target digest. + The sandbox is destroyed or sanitized after bounded evidence is persisted. +2. **Live read-only preflight.** A separately authorized worker re-introspects + the live target, requires its canonical digest to equal the plan base digest, + and runs bounded operation-specific read queries. Examples include proving + table emptiness before adding a required column without a default, detecting + NULL values before `SET NOT NULL`, and probing type convertibility. Timeouts + and incomplete evidence are non-success states. + +The isolation boundary is mandatory: + +- the sandbox receives no production target credentials and has no network + route to production targets; +- the live-preflight worker has only the guarded target route and cannot turn a + preflight operation into DDL; +- the application metadata database is never used as the migration sandbox; +- target connections continue to use the existing encrypted-secret, + TLS-verification, DNS-resolution, SSRF-validation, and IP-pinning boundary; +- neither worker persists row values, DSNs, or credential-bearing diagnostics. + +Live preflight is evidence, not a concurrency guarantee. Apply must repeat the +schema fingerprint and data-aware preconditions after acquiring the declared +locks on the execution connection. + +## Consequences + +### Positive + +- Production receives no DDL during dry run. +- Exact SQL executability and semantic convergence are tested, not inferred. +- Review distinguishes sandbox evidence from facts observed on the live target. +- Drift prevents execution before any live DDL. + +### Costs and risks + +- Deployment needs a version-compatible sandbox service, lifecycle cleanup, + egress isolation, capacity controls, and separate credentials. +- Dependency closure must be lossless for every admitted operation. Unknown + views, triggers, checks, defaults, partitions, domains, extensions, operator + classes, RLS policies, grants, or similar dependencies block dry run. +- Live read queries need bounded timeouts and must not expose row content. +- A passed dry run can become stale; the apply path must revalidate. + +## Alternatives rejected + +- **DDL plus rollback on the live target.** Rejected because rollback does not + undo lock, scan, rewrite, or resource impact. +- **Sandbox execution without live preflight.** Rejected because it cannot prove + current live fingerprint or data preconditions. +- **Live preflight without executable sandbox validation.** Rejected because + static analysis does not prove the exact plan executes and converges. +- **Reuse the metadata database.** Rejected because target DDL would share a + failure and privilege boundary with pg-erd-cloud control-plane data. + +## Repository evidence + +### Implemented + +- Plans contain base/target digests and structured statement preconditions. +- `app.forward.isolated_dry_run` verifies the persisted plan digest, compiler + version, PostgreSQL major, strict materialized-base digest, supported + all-transactional operation list, and bounded timeouts before executing the + exact compiler-owned statements in one sandbox transaction. It masks driver + failures without retaining cause/context, bounds server and client waits, + rolls back after a started transaction, preserves cancellation, + re-introspects through a bounded worker-owned callback, and requires the strict + target digest. The PostgreSQL 14–18 matrix exercises the real DDL/catalog + round trip in a dedicated ephemeral sandbox database distinct from its + migrated metadata database. This is an execution core, not evidence that a + deployed sandbox service is disposable or network-isolated. +- `complete_isolated_dry_run` rejects malformed or caller-extended success + results, revalidates PostgreSQL major, statement count, base/target digests, + expiry, and plan integrity, then derives only `live_preflight_running` with + aggregate evidence. It owns no sandbox, credential, queue lease, or attempt. +- `app.forward.live_preflight` compiles only the structured + `table_is_empty`, `no_null_values`, and `castable_values` preconditions into + server-owned quoted reads. It enforces a 1,000-query ceiling, PostgreSQL type + validation, one read-only repeatable-read transaction, bounded server/client + timeouts, boolean-only evidence, and fixed non-secret database failures. +- `execute_bound_live_preflight` invokes a caller-owned fresh snapshot callback + and the admitted structured checks inside the same read-only repeatable-read + transaction. It returns bounded check evidence, the canonical observed + digest, and the exact plan-base match without acquiring credential or DDL + authority. +- `complete_live_preflight` rejects extra, malformed, duplicate-position, or + aggregate-inconsistent result evidence and server-derives the terminal CAS: + base mismatch is `drifted`, exact base plus any failed check is `failed`, and + exact base plus all passing checks is `passed`. +- `app.jobs.migration_dry_run_worker_contract` defines least-authority sandbox + and live-reader requests. The sandbox request omits target identity, target + digest, plan JSON and SQL; the live-reader request omits plan JSON, SQL, + PostgreSQL major and base digest. Both bind the exact durable attempt UUID and + attempt number. +- `app.jobs.migration_dry_run_worker` composes the existing dual-lease attempt + handler with the isolated and read-only cores. It locks and reloads the exact + run/plan, verifies immutable plan authority, advances queued work through the + existing CAS/event contract, supports restart from `sandbox_running` or + `live_preflight_running`, and rechecks cancellation, state version and plan + integrity immediately before opening the target reader. Provider failures + are replaced by fixed diagnostics and async capability cleanup is retained. + This is deterministic orchestration, not a concrete deployed worker. +- PostgreSQL 14–18 CI creates a separate ephemeral preflight login for the + target database, grants it only fixture-scoped USAGE/SELECT, removes database + CREATE/TEMP, sets a default read-only policy, and proves both admitted reads + and DDL denial. This is test-environment privilege evidence, not deployed + credential, routing, audit, or worker-attempt evidence. +- The same matrix composes the durable handler with a test-owned sandbox and the + concrete stored-PostgreSQL live-reader provider. It stores an encrypted target + DSN, resolves the exact active attempt and succeeded snapshot scope, decrypts + only after that guard, repeats the exact lookup after connection acquisition + and before any target read, captures through the same acquired connection, and + proves sandbox DDL/convergence, no sandbox replay after takeover, terminal + `passed`, and four durable events on PostgreSQL 14–18. +- Repository composition now requires the durable handler and concrete + stored-target provider to use the same metadata session factory. A divergent + consumer factory fails before metadata or target I/O; sandbox lifecycle and + startup registration remain injected deployment responsibilities. +- The matrix substitutes only the provider's connector with an explicit + test-only loopback connector because the production DNS/SSRF guard correctly + rejects the CI runner's private target. Production route validation retains + separate unit evidence. This is provider composition and version evidence, + not deployed provisioning, guarded-route-to-private-target evidence, + credential/network isolation, startup wiring, or worker operation. + +### Planned before production release + +- isolated sandbox provisioning, complete base dependency materialization, + cleanup, capacity controls, and egress enforcement; +- deployed least-privilege credentials, guarded network identity, and route + isolation through the unmodified production connector (the version matrix + uses an explicit test-only loopback connector seam); +- application startup and queue registration for the injected worker handler; +- real provider-backed PostgreSQL/Valkey restart, cancellation, network-loss and + cleanup acceptance; +- redacted operational telemetry and a frontend presentation of both evidence + classes. + +## Acceptance evidence + +Focused repository tests cover exact attempt binding, least-authority provider +requests, sandbox-to-preflight order, restart without sandbox replay, queued CAS +evidence, cancellation propagation, failure redaction, capability cleanup, +bounded configuration and the target-access cancellation race. These tests are +component evidence only. + +Production acceptance must additionally observe the exact plan executing in a +real isolated provider, verify that the live target received only bounded reads, +prove cleanup on success, failure, cancellation and worker loss, and show that a +live fingerprint mismatch produces terminal drift before any DDL. diff --git a/docs/adr/ADR-0003-plan-execution-segmentation.md b/docs/adr/ADR-0003-plan-execution-segmentation.md new file mode 100644 index 000000000..4ec4dde31 --- /dev/null +++ b/docs/adr/ADR-0003-plan-execution-segmentation.md @@ -0,0 +1,126 @@ +# ADR-0003: Explicit plan execution segmentation + +- **Decision status:** Accepted +- **Implementation status:** Partially implemented +- **Date:** 2026-08-09 +- **Owners:** pg-erd-cloud maintainers and database operators +- **Supersedes:** none +- **Related:** [ADR-0001](ADR-0001-server-authoritative-planning.md), + [ADR-0002](ADR-0002-isolated-dry-run-and-preflight.md), + [ADR-0004](ADR-0004-durable-runs-and-recovery.md), + [forward-engineering v1 contract](../contracts/forward-engineering-v1.md) + +## Context + +PostgreSQL DDL differs in lock level, scan/rewrite cost, transaction capability, +required privileges, preconditions, and recovery behavior. In particular, +`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block and a failed +concurrent build can leave an invalid index. Treating all statements as an +undifferentiated SQL batch would make atomicity and recovery claims false. + +Compiler v1 currently marks every emitted statement `transactional: true`, but +there is no forward-engineering executor. A release contract is needed before +new operation kinds can reach a target. + +## Decision + +The stored plan explicitly declares execution segments and recovery boundaries. + +For the first production slice: + +- exactly one executable segment is allowed; +- every operation in that segment must be transaction-capable; +- the worker executes the ordered segment in one transaction; +- the worker sets bounded `lock_timeout`, `statement_timeout`, and transaction + timeout policy before work; +- it takes a deterministic target advisory lock, then object locks in sorted + qualified-name order at compiler-declared modes; +- it rechecks schema and data preconditions on that same connection after locks + are held; +- any statement or postcondition failure rolls the complete segment back; +- non-transactional operations, including `CREATE INDEX CONCURRENTLY`, are + blockers and do not coexist with an executable partial plan. + +Each operation must carry at least: stable kind, target, ordered SQL, +transaction capability, dependencies, required privileges, declared lock mode, +scan/rewrite/data-loss risk, preconditions, postconditions, and a recovery +classification. The executor dispatches known operation kinds and contract +versions; it does not accept arbitrary text or infer safety from SQL. + +Future non-transactional support requires a new compiler contract version and +an ADR that defines segment ordering, resumability, invalid-artifact cleanup, +compensation, verification, and operator intervention. It must not weaken the +single-transaction claim of v1. + +## Consequences + +### Positive + +- Atomicity claims are scoped to an explicit segment. +- Lock acquisition and precondition ordering can be tested deterministically. +- An unsupported operation cannot hide beside executable operations. +- Future online operations have an explicit compatibility and recovery gate. + +### Costs and risks + +- Ordinary transactional index creation can block writes and must be reported + as such when index support is introduced. +- Large schema changes may still be operationally unsafe despite transactional + rollback; risk review and timeouts remain mandatory. +- Plan format changes require compiler/executor version negotiation. +- External writers do not honor pg-erd-cloud advisory locks, so table locks and + in-transaction precondition checks are still required. + +## Alternatives rejected + +- **One raw SQL batch with best-effort rollback.** Rejected because transaction + capability and recovery differ by operation. +- **Automatically split and continue after a non-transactional failure.** + Rejected because this can leave an unreviewed intermediate schema. +- **Emit `IF EXISTS`/`IF NOT EXISTS` to make retries succeed.** Rejected where + those clauses would mask drift or a partially applied plan. +- **Retry the entire apply after a worker interruption.** Rejected because the + commit outcome may be ambiguous. + +## Repository evidence + +### Implemented + +- `app.forward.migration_plan` emits ordered structured statements, each with + `transactional`, dependency, reversibility, privilege, precondition, and risk + fields. +- Compiler v1 emits only `transactional: true` statements and suppresses all + statements when it records a blocker. +- The signed-plan pre-apply manifest emits zero segments for a no-op plan or one + ordered all-transactional segment for non-empty work. It also maps known + operations to structured database `CREATE`, schema `CREATE`, or table + `OWNER` requirements and rejects compiler-v1 privilege-label drift. This is + target-free input evidence, not privilege observation or execution proof. +- Those exact scopes compile to fixed parameterized PostgreSQL catalog probes. + The public compiler re-derives the manifest from the exact signed plan and + expected digest, so a caller-built manifest cannot redirect an otherwise + valid probe. Compilation neither executes the reads nor binds their results + to a target, role, transaction, or held lock. +- The pure observation assessor rejects incomplete or positionally mismatched + privilege/precondition rows and derives only non-authorizing booleans. It + does not prove target identity, freshness, held locks, or connection binding. +- The bounded caller-owned capture primitive re-derives the manifest from the + exact signed plan and observes one strict snapshot plus every privilege and + precondition position in one read-only repeatable-read transaction. It owns + no credential/attempt binding, acquires no advisory/object lock, and grants no + apply authority; the future executor must repeat the observations in-lock. + +### Planned before production release + +- explicit persisted postconditions; +- versioned executor dispatch over stored statement objects; +- deterministic advisory/object locking and apply-time revalidation; +- timeout configuration and classified, redacted failures; +- ephemeral PostgreSQL tests for rollback, concurrency, and lock timeouts. + +## Acceptance evidence + +Tests must prove that one failing statement rolls back earlier operations, an +unsupported/non-transactional operation prevents all execution, conflicting +writes cannot invalidate locked preconditions, and the executor rejects unknown +operation kinds or compiler versions. diff --git a/docs/adr/ADR-0004-durable-runs-and-recovery.md b/docs/adr/ADR-0004-durable-runs-and-recovery.md new file mode 100644 index 000000000..bc1da2d9b --- /dev/null +++ b/docs/adr/ADR-0004-durable-runs-and-recovery.md @@ -0,0 +1,234 @@ +# ADR-0004: Durable runs, idempotency, cancellation, and recovery + +- **Decision status:** Accepted +- **Implementation status:** Partially implemented; durable storage, + identifier-only transactional outbox, lock-scoped claim/publish-state CAS, + bounded scheduled UUID-only queue publication, exact lease-token + ready/processing claim-ack-release primitives, execution-neutral consumer + contract, DB-durable hashed attempt ownership and dual-lease binding, polling, + and dry-run creation/cancellation intent APIs exist, while application startup + wiring, workers, deployment failover, and recovery do not +- **Date:** 2026-08-09 +- **Owners:** pg-erd-cloud maintainers and operators +- **Supersedes:** none +- **Related:** [ADR-0002](ADR-0002-isolated-dry-run-and-preflight.md), + [ADR-0003](ADR-0003-plan-execution-segmentation.md), + [ADR-0005](ADR-0005-authority-approvals-and-convergence.md), + [forward-engineering v1 contract](../contracts/forward-engineering-v1.md) + +## Context + +Dry run, target preflight, lock waits, execution, reconciliation, and +re-introspection exceed a reliable HTTP request lifetime. A process may fail +before, during, or immediately after commit. Retrying an apply merely because a +queue lease expired can execute a destructive plan twice or misreport an +ambiguous outcome. + +The repository has a generic `JobQueue`, but it does not model an immutable plan +attempt, evidence, approval, target observation, commit ambiguity, or +append-only state history. + +## Decision + +Dry run and apply are durable `migration_run` resources. The API persists the +run, genesis event, and identifier-only transactional outbox before external +I/O, then returns `202`. The `migration_run_dispatch` row contains only its +own identity, `migration_run_uuid`, dispatch kind, delivery state, bounded +attempt metadata, and timestamps; it never contains DSNs, raw SQL, plan JSON, +or row values. The implemented bounded publisher claims one due row with +`FOR UPDATE SKIP LOCKED`, publishes only `migration_run_uuid` on a dedicated +Valkey sorted-set key, and acknowledges that exact attempt in one caller-owned +transaction. It neither commits nor executes work. Publication failure raises +before acknowledgement so the caller rolls back the claim, while consumers +must tolerate at-least-once redelivery after an ambiguous publish. + +The signal adapter atomically reclaims expired processing leases and moves one +due UUID-only ready member to an isolated processing set. A consumer-generated +exact lease-token is stored separately from the ready payload; only that token +may perform exact lease renewal, acknowledgement, or release. An expired signal +owner cannot renew; renewal is monotonic and cannot shorten the current expiry. +This prevents a stale claimant +from extending or acknowledging a successor lease. The execution-neutral +consumer invokes only an injected handler with the exact signal claim (run UUID +plus opaque lease-token), acknowledges after success, releases that exact lease +at a bounded retry time after a sanitized failure, and treats lost +acknowledgement or release ownership as non-success. The ready payload remains +UUID-only. Automatic heartbeat is **Implemented** around the injected handler: +renewal loss cancels and retrieves its task and cannot be acknowledged as +success. The consumer still does not load execution material, access a target, +or execute SQL, and is not wired into application startup. + +`migration_run_attempt` ownership primitives are **Implemented** separately. +Acquisition locks the active dry run, permits one active attempt, marks only an +expired predecessor abandoned, and assigns a monotonic per-run attempt number. +Only SHA-256 hashes of a bounded worker identity and the opaque Valkey signal +lease token are persisted. Renewal requires the exact unexpired owner and an +uncancelled executable run and never shortens expiry; finish requires that same +unexpired owner. These primitives grant no credential or execution authority. +Consumer/startup integration, credential routing, and worker execution remain +Planned. + +Each run binds: + +- one immutable plan UUID and digest; +- run kind (`dry_run` or `apply`); +- actor and project; +- unique idempotency key scoped to the effective action; +- observed live base digest and bounded evidence; +- current state plus compare-and-swap version; +- redacted classified error, timestamps, and verification outcome. + +Apply intent creation is **Implemented without execution authority**. The +deployer route verifies the immutable plan digest and expiry, exact target +connection name, same-plan `passed` dry run with exact observed base, and the +plan's destructive-confirmation requirement. Migration +`0012_apply_intent_confirmation` persists the restrictive passed-dry-run +self-reference, confirmation digest, and destructive boolean. Its genesis +event is hash chained, but no `migration_run_dispatch` row or Valkey signal is +created. Apply-time drift/privilege checks, locks, credentials, execution, +reconciliation, and verification remain Planned. + +`migration_run_event` is append-only and records state transitions, +confirmation, drift, commit acknowledgement, reconciliation, and verification +using identifiers, hashes, counts, and sanitized diagnostics. +Each event carries a versioned canonical digest and its predecessor; the parent +run anchors the latest digest. Writers update the anchor and append the new link +in one caller-owned transaction. Readers recompute the chain. This detects +accidental or partial row mutation but is not a signature and cannot defeat an +attacker with authority to rewrite the complete metadata database. + +Dry-run states are: + +`queued -> sandbox_running -> live_preflight_running -> passed | drifted | failed` + +Apply states are: + +`queued -> applying -> reconciling -> verifying -> verified | drifted_no_apply | not_applied | verification_failed | failed_rolled_back | applied_with_drift | outcome_unknown` + +Rules: + +- compare-and-swap transitions and a uniqueness constraint select one winner + for duplicate submissions; +- a dry-run may enter `passed` only when its canonical observed base digest + equals the integrity-checked immutable plan base, and `drifted` only when it + differs; the CAS stores the digest on the run and same-version chained event; +- cancellation may succeed only before a worker enters `applying`; after that + point the system records a cancellation request but finishes reconciliation + and verification rather than claiming execution stopped; +- dry-run jobs may retry only stages proven not to mutate the target; +- an apply is never automatically replayed after `applying` begins; +- a lost commit acknowledgement triggers re-introspection: exact target digest + becomes `verified`, exact base digest becomes `not_applied`, and unavailable + or third-state evidence becomes `outcome_unknown`; +- operators may resume evidence collection, but no recovery path may replay DDL + from `outcome_unknown` automatically. + +## Consequences + +### Positive + +- HTTP disconnects do not erase work or encourage duplicate applies. +- Terminal states distinguish “no DDL,” “rolled back,” “known committed,” and + “unknown” outcomes. +- Auditors receive a durable sequence rather than mutable log text. +- The UI can poll one bounded resource and truthfully survive page closure. + +### Costs and risks + +- The control plane needs run/event tables, uniqueness constraints, queue + outbox semantics, stale-lease handling, and retention policy. +- State transitions require careful compare-and-swap tests. +- `outcome_unknown` requires operator attention and must be prominent in UI and + alerts. +- Event payload redaction and cardinality limits must be enforced centrally. + +## Alternatives rejected + +- **Execute within the HTTP request.** Rejected because disconnect and timeout + behavior cannot provide durable state or safe recovery. +- **Use the generic queue row as the only record.** Rejected because queue + delivery state is not migration outcome or audit evidence. +- **At-least-once automatic apply retry.** Rejected because commit may already + have succeeded. +- **Collapse every failure to `failed`.** Rejected because operators need to + know whether DDL ran and whether replay is forbidden. + +## Repository evidence + +### Implemented + +- A generic `JobQueue` and snapshot job pattern exist for durable background + work. +- Persisted migration plans provide the immutable input identity for future + runs. +- `MigrationRun` and `MigrationRunEvent` ORM models plus Alembic revision 0010 + persist idempotent run identity and append-only ordered evidence. +- `MigrationRunDispatch` is the identifier-only transactional outbox. Its + unique run foreign key prevents duplicate dispatch identities, database + checks admit only isolated dry-run dispatch and consistent pending/published + timestamps, and its due index supports bounded relay claiming. +- `claim_one_migration_dispatch` orders due work, uses + `FOR UPDATE SKIP LOCKED`, and increments attempt state inside the caller's + open transaction; `mark_migration_dispatch_published` accepts only that + exact attempt and does not commit. +- `publish_one_migration_dispatch` publishes only the claimed run UUID to a + dedicated Valkey key before exact-attempt acknowledgement. The caller owns + commit/rollback; the function never loads the plan or executes SQL. +- `run_migration_dispatch_relay_forever` is an explicit opt-in application + lifecycle. It owns one fresh transaction per claim, rolls failed iterations + back through the transaction context, emits only a fixed non-secret failure + code, polls at a positive configured interval, and is cancelled and awaited + on shutdown. Startup rejects an unconfigured Valkey backend; client import, + URL validity, and connectivity failures surface in relay iterations after + startup. +- UUID-only signal claim, expiry reclaim, exact lease-token acknowledgement, + and scheduled release are implemented and verified against real Valkey. The + execution-neutral consumer contract is **Implemented**; application startup + wiring and worker execution remain **Planned**. +- Database checks constrain run kind, current state, positive state version, + positive event sequence, predecessor presence, and lowercase SHA-256 digest + shapes; uniqueness selects one run per hashed + project/run-kind idempotency identity, `request_digest` distinguishes + conflicting reuse, and at most one event is allowed per run sequence. The + polling boundary separately verifies that every sequence through the current + state version exists and is contiguous. +- `app.forward.migration_run` owns the exact transition graph, bounded + idempotency-key hashing, versioned request digests binding project, plan, + run kind, plan digest, and actor, plus recursive rejection of SQL/credential + fields and PostgreSQL connection-string values in evidence. +- `transition_migration_run` performs an optimistic update matching the exact + UUID, kind, state, and state version, then appends the same-version sanitized + event in the caller-owned transaction. A stale worker cannot publish evidence. +- `create_migration_run` uses the database idempotency constraint to select one + exact dry-run or apply-intent winner. Dry-run creation appends sequence-one + evidence plus one dispatch row. Apply-intent creation validates and persists + exact confirmation bindings plus sequence-one evidence but creates no + dispatch. Neither path commits, publishes, accesses credentials, or executes. +- Cancellation is a same-state, version-incrementing CAS event. A repeated + request is idempotent, a terminal run rejects it, and a worker holding the old + version must reload the intent before any further transition. +- Attempt-bound signal handling now acknowledges that persisted intent with a + locked, exact-version transition to terminal `cancelled` before signal + acknowledgement. Queued cancellation does not set `started_at`. A redelivered + terminal run is acknowledged without acquiring another attempt or replaying + sandbox/live-preflight work; either path locks and abandons a surviving active + attempt before acknowledgement. This is metadata recovery, not deployed + process interruption or apply authority. +- Event digest contract `migration-run-event/v1` covers run UUID, sequence, + type, state, sanitized evidence, actor, normalized UTC time, and predecessor; + the run CAS matches and advances `latest_event_digest`, and polling verifies + every link plus the terminal anchor before exposing evidence. + +### Planned before production release + +- apply executor and apply-time drift/privilege/lock/precondition revalidation; +- application startup wiring, deployed in-flight process cancellation, and + relay deployment restart/failover evidence; +- reconciliation and post-commit verification workers; +- operational metrics, alerts, retention, and recovery runbooks. + +## Acceptance evidence + +Concurrency tests must produce one accepted run for duplicate keys. Fault +injection before execution, before commit, after commit, and before verification +must produce the documented terminal state without automatic DDL replay. diff --git a/docs/adr/ADR-0005-authority-approvals-and-convergence.md b/docs/adr/ADR-0005-authority-approvals-and-convergence.md new file mode 100644 index 000000000..c7ea33bf2 --- /dev/null +++ b/docs/adr/ADR-0005-authority-approvals-and-convergence.md @@ -0,0 +1,140 @@ +# ADR-0005: Least authority, explicit approval, and verified convergence + +- **Decision status:** Accepted +- **Implementation status:** Partially implemented +- **Date:** 2026-08-09 +- **Owners:** pg-erd-cloud maintainers, security owners, and operators +- **Supersedes:** none +- **Related:** [ADR-0001](ADR-0001-server-authoritative-planning.md), + [ADR-0004](ADR-0004-durable-runs-and-recovery.md), + [forward-engineering v1 contract](../contracts/forward-engineering-v1.md) + +## Context + +Editing a desired model, proving a plan, authorizing production DDL, holding a +target credential, and declaring convergence are different authorities. A UI +button is not authorization, and a successful commit acknowledgement is not +proof that the live schema equals the desired model. Destructive operations +need stronger evidence of informed intent than a generic confirmation. + +The repository already encrypts DSNs and has project roles. The current role +ordering is `viewer < editor < deployer < owner`, and live +`apply-sql` (`dry_run=false`) now requires `deployer`. The complete immutable +plan execution and convergence flow is not implemented. A non-dispatched apply +intent boundary now persists exact deployer confirmation without granting +credential, queue, target, SQL, or DDL authority. + +## Decision + +### Authorization and credential boundaries + +- `viewer` can inspect project-scoped models, plans, and bounded evidence. +- `editor` can create/revise models, compile plans, and request dry runs. +- `deployer` can request live apply after every plan and evidence gate passes. +- `owner` manages membership and inherits deployer authority. +- Every API performs server-side project membership and role checks; frontend + control visibility is explanatory only. +- Reads and mutations mask cross-project identifiers using the repository's + uniform not-found behavior. No response reveals another project's resource. +- Target DSNs remain encrypted at rest, are decrypted only in the guarded + connection boundary, and never enter browser, queue, event, or plan payloads. +- Sandbox credentials and live-target credentials are different authorities. + +### Approval binding + +An apply request must bind the unexpired plan UUID and digest, the exact passed +dry-run UUID for the same plan/base observation, a unique idempotency key, and +the current model revision. The deployer types the exact connection name. If +the plan contains destructive operations, a separate acknowledgement is +required. The server stores only a normalized confirmation/approval record and +hash; it does not treat client-rendered warning text as authority. + +Any changed revision, checksum, target fingerprint, expired plan, missing dry +run, insufficient role, or missing confirmation prevents queueing. Enqueue and +model revision supersession use one compare-and-swap decision: either the stale +request returns `409`, or the accepted run remains frozen to the exact plan. + +### Convergence + +Commit success is followed by reverse engineering through the normal snapshot +boundary. The worker persists a dedicated verification snapshot and compares +its canonical digest with the desired target digest. Only equality produces +`verified`. A known commit with residual differences produces +`applied_with_drift`; unavailable verification produces `verification_failed` +or `outcome_unknown` according to the recovery evidence. The UI must never +describe these states as unchanged or verified. + +### Governance responsibility + +Central automation/governance owns reusable policy gates, required evidence, +security baselines, and cross-repository reporting. pg-erd-cloud owns the leaf +product contract, compiler/executor correctness, PostgreSQL-specific risk, +runtime authorization, target safety, user experience, tests, and operational +acceptance. A central green signal cannot replace leaf convergence evidence. + +## Consequences + +### Positive + +- Production mutation requires a distinct capability and evidence-bound human + intent. +- Destructive approval cannot be reused for a changed plan. +- Credential exposure is minimized across browser, queue, sandbox, and audit + boundaries. +- Success means observed convergence, not merely an API or commit response. + +### Costs and risks + +- Projects need a deployer role migration and clear owner/deployer UX. +- Typed confirmation adds deliberate friction. +- Re-introspection adds time after commit and can fail independently. +- Uniform IDOR masking must be corrected consistently; current model creation, + revision, and plan-creation paths do not all mask non-members identically. + +## Alternatives rejected + +- **Let every editor apply.** Rejected because authoring and production + deployment are distinct capabilities. +- **Rely on a disabled UI button.** Rejected because clients are untrusted. +- **Approve “the latest plan.”** Rejected because the approved content can race + with a model edit or target drift. +- **One generic confirmation for destructive plans.** Rejected because data-loss + intent must be explicit and separately recorded. +- **Treat commit acknowledgement as success.** Rejected because convergence has + not been observed. + +## Repository evidence + +### Implemented + +- `app.permissions._ROLE_RANK` includes `deployer` between `editor` and `owner`. +- Legacy live `apply-sql` requires `deployer`; its rollback-only compatibility + mode requires `editor`. +- Connection DSNs are encrypted at rest and guarded target-connection code is + reused for database access. +- Plans bind actor, project, connection, base snapshot, model revision, digests, + compiler version, and expiry. +- `POST /api/migration-plans/{migration_plan_uuid}/apply-runs` requires + deployer authority and binds the exact unexpired plan digest, same-plan + passed dry-run/base evidence, typed connection name, destructive decision, + actor, and idempotency key. It persists a confirmation digest and chained + genesis evidence but deliberately creates no dispatch or execution authority. +- The route locks the plan's schema-model row `FOR UPDATE`; a plan-bound + revision that is no longer the current exact UUID/number/digest fails as + `stale_revision` before intent insertion. + +### Planned before production release + +- independent approval policy beyond the initiating deployer confirmation; +- apply-time target drift/precondition revalidation and executable dispatch; +- uniform IDOR masking across every new resource; +- verification snapshots, convergence comparison, residual diffs, and UI; +- audit events, privilege tests, secret-boundary tests, and leaf operational + acceptance evidence. + +## Acceptance evidence + +Authorization tests must cover every role and cross-project identifier. Race +tests must prove stale revisions and fingerprints execute no DDL. End-to-end +tests must show that the only successful terminal result is a persisted +verification snapshot whose canonical digest equals the approved target digest. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 000000000..f8962775c --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,14 @@ +# Architecture Decision Records + +| ADR | Decision status | Implementation status | +|---|---|---| +| [ADR-0001: Server-authoritative planning](ADR-0001-server-authoritative-planning.md) | Accepted | Partially implemented | +| [ADR-0002: Isolated dry run and live preflight](ADR-0002-isolated-dry-run-and-preflight.md) | Accepted | Partially implemented | +| [ADR-0003: Explicit plan execution segmentation](ADR-0003-plan-execution-segmentation.md) | Accepted | Partially implemented | +| [ADR-0004: Durable runs, idempotency, cancellation, and recovery](ADR-0004-durable-runs-and-recovery.md) | Accepted | Partially implemented | +| [ADR-0005: Authority, approvals, and convergence](ADR-0005-authority-approvals-and-convergence.md) | Accepted | Partially implemented | + +Accepted means the architectural direction is approved; it does not mean the +runtime is complete. Each record carries a separate implementation status and +repository evidence. Superseding a decision requires a new ADR and an explicit +link from this index. diff --git a/docs/api-security-checklist.md b/docs/api-security-checklist.md index f2276dad0..7d9b03022 100644 --- a/docs/api-security-checklist.md +++ b/docs/api-security-checklist.md @@ -110,6 +110,12 @@ - ✅ 민감정보를 URL로 받지 않음(권장: Authorization header) - ✅ 문자열 입력에서 NUL(0x00) 제거(특히 PostgreSQL text/json 방어) - 근거: `backend/app/sanitize.py` +- 🟡 PostgreSQL·MySQL/MariaDB·Snowflake connection DSN은 암호화·영속화 전에 + dialect별 SSRF guard로 검증합니다. 실제 연결의 DNS 재검증·IP pinning은 + PostgreSQL·MySQL/MariaDB에 적용되지만, Snowflake 드라이버 전송 경로의 검증된 + IP pinning 증거는 아직 없으므로 배포 허용 전 별도 egress 통제가 필요합니다. + - 근거: `backend/app/api/connections.py`, `backend/app/db_introspect.py`, + 각 dialect introspector의 DSN guard - 🟡 스키마명 등 일부 입력은 제한(예: PostgreSQL identifier) - 근거: `backend/app/schemas.py` (패턴/길이 제한) diff --git a/docs/contracts/durable-dry-run-worker-v1.md b/docs/contracts/durable-dry-run-worker-v1.md new file mode 100644 index 000000000..e0f3ad9c0 --- /dev/null +++ b/docs/contracts/durable-dry-run-worker-v1.md @@ -0,0 +1,257 @@ +# Durable dry-run worker contract v1 + +- **Contract version:** `durable-dry-run-worker/v1` +- **Capability status:** Partial +- **Implemented boundary:** deterministic attempt orchestration, a + provider-callable metadata handoff guard, and an exact encrypted stored-target + lookup plus concrete guarded PostgreSQL live-preflight and lease-bound + migration-run handler factories +- **Not implemented:** concrete sandbox provider, deployed credential/network + isolation, application wiring, live apply or production readiness + +## Purpose + +This contract binds one UUID-only migration-run signal and one exact durable +`MigrationRunAttemptClaim` to the existing isolated PostgreSQL dry-run and live +read-only preflight cores. It does not grant arbitrary SQL or apply authority. + +`make_durable_dry_run_attempt_handler` returns the +`MigrationRunAttemptHandler` consumed by the existing dual-lease consumer. The +consumer remains responsible for the Valkey signal lease and durable attempt +heartbeat. The handler remains responsible for server-authoritative metadata, +capability sequencing and durable result transitions. + +## Authority inputs + +The handler accepts only: + +1. a server-owned `SessionFactory`; +2. a UUID-only `MigrationRunSignalClaim`; +3. an exact `MigrationRunAttemptClaim` containing the durable attempt UUID, + run UUID, attempt number and acquired state version; +4. injected `IsolatedSandboxFactory` and `LivePreflightFactory` capabilities; +5. bounded lock and statement timeouts, a finite guarded-connection + acquisition timeout in `(0, 60]` seconds, plus whole-stage sandbox and + preflight cancellation deadlines. + +A signal/run UUID mismatch fails before metadata or provider access. + +### Sandbox capability request + +`IsolatedSandboxRequest` contains only: + +- run, plan, project, base-snapshot and exact attempt UUIDs; +- attempt number; +- PostgreSQL major version; +- expected base digest. + +It does not contain a target connection UUID, target digest, plan JSON, +compiler-owned SQL, DSN or credential. + +### Live-preflight capability request + +`LivePreflightRequest` contains only: + +- run, plan, project, stored target-connection and exact attempt UUIDs; +- attempt number and the exact expected run state version refreshed immediately + before provider access. + +It does not contain plan JSON, compiler-owned SQL, PostgreSQL major, base +digest, DSN or credential. The injected provider resolves and constrains the +stored connection identity outside this contract. + +`guard_live_preflight_handoff` is a server-owned, provider-callable check. In +one fresh database statement it matches the exact run, plan, project, stored +target, attempt UUID, attempt number and expected run state version. The same +statement requires an active unexpired attempt, uncancelled +`live_preflight_running` state, matching run/plan digest and an unexpired plan. +It returns no credential, route, connection, plan JSON or SQL. Driver/query +failures and non-matches expose only the fixed handoff error. + +`load_guarded_live_preflight_target` applies that same canonical predicate and +joins the exact project-owned `db_connection` and exact succeeded base snapshot +in one fresh database statement. The snapshot must belong to the same project +and connection and have a non-null completion time. Only an exact active, +unexpired, uncancelled attempt can receive the stored encrypted DSN ciphertext +and nonce together with its `base_schema_snapshot_uuid` and validated optional +`schema_filter`. `GuardedLivePreflightTarget` excludes the secret-bearing byte +strings and schema filter from its representation, malformed stored material or +snapshot scope fails with one fixed non-reflecting error, and cancellation still +propagates. The lookup itself performs no decryption and opens no route or +target connection. + +`make_stored_postgres_live_preflight_factory` composes that lookup with +in-memory AES-GCM decryption and the existing guarded DNS/SSRF/TLS connection. +After the guarded connection opens, the provider repeats the exact encrypted +target/snapshot lookup and requires an identical result before any target read. +This post-connect revalidation closes an acquisition-window authorization +change before capability release; it does not eliminate a concurrent metadata +change after the second check, so exact attempt leasing and the worker's fresh +state checks remain required. +The returned capture callback rejects any connection other than the same +acquired connection, applies the validated snapshot `schema_filter`, and the +provider always closes the acquired connection. Acquisition, decryption, +connection, and cleanup failures expose fixed non-reflecting errors while +cancellation and process-control exceptions propagate. Only the durable +handler receives this capability, and that handler calls the structured +bounded read-only preflight core; the provider accepts no SQL and grants no +apply authority. Application startup wiring and deployed credential/network +isolation remain Planned. + +`make_stored_postgres_durable_dry_run_attempt_handler` is the bounded +repository composition for that provider and the durable attempt handler. It +binds both metadata orchestration and credential-bearing target lookup to the +same session factory and fails closed before metadata or target I/O if a +consumer supplies a different factory. The isolated sandbox factory remains +injected, and the returned handler is not registered with application startup +or granted apply authority. + +`make_stored_postgres_migration_run_handler` composes that exact +same-session attempt handler with `make_attempt_bound_migration_run_handler`. +The resulting execution-neutral handler is compatible with the UUID-only +signal consumer and delegates worker identity, attempt lease, and heartbeat +validation to the durable leasing boundary. It does not start the consumer, +provision or register a sandbox, accept SQL, or grant apply authority. + +The PostgreSQL 14–18 matrix stores an encrypted restricted-target DSN and +enters through `make_stored_postgres_durable_dry_run_attempt_handler`, with a +test-only crash wrapper around `make_stored_postgres_live_preflight_factory` +for the interrupted predecessor and the concrete provider for the successor. +It therefore exercises the exact same-session-factory composition, stored metadata/snapshot guard, +in-memory decryption, same-acquired-connection capture, cleanup, and supported +server versions after proving that the interrupted predecessor fails closed at +the exact lease-expiry boundary. The matrix substitutes an explicit test-only +loopback connector because the production DNS/SSRF guard correctly rejects the +private CI target. This is ephemeral provider-composition evidence, not +unmodified guarded-route, deployed credential/network, startup, or worker +evidence. + +## Server-authoritative metadata checks + +Before external I/O, the handler locks and reloads the exact run and immutable +plan and rejects any mismatch in: + +- run kind, state, state version or cancellation flag; +- run, plan, project, snapshot and target-connection identities; +- exact durable attempt UUID and attempt number; +- plan digest, compiler version, base digest and target digest; +- plan expiry, PostgreSQL major, `can_dry_run` or blockers. + +The plan JSON is deep-copied through canonical JSON before use. The existing +plan digest verifier remains authoritative. + +## State and execution sequence + +1. `queued` is advanced by CAS to `sandbox_running` using the existing + `sandbox_started` event. Evidence contains only the attempt number and exact + durable attempt UUID. +2. The isolated capability is entered and the existing + `execute_isolated_dry_run` core receives the verified plan directly from the + handler, never through the provider request. One whole-stage cancellation + deadline covers provider acquisition, execution and snapshot capture, then + requests task cancellation and awaits capability cleanup. +3. The existing `complete_isolated_dry_run` boundary verifies the result and + derives only `live_preflight_running`. +4. Immediately before target access, the handler locks and reloads the run and + plan again. Cancellation, version loss, identity drift, expiry or integrity + failure prevents opening the live capability. +5. The live read-only request carries the exact refreshed run state version. + A concrete provider can call `guard_live_preflight_handoff` immediately + before target access, or use `load_guarded_live_preflight_target` to combine + the same full identifier/state/lease check with release of the exact stored + encrypted target material and succeeded base snapshot scope in one fresh + database statement. The live capability is then entered and the existing + `execute_bound_live_preflight` core receives the verified plan directly. + A separate whole-stage cancellation deadline covers reader acquisition, + capture and checks, then requests cancellation and awaits cleanup. +6. The existing `complete_live_preflight` boundary derives only `passed`, + `drifted` or `failed`. + +## Restart and cancellation + +- A claimed attempt may resume from `sandbox_running`. +- A claimed attempt may resume from `live_preflight_running` without replaying + an already completed sandbox. +- Other states fail closed. +- `asyncio.CancelledError`, `KeyboardInterrupt` and `SystemExit` propagate. +- Both injected async context managers must close on success, failure and + cancellation. +- Provider acquisition, snapshot callbacks and async-context cleanup must use + cooperative cancellation: they must not suppress `CancelledError` or block + indefinitely after cancellation is requested. The in-process + `asyncio.wait_for` boundary does not prove a hard wall-clock termination + bound for a non-conforming provider. Process isolation and an external kill + boundary remain deployment requirements before worker operation can be + considered bounded against a hung provider. +- If handler completion and heartbeat termination become observable in the + same scheduler turn, the handler result proceeds first to the exact-attempt + completion CAS and then to exact signal acknowledgement. Those CAS + operations remain authoritative: an expired, replaced or otherwise lost + owner still fails closed and cannot authorize acknowledgement. +- The current post-sandbox reload narrows the cancellation/lease-loss window, + and its identifier-only request now carries the exact expected run state + version. The provider-callable guard and guarded encrypted-target lookup share + one canonical predicate that atomically revalidates cancellation, that state + version, stored identities and the exact active attempt lease. No provider is + wired into application startup. The stored PostgreSQL factory now composes + the lookup, in-memory decryption, guarded connection, same-connection snapshot + scope, and cleanup, but it does not eliminate the gap between that metadata + observation and target capability opening. Deployed least-privilege + credentials, network isolation, startup wiring, and cancellation after that + observation are still release-blocking. The stored-provider connection timeout + is injected through both repository composition factories, rejects non-finite, + non-positive, or greater-than-60-second values before metadata or target I/O, + and is passed unchanged to the guarded connector. + +## Failure and evidence policy + +Provider exceptions are replaced with fixed worker-boundary errors using +`from None`. Provider diagnostics, DSNs, credentials, SQL and row data are not +persisted or returned. Whole-stage deadline expiry requests cancellation of +the in-flight capability coroutine, awaits async-context cleanup and emits the +same fixed stage failure when cooperative cancellation completes. This is +timeout cancellation and capability cleanup evidence for conforming test +providers only; it does not prove a hard wall-clock termination bound. This +includes durable-attempt and signal-heartbeat +renewal failures; both cancel and retrieve in-flight work before exposing only +their fixed lease-loss error. Durable evidence continues to be canonicalized +by the existing migration-run transition functions. + +## Explicit non-goals + +This contract does not implement or prove: + +- disposable sandbox provisioning, dependency materialization or egress + isolation; +- deployed network route isolation or independently managed least-privilege + credential identity; +- application startup, queue registration or production worker operation; +- forcible termination of a provider that suppresses cancellation, or the + deployed process-supervisor kill boundary; +- apply dispatch, DDL execution, apply-time drift/CAS, recovery or convergence; +- PostgreSQL/Valkey deployment acceptance or accessible browser E2E. + +## Acceptance families + +Repository tests must cover: + +- exact attempt and provider-request field binding; +- sandbox then live-preflight ordering and cleanup; +- restart without sandbox replay; +- metadata integrity and queued CAS event evidence; +- claim mismatch before metadata access; +- cancellation propagation and provider-error redaction; +- sandbox/live whole-stage timeout cancellation and capability cleanup for + cooperative providers; +- cancellation/state-version recheck before target access; +- guarded stored-target decryption, connection/capture identity, fixed failures, + cancellation and cleanup; +- same-session-factory durable/provider composition and divergent-factory + rejection before metadata or target access; +- bounded configuration rejection; +- rejection of non-contract terminal states. + +Real deployment readiness additionally requires supported PostgreSQL-version +integration with deployed providers, network and credential isolation proof, +fault/restart recovery, operational telemetry and browser E2E. None is claimed +by this Partial contract. diff --git a/docs/contracts/forward-engineering-v1.md b/docs/contracts/forward-engineering-v1.md new file mode 100644 index 000000000..c06de4b03 --- /dev/null +++ b/docs/contracts/forward-engineering-v1.md @@ -0,0 +1,668 @@ +# Forward-engineering v1 contract + +- **Contract status:** Partially implemented +- **Release status:** Not production-ready +- **Compiler identifier:** `pg-erd-forward/v1` +- **Canonical model format:** `format_version: 1` +- **Supported server majors:** PostgreSQL 14 through 18 +- **Last verified against working tree:** 2026-08-09 + +This document separates current repository behavior from the accepted release +contract. The words **Implemented**, **Partially implemented**, **Planned**, and +**Rejected** are normative status labels. A planned route, entity, or state does +not exist merely because it appears here. + +Repository code, tests, this contract, and repository Mermaid diagrams are the +authoritative implementation sources. The +[FigJam board](https://www.figma.com/board/MLWimuWoOWhatQ239QihfP) is a +non-authoritative visual companion. + +## 1. Scope and release boundary + +Forward engineering v1 turns an edited PostgreSQL schema model into a +server-compiled immutable plan, proves that exact plan outside production, +performs live read-only preflight, obtains an evidence-bound deployer approval, +applies one transactional segment, and re-introspects the target to prove +convergence. + +Current code implements only the first control-plane slice: + +- **Implemented:** canonical model validation/digest; persisted model identities + and immutable revisions; optimistic revision API; deterministic structured + plan compilation/persistence and authenticated immutable-plan retrieval; + project `deployer` role; default-deny operator switch plus deployer gating on + the legacy persistent `apply-sql` path. +- **Partially implemented:** fail-closed snapshot-to-model conversion and the + supported compiler subset. Known gaps are listed in section 6. +- **Partially implemented:** durable run/event persistence, exact state + validation, hashed idempotency keys, bounded evidence canonicalization, and + atomic optimistic compare-and-swap transition/event persistence plus an + internal database-selected dry-run creation writer, cancellation intent, and + authenticated integrity-checked run polling and editor-authorized cancellation + API, plus editor-authorized exact-digest/idempotency-bound dry-run creation; + the terminal preflight CAS requires and persists the canonical observed base + digest, revalidates plan integrity, and enforces exact `passed` match versus + `drifted` mismatch semantics. `complete_live_preflight` validates the exact + bounded executor-result shape and server-derives `passed`, `drifted`, or + `failed`; a caller cannot select the state, event type, or digest. A + provider-neutral durable attempt handler owns the verified + sandbox-then-preflight sequence through injected capability contexts; + whole-stage deadlines request cancellation and await cooperative context + cleanup while exposing only fixed errors. This in-process boundary does not + forcibly terminate a provider that suppresses cancellation, so deployed + process isolation and an external kill boundary remain Planned. It grants no concrete credential, network, + provisioning, or startup authority. The + execution-neutral consumer contract is **Implemented**; consumer-to-attempt + binding is **Implemented** without plan, credential, or SQL authority. + When attempt acquisition finds a persisted cancellation intent, the adapter + locks the run and records the terminal `cancelled` acknowledgement before the + exact signal is acknowledged. A redelivered already-terminal run is settled + without replaying sandbox or live-preflight work. + The repository composition binds the durable handler and concrete + stored-target provider to the same session factory and rejects a divergent + consumer factory before metadata or target I/O. Sandbox lifecycle remains + injected. + Application startup wiring and deployed worker execution remain **Planned**. +- **Partially implemented:** the live-preflight primitive compiles the current + structured data preconditions into bounded boolean-only reads and executes + them in one timed read-only transaction. `execute_bound_live_preflight` + additionally binds a caller-owned fresh snapshot callback and those checks + to that same transaction, returning its canonical digest and plan-base match. + `complete_live_preflight` revalidates the stored run and immutable plan, then + requires the result's exact `(statement_index, precondition_index, kind)` set + to equal every persisted plan precondition before producing a bounded + aggregate-evidence CAS. Missing, extra, duplicate, or kind-mismatched checks + fail closed. These primitives have no credential, worker identity, + durable-attempt acquisition, or DDL authority. +- **Partially implemented:** the isolated-dry-run execution core verifies one + signed v1 plan, compatible PostgreSQL major, strict materialized base, + all-transactional statement list, rollback boundary, and target-digest + convergence. `complete_isolated_dry_run` accepts only its exact bounded + success result, revalidates plan provenance, and derives the fixed + `live_preflight_running` CAS. It does not provision, isolate, materialize, + clean, or bind a durable worker attempt. +- **Implemented:** exact deployer-confirmed, execution-free apply-intent + creation binds the current model revision, immutable plan, passed dry run, + observed base digest, confirmation digest, actor, and idempotency key without + creating dispatch or DDL authority. +- **Implemented boundary:** deterministic pre-apply lock-target compilation + consumes only structured known statement kinds, object references, + transaction flags, and reviewed risk metadata. It requires the exact + supported compiler version, sorts and deduplicates + existing tables, preserves PostgreSQL delimited identifiers, skips new + objects that do not yet exist, and fails closed for blockers, unknown or + non-transactional operations, tampered lock modes, invalid identifiers, and + oversized statement sets. It parses no SQL, opens no target connection, and + acquires no lock. +- **Implemented boundary:** target-free pre-apply revalidation-manifest + compilation verifies the exact persisted plan digest and v1 shape, binds the + supported PostgreSQL major and base/target digests to the deterministic lock + targets, structured database `CREATE`/schema `CREATE`/table `OWNER` + requirements, structured boolean checks, and zero segments for a no-op plan or one + ordered all-transactional segment for non-empty compiler-v1 work. It rejects + compiler-v1 privilege-label drift and cross-table or unlocked preconditions. It does not open a connection, acquire a lock, capture a + snapshot, check privileges, dispatch work, or execute SQL/DDL. Holding locks + and repeating fresh observation/checks on the execution connection remain + Planned. Test-only PostgreSQL 14–18 acceptance composes the emitted lock and + check against an ephemeral fixture and proves a concurrent insert is blocked + until rollback; this is database-semantics evidence, not a deployed executor. +- **Implemented boundary:** parameterized privilege-probe compilation re-derives + the manifest from the exact signed plan and expected digest, then maps only + its exact database `CREATE`, schema `CREATE`, and ordinary-table + `OWNER` scopes to fixed PostgreSQL catalog reads. Schema and table identifiers + remain query parameters. Redirected or otherwise tampered plans fail digest + validation before query compilation. The compiler does not connect, execute the probes, observe a role, or + bind results to a target/lock context. Test-only PostgreSQL 14–18 acceptance + proves owner success and denial for the independently constrained read-only + role; production capture and connection binding remain Planned. +- **Implemented boundary:** manifest-bound observation assessment accepts only + the exact plan digest plus a complete, ordered row for every structured + privilege requirement and precondition. It rejects missing, extra, renamed, + reordered, non-boolean, or differently targeted rows and derives base-match, + privilege, and precondition booleans. It cannot prove observation freshness or lock ownership, + bind a target connection, or authorize apply; those runtime controls remain + Planned. +- **Partially implemented runtime primitive:** + `capture_pre_apply_revalidation_observation` re-derives the manifest from the + exact signed plan, starts one bounded read-only repeatable-read transaction on + a caller-owned connection, captures one strict snapshot, executes every fixed + parameterized privilege probe and structured precondition in manifest order, + and returns the pure assessment. Driver/callback failures are fixed and + secret-safe. It does not own credentials, prove the stored target or durable + attempt identity, acquire advisory/object locks, execute DDL, or authorize + apply. Apply must repeat these checks after locks are held. +- **Partially implemented:** browser plan review, dry-run submission, durable run + polling/status/audit, exact-version cancellation, and a non-dispatched apply + intent control exist. The apply control requires the exact passed dry-run, + plan digest, observed base, typed target name, and conditional destructive + acknowledgement; ambiguous retry preserves the first confirmation body and + idempotency key. These remain bounded intent/review surfaces and provide no + credential, dispatch, or SQL authority. +- **Planned:** deployed isolated sandbox lifecycle, unmodified guarded-route + integration around the implemented and version-matrix-composed lookup/ + decrypt/connect/post-connect exact metadata revalidation/cleanup factory, + deployed credential/network constraints, + application worker execution, live apply dispatch/executor, apply-time + fingerprint revalidation, deployed in-flight cancellation, apply recovery, + post-apply convergence, and the complete browser apply/recovery workflow. +- **Rejected for v1:** browser-authored SQL in the graphical workflow, + production DDL rollback as dry-run evidence, DML/backfills, heuristic rename + inference, automatic rollback generation, scheduled apply, MySQL/Snowflake + live apply, and non-transactional/online index execution. + +## 2. Requirement and invariant IDs + +| ID | Normative requirement | Status | +|---|---|---| +| FE-INV-001 | Graphical clients submit semantic model intent; only the server renders executable SQL. | Partially implemented | +| FE-INV-002 | A model revision is canonical, content-digested, and append-only through the API. | Implemented | +| FE-INV-003 | A plan binds one revision, project, connection, succeeded base snapshot, compiler version, base/target digests, actor, and expiry. | Implemented | +| FE-INV-004 | Every admitted semantic difference becomes an operation or blocker; blockers suppress executable statements while supported independent deltas remain reviewable as proposals. | Implemented for the current admitted subset | +| FE-INV-005 | Dry run executes DDL only in an isolated sandbox; the live dry-run phase is read-only. | Partially implemented: isolated execution core plus `complete_isolated_dry_run` success-result CAS and bounded live-read primitive; deployed isolation, sandbox lifecycle, and workers Planned | +| FE-INV-006 | Dry run and apply re-introspect the target and require the bound base fingerprint before DDL. | Partially implemented for the isolated execution core; durable worker binding and apply remain Planned | +| FE-INV-007 | Apply repeats data preconditions after deterministic locks are held on the execution connection. | Partially implemented: deterministic existing-table lock targets and a signed-plan manifest bind statement-matching lock-covered checks; a caller-owned primitive captures a strict snapshot and all privilege/precondition observations with same-connection read-only repeatable-read semantics. Stored-target/attempt binding, lock acquisition, in-lock repetition, and concurrency proof remain Planned | +| FE-INV-008 | V1 applies exactly one all-transactional segment; a failure rolls it back. | Partially implemented input boundary: non-empty manifest work has exactly one ordered all-transactional segment and no-op work has none; target transaction execution, timeout enforcement, rollback proof, and recovery remain Planned | +| FE-INV-009 | A run is durable and idempotent; an apply is never automatically replayed after `applying` begins. | Partially implemented | +| FE-INV-010 | Live apply requires `deployer`, exact-plan confirmation, a matching passed dry run, and separate destructive acknowledgement when applicable. | Partially implemented | +| FE-INV-011 | Queue/event/browser payloads never contain a DSN, decrypted secret, or raw client SQL. | Partially implemented | +| FE-INV-012 | Only a persisted verification snapshot matching `target_digest` may produce `verified`. | Planned | +| FE-INV-013 | Cross-project resource identities are uniformly masked as not found. | Partially implemented | +| FE-INV-014 | Unknown fields, object kinds, operation kinds, and compiler versions fail closed. | Partially implemented | +| FE-INV-015 | Apply rechecks the exact compiler-bound PostgreSQL privilege scope before DDL. | Partially implemented: the manifest maps compiler-v1 operations to structured database `CREATE`, schema `CREATE`, or table `OWNER` requirements and rejects label drift; fixed probes execute in the caller-owned same-connection capture primitive. Stored-target/attempt role binding and in-lock repetition remain Planned | + +No production-readiness claim is permitted while any FE-INV requirement is +Partially implemented or Planned. + +## 3. Current persisted resources + +### `schema_model` — Implemented + +Project-scoped editable identity. `current_revision_number` points to the +current immutable revision. `(project_space_uuid, model_name)` is unique. + +### `schema_model_revision` — Implemented + +Append-only through current APIs. It stores `revision_number`, +`revision_digest`, canonical `model_json`, optional +`base_schema_snapshot_uuid`, actor, and creation time. The database enforces a +unique `(schema_model_uuid, revision_number)` pair. Immutability is currently an +application/API rule rather than a database update-prevention trigger. + +### `migration_plan` — Implemented + +Created by the server from one stored revision and one succeeded snapshot bound +to the same project and exact connection. It stores `compiler_version`, +`base_digest`, `target_digest`, `statement_digest`, `plan_json`, actor, and a +24-hour `expires_at`. There is no update route. Internal dry-run creation +rejects expired plans. Creation-time maintenance deletes only derived plans +that expired at least 30 days earlier, belong to the authorized project, and +have no durable `MigrationRun` history; plans with run evidence are retained. + +### `migration_run`, dispatch, attempt, and event — Partially implemented + +The ORM classes and Alembic revisions `0010_migration_run`, +`0011_migration_run_attempt`, `0012_apply_intent_confirmation`, and +`0013_migration_run_cancellation` persist an +idempotent run identity, optional passed-dry-run/confirmation bindings, one +identifier-only transactional outbox row for executable dry runs, and an +append-only per-run event sequence. The implemented dry-run writer adds exactly +one outbox row for each new run; apply-intent creation adds none. The database +unique constraint enforces at most one dispatch per run. +The row admits only `isolated_dry_run` and binds pending/published state to its +publication timestamp without containing a DSN, SQL, or plan payload. Database +checks bound +run kind, state, state version, event type, before/after state tokens, event +sequence, predecessor presence, and every persisted lowercase SHA-256 digest +shape (idempotency, plan, request, observed base, chain link, and run anchor). A +project/run-kind idempotency +key is unique independently of plan identity, while `request_digest` preserves +the effective request needed to reject same-key/different-request reuse. +`app.forward.migration_run` defines the exact state graph, hashes bounded +idempotency keys, deterministically binds project, plan, run kind, plan digest, +and requesting actor in versioned `request_digest`, and rejects raw SQL, +credential-bearing fields, or PostgreSQL connection-string values from bounded +evidence JSON. The internal cancellation-intent writer and editor-authorized +`POST /api/migration-runs/{migration_run_uuid}/cancel` route are +**Implemented**. Public dry-run creation is **Implemented**. Public apply intent +creation is **Implemented**. Apply intent creation binds a deployer, exact plan digest, +same-plan passed dry-run UUID/base digest, exact typed target connection name, +and the plan's exact destructive-confirmation requirement. The API locks the +plan's schema-model row `FOR UPDATE` and rejects `stale_revision` unless the +plan-bound revision UUID, number, target/revision digest, model, and project +still match the current row. It creates no +dispatch, signal, credential access, SQL execution, or executor authority. +Lock-scoped due-order outbox claiming, +attempt-bound publish-state CAS, and the opt-in scheduled relay lifecycle are +**Implemented**. Atomic UUID-only ready-to-processing claim, expiry reclaim, +exact lease renewal, acknowledgement, and retry release use an exact +lease-token so a stale claimant cannot extend or complete a successor lease. +The execution-neutral consumer +contract is **Implemented**: it invokes one injected handler with the exact +signal claim (the run UUID plus its opaque lease-token), acknowledges only +after success, releases the exact lease on a sanitized failure, and fails +closed when either completion loses lease ownership. The ready-queue payload +remains UUID-only; the token is processing metadata, not execution authority. +An expired signal owner cannot renew, and renewal never shortens an existing +expiry. Automatic heartbeat is +**Implemented**: the consumer renews while its injected handler runs, cancels +the handler when exact renewal is lost, and never acknowledges that loss as +success. A separate DB-durable attempt contract is **Implemented**: acquisition +serializes on an executable uncancelled dry run, stores only hashes of bounded +worker identity and the opaque signal token, permits one active owner, abandons +only an expired owner, and creates a monotonic attempt number. Renewal and +finish are exact-token CAS operations; renewal also requires an executable run, +and neither operation can revive or complete an expired attempt. +Consumer-to-attempt binding is **Implemented** by an execution-neutral adapter: +it commits acquisition before invoking an injected handler, renews the exact +attempt through fresh metadata transactions, cancels work on lease loss, and +finishes the exact owner before the outer consumer may acknowledge the signal. +If acquisition rejects a cancelled run, the adapter locks and reloads the row, +records an empty-evidence `cancellation_acknowledged` transition to terminal +`cancelled`, and only then permits exact signal acknowledgement. If the row is +already terminal, redelivery is acknowledged without reacquiring an attempt or +replaying sandbox/preflight execution. In either settlement path, a surviving +active attempt is locked and marked `abandoned` before acknowledgement so crash +recovery cannot leave a durable owner active forever. +Application startup wiring, credentials, and worker execution remain +**Planned**. The bounded +one-attempt publisher is **Implemented**: it emits only `migration_run_uuid` +to a dedicated Valkey sorted-set key, then acknowledges only the exact claimed +attempt in the same caller-owned transaction. Deployed sandbox/preflight worker +startup, in-flight process cancellation, apply, reconciliation, and verification +remain **Planned**. + +Each event stores `previous_event_digest` and `event_digest`; the run stores +`latest_event_digest`. Contract `migration-run-event/v1` hashes the run UUID, +sequence, type, before/after state, canonical evidence, actor, normalized UTC +timestamp, and predecessor. Genesis has no predecessor; later events require a +64-character lowercase SHA-256 predecessor. The run CAS also matches the prior +anchor. Retrieval recomputes every link and the terminal anchor, returning a +sanitized `409` on mismatch; it also requires the exact genesis event and +replays every ordinary or same-state cancellation transition through the v1 +state graph. The persisted `cancellation_requested` flag must exactly match one +cancellation event; a missing, duplicate, or contradictory event fails closed. +This is tamper-evidence, not a signature or a +guarantee against an actor that can rewrite the entire metadata database. + +`create_migration_run` is the implemented internal creation boundary. It +verifies stored-plan integrity and expiry, rejects blocked plans and all apply +requests, hashes the opaque idempotency key, and uses +`uq_migration_run__idempotent_action` as the PostgreSQL concurrency winner. +Only a new winner receives the sequence-one `run_queued` event and one +`migration_run_dispatch` row in the same caller-owned transaction; a +duplicate is reused only when its versioned request digest is identical. The +function does not commit, publish the outbox, or signal a worker. +`claim_one_migration_dispatch` selects one due pending row with +`FOR UPDATE SKIP LOCKED`, increments its attempt in the caller-owned +transaction, and returns only identifiers plus fixed kind and attempt. +`mark_migration_dispatch_published` CAS-updates that exact attempt. +`publish_one_migration_dispatch` keeps the caller-owned transaction open across +claim, UUID-only publication on the dedicated migration-run Valkey key, and +acknowledgement. A publication failure raises before acknowledgement so the +caller can roll the claim back. The sorted-set member is the run UUID, making +an ambiguous retry idempotent at the signal layer. The opt-in scheduled relay +lifecycle is **Implemented**: it opens one fresh metadata transaction per +claim, commits only after exact-attempt acknowledgement, rolls an exception +back through the transaction context, sleeps at a bounded positive interval +after empty or failed iterations, and cancels cleanly with the application. +It refuses startup unless the Valkey signal backend is configured. The +execution-neutral consumer contract is **Implemented**; consumer-to-attempt +binding is **Implemented** without plan, credential, or SQL authority. +Application startup wiring and worker execution remain **Planned**. The consumer and lease +primitives never load a plan, target credential, SQL batch, or row value. + +`transition_migration_run` validates event metadata and evidence before any +database access, reads the current run identity, and executes one optimistic +update matching UUID, run kind, state, expected state version, and prior event +digest. Only the CAS +winner appends `migration_run_event` with the next sequence number. The caller +owns the transaction, so an event insert failure rolls the state update back. + +The **Implemented** internal `request_migration_run_cancellation` writer does +not invent a synthetic state. It CAS +updates `cancellation_requested` and `state_version` while matching the exact +UUID, kind, state, prior version, and false cancellation flag, then appends a +same-state event at the new sequence. Repeated intent is idempotent; terminal, +missing, invalid, and stale runs fail closed. + +The attempt-bound consumer acknowledgement is also **Implemented** for dry-run +states and queued apply intent. It requires the persisted intent, fixed +`cancellation_acknowledged` event type, empty evidence, no actor, and the exact +state-version/flag CAS. Cancelling from `queued` sets `finished_at` without +claiming `started_at`; `cancelled` is terminal and makes no live-DDL claim. + +The public cancellation route requires a strict positive +`expected_state_version`, editor authority, and the exact run UUID. It binds the +middleware-selected request ID and actor to the appended evidence, commits only +after the CAS writer succeeds, returns `202`, masks nonmembers as `404`, and +uses the stable sanitized run-action error envelope described in section 9. + +## 4. Canonical model JSON + +`app.forward.schema_model.canonicalize_schema_model` is the current authority. +Canonical JSON has this shape: + +```json +{ + "format_version": 1, + "postgresql_major": 16, + "schemas": [ + { + "schema_name": "public", + "tables": [ + { + "table_name": "account", + "comment": null, + "columns": [ + { + "column_name": "account_id", + "data_type": "uuid", + "nullable": false, + "ordinal_position": 1, + "comment": null + } + ], + "primary_key": { + "constraint_name": "account_pkey", + "columns": ["account_id"], + "deferrable": false, + "initially_deferred": false + }, + "unique_constraints": [], + "foreign_keys": [], + "indexes": [], + "unsupported_features": [] + } + ] + } + ] +} +``` + +Normative validation: + +- payload size is at most 2 MiB at the current model API boundary; +- `format_version` equals `1` and PostgreSQL major is 14–18; +- identifiers are non-empty, NUL-free, and at most 63 UTF-8 bytes; exact case, + whitespace, Unicode, reserved words, and quotes are preserved; +- schema names, table names, column names, column ordinals, and primary-key + columns are unique in their respective scopes; +- every primary-key column is explicitly `nullable=false` so catalog + re-introspection cannot introduce an unrequested nullability delta; +- column ordinals are positive integers and are semantically meaningful; +- only the canonicalizer's allow-listed PostgreSQL data types are admitted; + aliases normalize to `pg_catalog.format_type` spelling and non-convergent + `smallserial`/`serial`/`bigserial` pseudo-types are rejected; +- defaults, identity/generated columns, unique/foreign-key constraints, + indexes, and `unsupported_features` are currently rejected when non-empty; +- unknown fields fail closed, except explicitly named volatile capture fields, + which are discarded; +- schemas and tables are sorted by exact name; columns are sorted by ordinal and + name; JSON is hashed with sorted keys, compact separators, and UTF-8. + +The canonicalizer accepts table/column comments, but compiler v1 does not emit +`COMMENT` statements. Comment additions, removals, or changes therefore produce +explicit blockers and suppress all executable statements. + +## 5. Current HTTP API contract + +All routes use the repository's current authenticated, credentialed API +boundary. Unless a route decorator states otherwise, successful FastAPI +mutations currently return `200`, not `201`. + +| Method and current route | Request | Success response | Authority | Status | +|---|---|---|---|---| +| `POST /api/schema-models/by-project/{project_space_uuid}` | `SchemaModelCreateIn` | `SchemaModelDetailOut`, `200` | editor+ | Implemented | +| `GET /api/schema-models/{schema_model_uuid}` | none | current `SchemaModelDetailOut`, `200` | member | Implemented | +| `PUT /api/schema-models/{schema_model_uuid}` | `SchemaModelReviseIn`; required `If-Match` | successor `SchemaModelDetailOut`, `200` | editor+ | Implemented | +| `POST /api/schema-model-revisions/{schema_model_revision_uuid}/migration-plans` | `MigrationPlanCreateIn` | `MigrationPlanOut`, `200` | editor+ | Implemented | +| `GET /api/migration-plans/{migration_plan_uuid}` | none | current `MigrationPlanOut`, `200` | member | Implemented | +| `POST /api/connections/{db_connection_uuid}/apply-sql` | legacy `ApplySqlIn` | `ApplySqlOut`, `200`; persistent disabled `403` | editor for rollback-only; deployer plus explicit operator opt-in for persistent apply | Implemented default-deny legacy compatibility only | + +`SchemaModelCreateIn` contains `model_name`, `model_json`, and optional +`base_schema_snapshot_uuid`. `SchemaModelReviseIn` contains `model_json` and +optional `base_schema_snapshot_uuid`. `SchemaModelDetailOut` contains model and +current revision UUIDs, model name, revision number/digest, canonical model JSON, +and optional base snapshot UUID. + +`MigrationPlanCreateIn` contains `db_connection_uuid` and +`base_schema_snapshot_uuid`. The server additionally receives the exact model +revision UUID in the route. `MigrationPlanOut` contains plan/base/target +digests, compiler version, `can_dry_run`, destructive-confirmation flag, +structured executable statements, review-only `proposed_statements`, blockers, +risk summary, and expiry. + +The immutable preview exposes project, model-revision, connection, +base-snapshot, snapshot-contract, PostgreSQL-major, creator, and creation-time +bindings so a client can review the exact stored execution identity rather than +infer authority from mutable UI state. + +Current route truth takes precedence over the older design-spec spelling +`POST /api/projects/{project_uuid}/schema-models`. That project-nested alias is +**Planned**, not implemented; the team must choose one canonical release path +or supply an explicit compatibility alias before public v1 stabilization. + +## 6. PostgreSQL support matrix + +| Desired change / object | Current canonical/snapshot boundary | Current compiler | Release-v1 disposition | +|---|---|---|---| +| Create schema | Admitted | `create_schema` | Implemented control plane; execution Planned | +| Remove schema | Admitted model difference | `schema_removal_unsupported` blocker | Implemented blocker | +| Create table | Admitted subset | `create_table` | Implemented control plane; execution Planned | +| Drop table | Admitted subset | `drop_table`, destructive | Implemented control plane; execution Planned | +| Add column | Admitted subset | `add_column`; required/no-default adds `table_is_empty` precondition | Implemented plan; bounded live-read `table_is_empty` precondition primitive and completion CAS are Implemented; durable worker binding and apply remain Planned | +| Drop column | Admitted subset | `drop_column`, destructive | Implemented control plane; execution Planned | +| Change data type | Catalog-spelling allow-list; aliases normalize; serial pseudo-types reject | `alter_column_type`, conservative destructive/data-loss/scan/rewrite risk and castability precondition | Implemented plan; generic isolated executor core plus bounded live-read `castable_values` precondition primitive and completion CAS are Implemented; durable worker binding and type-change dependency/privilege/apply proof remain Planned | +| Set/drop nullability | Admitted | `set_not_null` / `drop_not_null` | Implemented plan; bounded live-read `no_null_values` precondition primitive and completion CAS are Implemented for set-not-null; durable worker binding and apply remain Planned | +| Primary key on a new table | Admitted | Included in `CREATE TABLE`, preserving ordered columns and deferrability | Implemented plan | +| Change existing primary key | Admitted | `primary_key_change_unsupported` blocker | Implemented blocker | +| Table/column comment change | Admitted and digest-affecting | Explicit comment blocker; no partial statements | Implemented blocker | +| Existing/non-append column order change | Admitted and digest-affecting | `column_order_change_unsupported` blocker | Implemented blocker | +| Unique or foreign-key constraint | Non-empty collections rejected | Not compiled | Rejected for current slice; future support needs a versioned contract | +| Secondary or expression/partial index | Snapshot/model rejected | Not compiled | Rejected for current slice | +| Default, identity, generated column | Model rejects; snapshot mapping must be proven lossless | Not compiled | Rejected for current slice | +| Views, triggers, functions, RLS, policies, grants, partitions, domains, extensions | Not represented losslessly | Not compiled | Rejected; detected dependencies must block planning | +| `CREATE INDEX CONCURRENTLY` or other non-transactional DDL | Not admitted | Not compiled | Rejected for v1 | +| DML or backfill | Not a model operation | Not compiled | Rejected for v1 | +| MySQL/Snowflake live apply | No forward model contract | No compiler | Rejected for v1 | + +Current `snapshot_to_schema_model` admits primary-key backing indexes only when +the same primary key is represented by `pk_columns`. It preserves primary-key +deferrability and fails closed on the actual introspection keys for defaults, +unique/check/foreign-key constraints, non-primary indexes, partition metadata, +and tablespaces. Planning requires the current `snapshot_contract_version`; +legacy snapshots require recapture. The PostgreSQL introspector reads all +catalogs in one read-only repeatable-read transaction and marks relations with +dropped column slots, which the adapter rejects. Real PostgreSQL round-trip +coverage remains a production gate. + +## 7. Structured plan contract + +Current `plan_json` contains: + +```json +{ + "compiler_version": "pg-erd-forward/v1", + "postgresql_major": 16, + "base_digest": "", + "target_digest": "", + "statements": [], + "proposed_statements": [], + "blockers": [], + "risk_summary": {"safe": 0, "warning": 0, "destructive": 0}, + "requires_destructive_confirmation": false, + "can_dry_run": true, + "plan_digest": "" +} +``` + +Each current statement contains `kind`, `target`, `object_ref`, rendered `sql`, +`transactional`, `dependencies`, `dependency_refs`, `reversible`, `risk`, +`required_privileges`, and `preconditions`. Risk contains severity, lock mode, +possible rewrite, table scan, data loss, and detail. + +`object_ref` and `dependency_refs` are authoritative structured identifiers. +The delimiter-joined `target` and `dependencies` strings are display-only and +must never drive approval, ordering, execution, or audit joins. + +When `blockers` is non-empty, `statements` is empty and cannot be executed. +Independent supported deltas are retained in `proposed_statements` solely for +complete review; they are covered by the plan digest and the same risk summary. + +Every immutable-plan retrieval recomputes the canonical plan digest and +compares it with both the JSON claim and separately persisted statement digest. +It also verifies the separately persisted compiler, base, and target digests +against their digest-covered JSON values. A mismatch fails closed with +sanitized `409` and returns no plan payload. + +Release-v1 requires the following; the plan count/size bound is implemented and +the remaining items are planned: + +- explicit segment identity and order; +- postconditions and recovery classification per operation; +- an operation/statement-count and encoded-size bound before persistence + (**Implemented:** 1,000 executable plus proposed statements and 4 MiB); +- a digest calculation version that covers every execution-relevant field; +- compiler/executor compatibility rejection for unknown versions or kinds; +- a persisted structural diff and complete blocker list; +- enforcement that an expired plan cannot create a run. + +Plan SQL is read-only review output. The release executor consumes the +structured stored plan and verifies `plan_digest`; it does not execute a new SQL +string supplied in a run request. + +## 8. Migration-plan retrieval and bounded run API + +Immutable plan retrieval is **Implemented**. Durable run/event persistence and +the pure state/evidence contract are **Partially implemented**; each route is +classified below without implying worker execution: + +| Method and target route | Required request contract | Success | Status | +|---|---|---|---| +| `GET /api/migration-plans/{migration_plan_uuid}` | authenticated member; no body | immutable IDOR-masked plan preview, `200` | **Implemented** | +| `POST /api/migration-plans/{migration_plan_uuid}/dry-runs` | editor+; bounded `Idempotency-Key`; exact `plan_digest` | persisted queued dry-run identity plus identifier-only transactional outbox, `202`; no worker signal | **Implemented** | +| `POST /api/migration-plans/{migration_plan_uuid}/apply-runs` | deployer+; `Idempotency-Key`; exact `plan_digest`; same-plan passed dry-run UUID with exact observed base; exact typed connection name; destructive acknowledgement equal to plan requirement | persisted queued apply intent and hash-chained confirmation evidence, `202`; creates no dispatch or execution authority | **Implemented intent boundary; executor Planned** | +| `GET /api/migration-runs/{migration_run_uuid}` | authenticated member; no body | IDOR-masked bounded state/evidence view; corrupt count/sequence/genesis/transition-graph/cancellation-intent/chronology/evidence/digest-chain/anchor returns sanitized `409` | **Implemented** | +| `POST /api/migration-runs/{migration_run_uuid}/cancel` | editor+; strict positive `expected_state_version` | exact-version cancellation intent, `202`; stable correlated error envelope on rejection | **Implemented** | + +Dry-run states: + +`queued -> sandbox_running -> live_preflight_running -> passed | drifted | failed`, +with `queued | sandbox_running | live_preflight_running -> cancelled` after a +persisted cancellation intent. + +Apply states: + +`queued -> applying -> reconciling -> verifying -> verified | drifted_no_apply | not_applied | verification_failed | failed_rolled_back | applied_with_drift | outcome_unknown`, +with only `queued -> cancelled` before apply execution begins. + +Terminal semantics are exact: + +| State | DDL/outcome claim | +|---|---| +| `passed` | Sandbox execution converged and bounded live read-only preflight passed for an observed digest equal to the integrity-checked plan base; no live DDL ran. The CAS binding and reserved server-authored digest evidence field are Implemented; worker evidence production is Planned. | +| `drifted` / `drifted_no_apply` | Target base mismatch was observed; no plan DDL ran. Dry-run mismatch classification/persistence is Implemented; worker capture and apply-time classification are Planned. | +| `failed` | Dry-run stage failed; no live DDL ran. | +| `cancelled` | A persisted cancellation intent was acknowledged before further execution authority was acquired. Queued cancellation leaves `started_at` unset; no live DDL completion or rollback claim is made. | +| `failed_rolled_back` | Apply started; the transactional segment is proven rolled back. | +| `not_applied` | Reconciliation proves the exact base digest still exists. | +| `verified` | A persisted post-commit verification snapshot equals `target_digest`. | +| `verification_failed` | Commit is known, but verification could not finish; no convergence claim. | +| `applied_with_drift` | Commit is known and verification proves a non-empty residual diff. | +| `outcome_unknown` | Commit/reconciliation evidence is insufficient; replay is forbidden. | + +## 9. Authorization, concurrency, and error contract + +### Role matrix + +| Action | viewer | editor | deployer | owner | Status | +|---|---:|---:|---:|---:|---| +| Read models/plans/evidence | yes | yes | yes | yes | Partially implemented | +| Create/revise a model | no | yes | yes | yes | Implemented | +| Compile a plan | no | yes | yes | yes | Implemented | +| Request a dry-run intent | no | yes | yes | yes | Implemented; worker execution Planned | +| Cancel a non-terminal run | no | yes | yes | yes | Intent and metadata-only worker acknowledgement Implemented; deployed in-flight process cancellation Planned | +| Request live apply | no | no | yes | yes | Implemented as non-dispatched confirmed intent; executor Planned | +| Manage membership | no | no | no | yes | Existing product contract | + +The server is authoritative. UI gating never substitutes for authorization. + +### Concurrency + +- Model create/get/revise responses emit a strong `ETag` containing the current + revision UUID. Revision requires that exact quoted value in `If-Match`; + content digests and weak tags are rejected with `409`. +- A missing `If-Match` on the current route is a request-validation error. +- CORS allows request header `If-Match` and exposes response header `ETag`. +- Plan and run requests bind an exact revision and digest; “latest” is not an + executable identifier. +- Enqueue must compare-and-swap the current model revision, plan digest, passed + dry-run evidence, and idempotency key in one control-plane transaction. +- A duplicate identical idempotency key returns the original accepted resource; + reuse with a different effective request returns `409`. +- Once `applying` begins, automatic execution replay is forbidden. + +### Error responses + +Current implemented endpoints use FastAPI's JSON shape +`{"detail": ""}`. Current important status codes are: + +| Status | Current meaning | +|---:|---| +| `401` | Missing or invalid authentication at the shared auth boundary. | +| `403` | Authenticated project member lacks the required editor/deployer role. | +| `404` | Missing, cross-project, or non-member resource identity on current schema-model/plan/run paths. | +| `409` | Stale model `If-Match` or corrupt durable run history. | +| `413` | Model JSON exceeds 2 MiB, or a compiled plan exceeds 1,000 executable plus proposed statements or 4 MiB. | +| `422` | Request validation, unusable/mismatched/outdated snapshot or connection, invalid model, or snapshot content unsupported by the current adapter. | + +Release-v1 read-only endpoint errors remain sanitized and machine-classifiable +using the current string `detail` envelope. Mutating run-action endpoints, +including dry-run creation and cancellation, fix their envelope as +`{"detail":{"code":"...","detail":"...","correlation_id":"..."}}`. +The apply-intent creation route reuses this shape; future executor routes must +also reuse it. Optional bounded +`findings` may be added without exposing source values. The contract distinguishes: + +- `stale_revision`, `stale_plan`, `plan_expired`, and `idempotency_conflict` + (`409`); +- `model_invalid`, `input_binding_invalid`, and `unsupported_schema_feature` + (`422`); +- `plan_blocked` as a successful preview with `can_dry_run=false`, not an HTTP + execution failure; +- target operational failure as a durable run state, not a credential-bearing + synchronous error; +- cross-project missing/unauthorized identity as uniform `404`. + +Raw DSNs, SQL batches, row values, and credential-derived text never appear in +errors, logs, events, metrics, or queue payloads. + +## 10. Acceptance criteria and traceability + +| ID | Release acceptance | Evidence required | Status | +|---|---|---|---| +| FE-AC-001 | Save and reopen an edited canvas as an immutable successor revision. | API + frontend adapter/E2E tests | Partially implemented | +| FE-AC-002 | Every supported change appears in the plan; every unsupported difference blocks without partial statements. | mutation/contract tests across every canonical field | Partially implemented | +| FE-AC-003 | Exact stored plan executes successfully in an isolated compatible PostgreSQL sandbox and reaches `target_digest`. | dedicated ephemeral PostgreSQL 14–18 integration database, separate from metadata and target databases; `test_real_postgres_durable_worker_recovers_without_sandbox_replay` drives the durable handler through a test-owned sandbox, interrupts the first attempt after committed convergence, expires its lease, and composes the concrete stored-target provider for successor metadata/decryption/same-connection capture without sandbox replay. Its connector is an explicit test-only loopback seam because the production guard rejects the private CI target | Partially implemented core, provider composition, and pre-live-read takeover evidence; deployed provisioning, dependency materialization, isolation/egress proof, unmodified guarded-route integration, cleanup, startup, process/container restart, and worker operation remain Planned | +| FE-AC-004 | Dry run performs no DDL on the live target and returns bounded preflight evidence. | **Partial:** PostgreSQL 14–18 CI runs bounded preflight through a fixture-scoped USAGE/SELECT login with database CREATE/TEMP removed; proves DDL denial and SELECT denial on an ungranted table; forces a real relation-lock wait through the bounded statement timeout; terminates the restricted backend during another lock wait; requires fixed non-secret failures; and verifies reusable transaction cleanup or a closed connection as appropriate. Deployed network/credential isolation and database audit assertions remain Planned. | Partial | +| FE-AC-005 | Live drift before dry run or apply results in no DDL. | injected-drift E2E tests | Planned | +| FE-AC-006 | Apply cannot queue without editor-authored revision, deployer role, exact passed dry run, exact digest, typed target, and destructive acknowledgement when required. | role/tamper/race/API tests | Implemented control-plane boundary; live apply remains Planned | +| FE-AC-007 | Concurrent duplicate submissions create one effective run. | PostgreSQL 14–18 same-key apply race proves the database uniqueness winner persists one apply run and genesis event with no apply dispatch | Partially implemented for the non-dispatched apply intent; live apply concurrency remains Planned | +| FE-AC-008 | Apply-time locks prevent a concurrent write from invalidating data preconditions. | PostgreSQL concurrency integration tests | Planned | +| FE-AC-009 | A statement failure rolls back the complete v1 segment. | fault-injected PostgreSQL test | Planned | +| FE-AC-010 | Commit uncertainty reconciles to `verified`, `not_applied`, or `outcome_unknown` without automatic replay. | crash/fault-injection tests | Planned | +| FE-AC-011 | A successful apply persists a verification snapshot equal to `target_digest`; residual diff is never called verified. | composed-app E2E test | Planned | +| FE-AC-012 | Cross-project identifiers and insufficient roles follow the documented masking/authorization matrix. | IDOR/role test matrix | Partially implemented | +| FE-AC-013 | Browser workflow is keyboard-operable and exposes named risk, progress, error, and terminal-state live regions. | automated accessibility + manual keyboard verification | Planned | +| FE-AC-014 | Backend typing/tests/coverage, frontend typecheck/tests/coverage/build, security scans, and browser E2E pass on the exact release head. | immutable CI check suite | Planned | + +## 11. Decision links + +- [ADR-0001: Server-authoritative planning](../adr/ADR-0001-server-authoritative-planning.md) +- [ADR-0002: Isolated dry run and live preflight](../adr/ADR-0002-isolated-dry-run-and-preflight.md) +- [ADR-0003: Explicit plan execution segmentation](../adr/ADR-0003-plan-execution-segmentation.md) +- [ADR-0004: Durable runs and recovery](../adr/ADR-0004-durable-runs-and-recovery.md) +- [ADR-0005: Authority, approvals, and convergence](../adr/ADR-0005-authority-approvals-and-convergence.md) +- [Product requirements](../PRD.md) +- [Technical requirements](../TRD.md) +- [Approved design scope](../superpowers/specs/2026-08-09-forward-engineering-design.md) diff --git a/docs/doctoring/dbml-identifier-ddl-boundary.md b/docs/doctoring/dbml-identifier-ddl-boundary.md new file mode 100644 index 000000000..efd6b3819 --- /dev/null +++ b/docs/doctoring/dbml-identifier-ddl-boundary.md @@ -0,0 +1,73 @@ +# DBML identifier-to-DDL boundary + +## Status and scope + +This boundary is **Implemented** for the authenticated DBML conversion path. +It is an export safety contract, not live-apply authority. The parser decodes +each DBML identifier once into the snapshot representation; the PostgreSQL DDL +renderer then delimits each identifier and doubles embedded quotes. Identifier +text is never interpreted as a SQL fragment. + +The parser fails closed for NUL, unterminated quoted identifiers, empty or +ambiguous path segments, more than `schema.table.column` in references, more +than `schema.table` in table declarations, total input over 524,288 characters, +more than 10,000 lines, individual lines over 4,096 characters, and identifiers +over PostgreSQL's default 63-byte UTF-8 limit. Fixed API `422` +errors do not reflect rejected identifier text. Valid reserved words, Unicode, +whitespace, dots, semicolons, comment markers, and embedded quotes represented +as doubled DBML quotes remain data. + +Generated primary-key and foreign-key names are bounded deterministically. A +name that would exceed 63 bytes retains the longest complete UTF-8 prefix that +fits plus a SHA-256-derived suffix, so PostgreSQL never silently truncates it. + +## Data and authority flow + +```mermaid +flowchart LR + Input[Untrusted DBML text] --> Parser[Bounded DBML identifier scanner] + Parser -->|decoded identifiers| Snapshot[Canonical snapshot JSON] + Snapshot --> Renderer[Dialect-owned identifier renderer] + Renderer --> Export[Reviewable DDL export] + Input -. never executable authority .-> Export +``` + +Parsing, canonical storage, and dialect rendering are deliberately separate. +The DBML API neither opens a target connection nor grants a browser-provided +statement execution authority. Value parameterization remains required in +database-query paths; bind parameters cannot replace identifier delimiters in +DDL, so identifiers use the renderer instead. + +## Acceptance evidence + +- `backend/tests/test_dbml_import.py` covers ordinary, Unicode, reserved-word, + whitespace, embedded-quote, dot, semicolon, comment-marker, NUL, malformed, + overlength, multi-segment, resource-bound, and derived-name cases. +- `backend/tests/test_api_dbml.py` proves a fixed non-reflecting `422` response. +- `backend/tests/test_fuzz_properties.py` provides an optional Hypothesis + parse/decode/render round trip when Hypothesis is installed. +- `backend/tests/test_postgres_migration_run_integration.py` executes hostile-looking + quoted names on each ephemeral PostgreSQL 14–18 matrix target and verifies + that only the intended relation exists. + +The real-version matrix, repository CI/security checks, exact-head review, and +protected-branch policy are authoritative. Local focused tests alone are not a +production-readiness claim. CodeGraph was unavailable in the implementation +runtime, so `rg`-based source/sink tracing supplemented direct inspection; the +exact-head review remains required to challenge that impact map. + +## Monitoring and rollback + +Monitor fixed `422 invalid DBML identifier` counts without logging DBML bodies +or rejected names. A rise can indicate incompatible producer output or hostile +input. Rollback must revert parser, renderer, tests, and this contract together; +do not retain permissive parsing with raw constraint rendering. + +## References + +Open Worldwide Application Security Project. (2026). *SQL injection prevention +cheat sheet*. OWASP Cheat Sheet Series. +https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +Lexical structure*. https://www.postgresql.org/docs/18/sql-syntax-lexical.html diff --git a/docs/doctoring/multiline-sql-request-controls.md b/docs/doctoring/multiline-sql-request-controls.md new file mode 100644 index 000000000..f3d3a6bcd --- /dev/null +++ b/docs/doctoring/multiline-sql-request-controls.md @@ -0,0 +1,77 @@ +# Multiline SQL request controls + +## Status and scope + +The transitional `ApplySqlIn.sql` request remains bounded to 262,144 +characters. Its request-schema contract accepts ordinary Unicode text plus tab +(`U+0009`), line feed (`U+000A`), carriage return (`U+000D`), and all printable +spacing needed by multiline DDL. It rejects `U+0000`–`U+0008`, `U+000B`, +`U+000C`, `U+000E`–`U+001F`, and `U+007F`. + +This boundary prevents NUL, DEL, and other non-text controls from crossing into +logs, audit tooling, parsers, and database-driver text boundaries. It does not +make SQL safe and is not described as SQL-injection prevention. The existing +conservative PostgreSQL DDL parser and deployer/default-deny route controls +remain the legacy authorization boundary; the structured forward-engineering +workflow still rejects browser SQL as execution authority. + +## Secret-safe failure behavior + +FastAPI/Pydantic validation details normally include the rejected input. The +production application therefore returns a fixed `422` body for validation +failures on `/api/connections/{uuid}/apply-sql`. That response contains neither +the SQL value nor secret-like literals embedded in it. Other API validation +responses retain the standard handler. + +`SecretSafeLegacyApplyRoute` validates this one sensitive request body before +`get_current_user` and `get_session`. A malformed, missing, oversized, or +control-bearing body therefore cannot enter authentication, metadata-session, +project lookup, credential, target, or driver work. The global sensitive-body +handler remains defense in depth: it ignores `RequestValidationError.body` and +returns the same fixed response for later validation failures. Neither boundary +parses the SQL authorization grammar; character acceptance does not authorize SQL. + +## Evidence + +- `backend/tests/test_schema_validation.py` exhaustively covers every rejected code + point at the beginning, middle, and end of a realistic multiline value, the + accepted whitespace/Unicode boundaries, and the exact length limit. +- `backend/tests/test_request_validation.py` proves the fixed response does not reflect + hostile SQL or a secret-like literal, that production wiring registers the + handler, and that invalid legacy-apply input stops before authentication or + metadata-session dependencies. +- `backend/tests/test_api_apply_sql.py` keeps the conservative parser, authorization, + default-deny persistent path, and DSN-redacted execution failure contracts + separate from character validation. + +Repository CI, security scans, exact-head review, and protected-branch policy +remain authoritative; focused local tests do not transfer across revisions. + +## Monitoring and rollback + +Monitor the fixed validation-error counter by route and status without logging +request bodies, SQL fragments, credential-like values, or raw validation input. +An unexpected rise may indicate broken clients or hostile transport data. A +rollback must revert the route class, focused tests, schema pattern, fixed +handler, and this record together; never keep the behavior while removing its +non-reflection or pre-dependency proof. + +## References + +- Open Worldwide Application Security Project. (n.d.). *Logging cheat sheet*. + OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + This is implementation guidance for excluding or sanitizing untrusted event + data and preventing carriage-return/line-feed log injection; it is not a + certification source. +- The Unicode Consortium. (2025). *The Unicode standard, version 17.0—Chapter + 23: Special areas and format characters*. + https://www.unicode.org/versions/Unicode17.0.0/core-spec/chapter-23/ + This is the normative character authority used to distinguish control and + format-code semantics from printable Unicode text. +- Yuan, H. Y., Wang, X., Yao, K., Chen, A. R., Ding, Z., & Li, Z. (2026). + *Towards Secure Logging: Characterizing and Benchmarking Logging Code + Security Issues with LLMs*. arXiv. https://arxiv.org/abs/2604.20211 + This empirical secure-logging study identifies log injection and sensitive + information exposure as recurring logging-code security issue classes. It + supports treating rejected body text as untrusted data, but does not prove + this repository's implementation correct. diff --git a/docs/runbooks/forward-engineering.md b/docs/runbooks/forward-engineering.md new file mode 100644 index 000000000..830dc6aaa --- /dev/null +++ b/docs/runbooks/forward-engineering.md @@ -0,0 +1,387 @@ +# Forward Engineering Operational Runbook + +- **Runbook status:** Accepted operating design; not yet executable end to end +- **Runtime status:** Partially implemented; structured production apply is Planned +- **Applies to:** PostgreSQL 14–18 forward-engineering v1 +- **Last reconciled with the working tree:** 2026-08-11 + +> Do not use this runbook as evidence that structured production apply exists. +> The repository persists model revisions, migration plans, durable run/event +> evidence, integrity-checked polling, an editor-authorized cancellation API, +> and a deployer-confirmed apply intent that creates no dispatch. Isolated dry +> run and live-preflight execution cores are Partial; deployed worker binding, +> apply execution, reconciliation, verification, alerts, and the application +> kill switch remain **Planned** release gates. + +The legacy `POST /api/connections/{uuid}/apply-sql` endpoint is a transitional +compatibility path. Its rollback mode operates on the live target and therefore +does not satisfy the dry-run procedure below. Persistent use requires deployer +role but lacks plan, approval, drift, event, and convergence binding. + +## Status and authority + +| Capability | Availability | Operational conclusion | +|---|---|---| +| Canonical model save/revision with `If-Match` | Implemented | May be used as a control-plane preview feature. | +| Structured immutable plan compilation/persistence | Implemented bounded subset | May be reviewed; blocked plans are not executable. | +| Real-target preflight and plan expiry enforcement | Partial | Structured bounded boolean reads, `execute_bound_live_preflight` same-transaction snapshot/check binding, the query-only `capture_postgres_snapshot` callback for a caller-owned connection/transaction, `complete_live_preflight` server-derived terminal CAS, `complete_isolated_dry_run` sandbox result binding, DB-durable hashed attempt ownership, execution-neutral consumer-to-attempt binding, and provider-neutral sandbox/preflight orchestration with cooperative cancellation deadlines exist. The guarded stored-target lookup binds encrypted target material to the exact succeeded snapshot scope. The concrete stored-PostgreSQL provider decrypts only after that guard, opens through the existing DNS/SSRF/TLS-pinned connector, scopes capture to the same acquired connection, sanitizes failures, and closes it. Snapshot capture rejects a missing caller transaction before catalog reads or optional Citus savepoint access. PostgreSQL 14–18 CI composes the provider's metadata/decryption/same-connection lifecycle and proves DDL denial; only its connector is an explicit test-only loopback seam because the production guard correctly rejects the private CI target. In-process cancellation cannot forcibly terminate a non-cooperative provider. Deployed process isolation/kill, unmodified guarded-route integration, credential/network constraints, application startup wiring, worker operation, and target audit evidence are absent, so no plan is production-authorized. | +| Isolated disposable PostgreSQL dry run | Partially implemented | Exact signed-plan execution, rollback, version/base checks, target-digest convergence, and `complete_isolated_dry_run` server-derived success CAS have a PostgreSQL 14–18-tested core. Provisioning, dependency materialization, deployed isolation/egress proof, cleanup, and worker binding are Planned; no current result is release evidence. | +| Durable dry-run/apply states and events | Partially implemented | Storage, CAS/event integrity, polling, cancellation intent and terminal acknowledgement, terminal redelivery without sandbox/preflight replay, an execution-neutral consumer contract, consumer-to-attempt binding, and an exact deployer-confirmed apply intent with no dispatch exist; no application startup wiring, sandbox lifecycle, apply executor, or recovery worker exists. | +| Stored-plan executor and in-lock revalidation | Partial foundation | The execution-neutral compiler deterministically sorts/deduplicates existing-table lock targets from structured statement references and fails closed for missing/unknown compiler versions and unknown/non-transactional/tampered operations. It parses no rendered SQL and acquires no target lock. Dispatch, target connection/credential binding, lock acquisition, in-lock drift/precondition revalidation, DDL, commit, recovery, and verification remain Planned; do not enable structured live apply. | +| Post-apply re-introspection and convergence | Planned | No current API may claim verified convergence. | +| Browser forward-engineering workflow | Partially implemented | Read-only plan review, bounded dry-run intent, exact passed-evidence/typed-target non-dispatched apply intent, verified run polling/audit, and exact-version cancellation are available. The apply intent control is **Partially implemented** and does not dispatch or execute. Apply execution/recovery and composed browser E2E remain Planned; do not simulate success. | + +The stored-PostgreSQL provider repeats its exact encrypted +target/snapshot/attempt lookup after guarded connection acquisition and before +any target read. A mismatch closes the connection without granting capture +authority. This post-connect revalidation narrows but cannot eliminate a +concurrent metadata change after the second check; the exact attempt lease and +fresh worker-state checks remain mandatory. + +The PostgreSQL 14–18 CI matrix composes each metadata server with a +digest-pinned ephemeral Valkey 8 service. It verifies that a sanitized handler +failure abandons the exact durable attempt and releases the exact signal, then +that retry completes the next attempt before acknowledgement and removes all +ready/processing/token entries. It then leaves one one-second signal/attempt +pair unacknowledged, waits for actual expiry, and proves a successor reclaims +both stores, marks the expired attempt abandoned, reaches `passed`, completes +the successor, and rejects the stale signal. This is in-process ephemeral +recovery evidence only; it does not prove process/container restart or authorize +deployment startup wiring, credentials, or worker execution. + +Actors: + +- **Editor:** save/revise models, compile plans, request dry run when available. +- **Deployer:** authorize live apply after every evidence gate passes. +- **Operator:** monitor durable states, contain incidents, collect evidence, and + coordinate database recovery. Operator access does not imply deployer intent. +- **Database owner:** validate target privileges, backup/recovery posture, and + manual remediation when a destructive outcome cannot be automatically + reversed. + +## Production enablement checklist + +Every item is **Planned** until an implementation link and immutable CI or +operational artifact is attached to the release record. + +- [ ] Dedicated sandbox service matches each admitted target PostgreSQL major, + cannot route to production, and never receives production credentials. +- [ ] Live preflight credential is read-only; execution credential has only the + object-specific DDL authority required by the plan. +- [ ] Application metadata database is not used as a sandbox. +- [ ] Target allowlist, DNS/IP pinning, TLS policy, certificate roots, firewall + egress, and credential rotation are tested in the deployment environment. +- [x] Run/event/outbox migrations, idempotency uniqueness, compare-and-swap + transitions, atomic identifier-only dispatch creation, cancellation intent + and terminal acknowledgement, terminal no-replay settlement, and evidence + redaction are verified by repository tests. +- [x] Due dispatch claiming uses `FOR UPDATE SKIP LOCKED` and exact-attempt + publish-state CAS in a caller-owned transaction. +- [x] One bounded publisher emits only `migration_run_uuid` on a dedicated + Valkey key before exact-attempt acknowledgement; it neither commits nor + executes work. +- [x] A digest-pinned real Valkey 8 CI service verifies dedicated-key UUID-only + membership and generic-pop isolation through the production adapter. +- [x] An opt-in scheduled relay lifecycle uses one transaction per UUID-only + claim, bounded polling, fixed non-secret failure logging, startup validation, + and cooperative application shutdown. Set `JOB_QUEUE_BACKEND=valkey`, a + usable `VALKEY_URL` or Sentinel configuration, + `MIGRATION_DISPATCH_RELAY_ENABLED=true`, and a positive + `MIGRATION_DISPATCH_RELAY_POLL_INTERVAL_SECONDS` to enable it. This does not + start a queue consumer, load a plan, or execute SQL. +- [x] Atomic ready-to-processing claim, bounded expiry reclaim, exact lease + renewal, acknowledgement, and retry release use an exact lease-token. A stale + claimant cannot extend or complete a successor lease, an expired signal + owner cannot renew, renewal cannot shorten the current expiry, and the ready + payload remains only + `migration_run_uuid`. +- [x] The execution-neutral consumer contract is **Implemented**. It calls one + injected handler with the exact signal claim (run UUID plus opaque + lease-token), acknowledges only after success, releases only the exact lease + at a bounded retry time after sanitized failure, and fails closed on lost + lease ownership. The ready payload remains UUID-only. It does not load plans, + credentials, SQL, or target data. Application startup wiring and worker + execution remain **Planned**. +- [x] Automatic heartbeat is **Implemented** in the execution-neutral consumer. + It renews only the exact claim while the injected handler runs, cancels and + retrieves the handler task on renewal loss, and never acknowledges that loss + as success. No consumer startup wiring or execution worker is implied. +- [x] DB-durable attempt ownership primitives are **Implemented**. They store + only hashed worker/signal-token identity, permit one active owner, reclaim + only expiry, and require exact unexpired-owner CAS for renew/finish. + Consumer-to-attempt binding is **Implemented** as an execution-neutral + dual-lease adapter that commits acquire/renew/finish in fresh transactions + and cancels injected work on durable ownership loss. Application startup + wiring, credentials, and worker execution remain **Planned**. +- [x] The stored-PostgreSQL provider/durable-handler composition requires the + same session factory for run metadata and credential-bearing target lookup. + A divergent consumer factory fails before metadata or target I/O. Sandbox + provisioning and application startup registration remain **Planned**. +- [ ] Relay deployment restart/failover, application startup wiring, worker execution, + recovery, retry exhaustion, and retention are verified in the deployment + environment. +- [ ] `lock_timeout`, `statement_timeout`, and transaction timeout policy have + finite environment-specific values below the incident-response objective. + No repository default currently establishes forward-worker values. +- [ ] One active apply per target/plan authority is enforced, and deterministic + advisory/object lock ordering is tested with external writers. +- [ ] Backup, restore, point-in-time recovery, and destructive-change owner are + confirmed before plans capable of data loss can be approved. +- [ ] Feature-specific metrics and alerts distinguish queue delay, dry-run + failure, drift, rollback, verification failure, applied-with-drift, and + `outcome_unknown` without high-cardinality or secret labels. +- [ ] Application apply kill switch and a separately tested ingress/database + containment procedure are available. +- [x] Legacy persistent `apply-sql` is disabled by default for the product + workflow; retirement remains a separate release decision. + +## Normal planned procedure + +### 1. Freeze and review the plan + +1. Confirm the plan references the intended project, connection name, succeeded + base snapshot, model revision, compiler version, and unexpired timestamp. +2. Compare the model revision digest, base digest, target digest, and plan + digest with the UI/API response. Never use “latest” as an executable + identifier. +3. Review every executable statement and review-only proposed statement, + dependency, required privilege, precondition, lock mode, possible + scan/rewrite, data-loss flag, blocker, and risk count. The risk summary + includes proposals even when execution is blocked. + Treat structured `object_ref`/`dependency_refs` as authoritative; joined + labels are display-only and can collide for delimiter-bearing identifiers. +4. Stop if any blocker exists or `can_dry_run=false`. The compiler must return + `statements=[]` when blocked. `proposed_statements` may retain independently + supported deltas for review, but they are never executable in that plan. +5. Treat primary-key changes, comments/order changes, unsupported constraints, + indexes, defaults, identity/generated columns, partitions, views, triggers, + policies, grants, extensions, DML/backfills, and non-transactional work + according to the current support matrix. Unsupported means stop, not omit. +6. Confirm safe type aliases were canonicalized to PostgreSQL catalog spelling; + serial pseudo-types are unsupported, and every admitted type alteration is + conservatively destructive with possible rewrite, scan, and data loss. +7. Reject and recapture any snapshot without the current capability-contract + version. Dropped column slots are unsupported in this slice. + +### 2. Run isolated validation + +1. Submit the exact plan UUID and digest with a new idempotency key. +2. Confirm the queued run is persisted before external I/O and the queue + payload contains only `migration_run_uuid`. +3. Require sandbox major-version compatibility and complete dependency closure. +4. Execute the stored structured plan in the sandbox, then re-introspect it. +5. Require the sandbox digest to equal `target_digest`. Destroy or sanitize the + sandbox after bounded evidence is persisted. +6. Run live **read-only** fingerprint and data-aware preconditions. Do not run + live DDL. Incomplete, timed-out, or redaction-failed evidence is failure. +7. The terminal CAS must persist the lowercase canonical observed digest on the + run and chained event. It accepts `passed` only for exact plan-base equality + and `drifted` only for inequality after plan-integrity revalidation. +8. Proceed only from `passed`; `drifted`, `failed`, and `cancelled` are terminal + non-success results. + +### 3. Authorize live apply + +This section's intent-creation boundary is implemented. Completing it produces +only a durable queued intent and chained confirmation evidence; it creates no +outbox dispatch, queue signal, credential access, target connection, SQL, or +DDL execution. Every operation in section 4 remains Planned. + +1. Confirm the actor has server-verified deployer authority. +2. Bind the exact unexpired plan/digest, current model revision, and passed + dry-run UUID for the same base observation. + The server locks the schema-model row `FOR UPDATE`; `stale_revision` means a + successor model revision won and no intent was created. +3. Require the deployer to type the exact connection name. +4. Require a separate destructive acknowledgement when any operation has + destructive severity or data-loss risk. +5. Submit once using a new idempotency key. Identical reuse returns the original + run; different input under the same key returns `409`. +6. Verify the returned intent has no dispatch. Stop here until the separately + reviewed executor, apply-time revalidation, and recovery gates are enabled. + +The current execution-neutral revalidation manifest may be compiled for review +from the exact stored plan digest. It binds PostgreSQL compatibility and +base/target digests to deterministic object-lock targets, structured database +`CREATE`/schema `CREATE`/table `OWNER` requirements, and structured data checks. +It rejects compiler-v1 privilege-label drift and a check whose table is not +covered by its statement lock. +For a non-empty v1 plan it also describes exactly one ordered all-transactional +segment; a no-op plan has no segment. It does not acquire a target connection, +observe a target role's privileges, start that transaction, prove rollback, or +make step 2 below true. + +The privilege-probe compiler re-derives the manifest from the exact signed plan +and expected digest, then maps only those exact structured requirements to fixed +read queries. Schema/table names are parameters, not rendered SQL. The +compiled probes are reviewable inputs; compiling them does not execute a query, +identify the target role, or prove that a result came from the locked execution +connection. + +The pure observation assessor may validate that caller-supplied digest, +privilege, and precondition rows are complete and positionally identical to the +manifest. Its base-match and aggregate booleans are untrusted input assessment, +not proof of freshness, target identity, held locks, same-connection capture, +or permission to continue to DDL. + +The bounded capture primitive may run those exact observations on a +caller-owned connection. It re-derives the signed manifest, starts one +read-only repeatable-read transaction, captures the strict snapshot, executes +the fixed privilege probes and structured preconditions in order, commits only +the read transaction, and returns the pure assessment. A fixed failure means no +assessment. This does not bind the connection to the stored target or durable +attempt, acquire advisory/object locks, or permit DDL. Do not reuse its result +as apply authority; apply must repeat revalidation after locks are held. + +### 4. Execute and verify + +1. Worker reloads the run, plan, revision, target, and evidence from metadata; + it does not trust queue or browser copies. +2. Acquire the target advisory lock, begin the transaction, acquire object + locks in deterministic qualified-name order, and recheck base fingerprint + and data preconditions on that same connection. +3. If any recheck fails, execute no plan DDL and record `drifted_no_apply` or a + classified non-success state. +4. Set bounded PostgreSQL lock, statement, and transaction timeouts; execute + the single all-transactional segment. A statement/postcondition failure must + roll back the whole segment and produce `failed_rolled_back` only when the + rollback is proven. +5. After known commit, re-introspect using the same connection and schema + filter, persist a dedicated verification snapshot, and compare its canonical + digest to `target_digest`. +6. Report `verified` only for exact digest equality. A known commit plus + residual diff is `applied_with_drift`; unavailable verification is + `verification_failed`. + +## Fail-closed decision table + +| Observation | Required action | Permitted automatic retry? | Terminal/outcome claim | +|---|---|---:|---| +| Plan has blockers, unknown kind/version, oversized payload, or expired timestamp | Reject before queueing. | No; create a reviewed successor plan. | No DDL | +| Model revision or plan digest changed | Return stale/conflict; require review of the successor. | No | No DDL | +| Target fingerprint differs before dry run or apply | Stop and re-introspect; invalidate old evidence. | No | `drifted` or `drifted_no_apply`; no DDL | +| Sandbox cannot materialize dependencies or does not converge | Destroy/sanitize sandbox; retain bounded error evidence. | Only a stage proven isolated and idempotent | `failed`; no live DDL | +| Live preflight is incomplete or times out | Stop. Do not interpret absence of evidence as success. | New dry-run attempt after cause is resolved | `failed`; no live DDL | +| Role, target confirmation, destructive acknowledgement, or passed dry run is missing | Reject authorization. | No automatic retry | No DDL | +| Lock acquisition or in-lock precondition times out | Roll back and release resources. | New reviewed apply attempt only if DDL is proven not started | Non-success; no DDL if pre-execution proof exists | +| Statement fails and transaction rollback is proven | Persist failure and rollback evidence. | Never automatically replay apply | `failed_rolled_back` | +| Commit succeeds but verification fails | Preserve known-commit evidence; resume verification only. | Verification may retry; DDL may not | `verification_failed` | +| Commit succeeds and residual diff is observed | Stop and escalate for DBA/product review. | No DDL replay | `applied_with_drift` | +| Commit acknowledgement is lost | Re-introspect before any conclusion. | Reconciliation/evidence collection only | Target digest → `verified`; base digest → `not_applied`; otherwise `outcome_unknown` | +| `outcome_unknown` | Freeze the run, alert, preserve evidence, and require manual target investigation. | **No automatic or operator one-click DDL replay** | No applied/not-applied claim | + +## Timeout handling + +The target worker must set finite, separately observable values for: + +- connection timeout before a transaction; +- PostgreSQL `lock_timeout` for advisory and object locks; +- `statement_timeout` for preconditions and each plan statement; +- transaction timeout policy for the complete execution boundary; +- sandbox provisioning/execution/cleanup; and +- verification/reconciliation. + +**Status: Planned.** Numeric defaults and configuration names are not present +for a forward worker. They must be defined, tested against representative table +sizes, and documented per deployment before enablement. A timeout is a +classified failure, never evidence that a transaction rolled back or did not +commit. Do not report `failed_rolled_back` without rollback evidence. + +## Kill switch and containment + +### Planned application kill switch + +The structured workflow must add a deny-by-default server-side apply gate +checked both when an apply run is queued and immediately before `applying`. +The exact configuration contract is **Planned** and must be frozen in the TRD +and deployment manifests. Disabling it must: + +- reject new apply-run requests while preserving read/model/plan operations; +- prevent queued runs from entering `applying`; +- leave already applying runs in reconciliation/verification rather than + killing them and falsely claiming rollback; and +- emit a bounded audit event and operator metric. + +### Current emergency containment + +`LEGACY_PERSISTENT_APPLY_ENABLED=false` is the built-in default and rejects new +persistent compatibility requests before credential access. If an operator had +explicitly enabled the route, restore the setting to `false` and restart/roll +the backend, then coordinate these external controls for in-flight or uncertain +work: + +1. Block `POST /api/connections/*/apply-sql` at the ingress/API policy layer if + rollout of the disabled setting is not yet complete. +2. Revoke the target database role's DDL privileges or rotate/disable the + affected connection credential. +3. Preserve metadata and application logs; do not delete plan/revision records. +4. Check target `pg_stat_activity`, locks, server logs, and schema state with the + database owner before restoring access. + +Blocking a route or stopping a process does not prove that an in-flight +transaction rolled back. Determine the database outcome independently. + +## Recovery by terminal state + +| State | Operator procedure | +|---|---| +| `cancelled` | Confirm the worker never entered `applying`; retain the event trail. | +| `drifted` / `drifted_no_apply` | Capture a new succeeded snapshot, explain drift, create/revise the desired model, and compile a new plan. Never reuse old evidence. | +| `failed_rolled_back` | Verify rollback/connection evidence, fix the cause, then start from a newly reviewed plan or new dry run. Never auto-requeue. | +| `not_applied` | Reconciliation proved the exact base digest. A new apply still requires fresh evidence and explicit deployer intent. | +| `verification_failed` | Retry only read-only verification. Do not run DDL; commit is known. | +| `applied_with_drift` | Preserve residual diff and known-commit evidence; stop related automation; involve the target owner. Remediation is a new reviewed plan or a DBA-managed recovery, never generated rollback. | +| `outcome_unknown` | Disable further applies to the target, preserve evidence, inspect catalogs/server logs/backups with the DBA, and document the conclusion. The system and operator UI must offer no replay action. | +| `verified` | Confirm the persisted verification snapshot UUID and target digest, review event completeness, and close the change record. | + +Automatic rollback generation is **Rejected**. Transaction rollback handles +only a known failure before commit in the v1 segment. Destructive changes after +a known or possible commit require a new explicitly reviewed forward plan or +the database owner's tested backup/point-in-time recovery process. + +## Evidence bundle + +Retain bounded, redacted evidence sufficient to answer what was authorized, +observed, executed, and verified: + +- project, connection, model revision, plan, dry-run, apply-run, and actor UUIDs; +- compiler version; revision, base, target, plan, request, and confirmation + digests; +- plan statement/risk/blocker counts and operation kinds, not a copied SQL + batch in queue/event/log payloads; +- timestamps and durations for state changes, locks, statements, + reconciliation, and verification; +- precondition/postcondition result categories without row values; +- commit/rollback acknowledgement classification; +- verification snapshot UUID, observed digest, and bounded residual diff; and +- correlation/request identifiers and sanitized diagnostic code. + +Never retain decrypted DSNs, passwords/tokens, raw credential-bearing driver +errors, arbitrary client SQL, complete row samples, or secrets in metric labels. + +## Escalation and closure + +Escalate immediately for destructive unexpected change, sustained target +blocking, credential exposure, cross-project access, missing audit events, +`applied_with_drift`, `verification_failed` beyond the verification objective, +or any `outcome_unknown`. + +Closure requires: + +1. target database owner confirmation; +2. preserved and redacted evidence bundle; +3. documented root cause and whether DDL committed; +4. new tests or controls for the failure mode; +5. threat model, ADR/contract, and this runbook updated if the operating model + changed; and +6. a newly reviewed plan for any remediation, never reuse of the incident run. + +## Related authority + +- [Architecture](../../ARCHITECTURE.md) +- [Forward-engineering v1 contract](../contracts/forward-engineering-v1.md) +- [UML and state machines](../UML.md) +- [Data model](../DATA_MODEL.md) +- [Threat model](../security/forward-engineering-threat-model.md) +- [Test strategy](../TEST_STRATEGY.md) +- [ADR-0004: durable runs and recovery](../adr/ADR-0004-durable-runs-and-recovery.md) diff --git a/docs/security/forward-engineering-threat-model.md b/docs/security/forward-engineering-threat-model.md new file mode 100644 index 000000000..4efa325d0 --- /dev/null +++ b/docs/security/forward-engineering-threat-model.md @@ -0,0 +1,161 @@ +# Forward Engineering Threat Model + +- **Threat-model status:** Active design review +- **Runtime status:** Partially implemented; production apply workflow is not ready +- **Scope:** PostgreSQL 14–18 model, plan, dry-run, apply, and convergence path +- **Last reconciled with the working tree:** 2026-08-09 + +This document evaluates the accepted forward-engineering workflow, not a claim +of certification against any external standard. Status labels are normative: +**Implemented**, **Partially implemented**, **Planned**, and **Rejected**. + +## Security objectives + +1. An untrusted browser cannot turn model input into arbitrary target SQL. +2. A user cannot read or mutate another project's model, plan, connection, run, + or evidence by guessing an identifier. +3. A plan cannot execute against a different revision, project, connection, + snapshot, target fingerprint, or compiler contract than the reviewed one. +4. Dry run causes no DDL, lock, scan, or rewrite on the production target. +5. Live DDL requires distinct deployer authority and evidence-bound, + plan-specific intent. +6. A worker failure or ambiguous commit never causes automatic DDL replay. +7. Success means a persisted verification snapshot equals the approved target + digest; a commit acknowledgement alone is insufficient. +8. DSNs, decrypted credentials, raw SQL batches, and sampled row values do not + cross into browser, queue, event, metric, or diagnostic payloads. + +## Assets and impact + +| Asset | Required property | Representative impact if lost | +|---|---|---| +| Target schema and stored data | Integrity, availability, recoverability | Data loss, invalid application behavior, prolonged blocking or outage | +| Target credentials | Confidentiality, least privilege, rotation | Unauthorized introspection or DDL on every reachable database | +| Canonical model revisions | Integrity, provenance, tenant isolation | Review/apply mismatch or attacker-controlled desired state | +| Immutable migration plans and digests | Integrity, authenticity, expiry | Executing different SQL or risk than the deployer reviewed | +| Dry-run, approval, and preflight evidence | Integrity, freshness, non-replay | Unsafe apply accepted using stale or fabricated evidence | +| Run state and audit events | Durability, ordering, accurate outcome | Duplicate apply or false success/rollback claim | +| Metadata PostgreSQL | Confidentiality, integrity, availability | Cross-project leakage, workflow outage, loss of provenance | +| Browser and API session | Authentication, CSRF resistance | Actions attributed to the wrong actor | + +## Trust boundaries + +```mermaid +flowchart TB + Browser["Untrusted browser"] --> API["Authenticated FastAPI boundary"] + API --> Metadata[("Metadata PostgreSQL")] + API --> Guard["DSN guard and credential boundary"] + Guard --> Target[("Live target PostgreSQL")] + Worker["Planned run worker"] -. identifiers only .-> Metadata + Worker -. separate credential .-> Sandbox["Planned isolated sandbox"] + Worker -. guarded route .-> Guard +``` + +| Boundary | Inputs crossing it | Current or target rule | Status | +|---|---|---|---| +| Browser → API | Model JSON, UUIDs, digests, `If-Match`, CSRF token, confirmations | Treat every field as untrusted; authorize server-side; never accept replacement execution SQL on the graphical path. | Partially implemented | +| API → metadata database | Canonical JSON, digests, actor/tenant IDs, encrypted DSN | Parameterized ORM access; project binding; append-only revision/plan convention. Database immutability enforcement remains absent. | Partially implemented | +| API/worker → credential boundary | Connection UUID | Decrypt DSN only in process memory after authorization; redact failures. | Implemented for current API paths and the unwired guarded live-preflight provider; deployed worker credential identity remains Planned | +| Credential boundary → live target | Pinned validated IP, optional verified-hostname TLS, introspection or DDL | Configured host allowlist and restricted-range rejection; PostgreSQL 14–18 CI proves a separate ephemeral preflight login lacks database CREATE/TEMP and is denied DDL. Deployed workers still require independently managed read-only preflight and execution identities. | Partially implemented | +| Worker → sandbox | Exact stored structured plan and compatible schema closure | No production credential or route; disposable lifecycle; re-introspect and require target digest. | Partial execution core with dedicated ephemeral integration database; worker, closure service, deployed route isolation, and lifecycle Planned | +| API → outbox → queue → worker | Run identity | The transactional outbox and ready payload are identifier-only; exact Valkey claim/renew/ack/release and automatic heartbeat are implemented. DB acquisition locks the executable run, persists only worker/signal-token hashes, permits one active attempt, reclaims only expiry, and makes renew/finish exact unexpired-owner CAS operations. Consumer-to-attempt binding is **Implemented** as an execution-neutral dual-lease adapter. | Outbox/signal/consumer contract, durable attempt primitives, and dual-lease binding Implemented; application startup wiring, credentials, worker execution, and deployment failover Planned | +| Worker → browser/log/metrics | Bounded state and evidence | Identifiers, hashes, counts, durations, classified diagnostics only. | Run evidence canonicalization and verified polling Implemented; worker/log integration Planned | + +## Threat actors and assumptions + +- An authenticated viewer, editor, or deployer may be malicious or may make a + destructive mistake. +- A browser, extension, or intercepted request may alter UUIDs, hashes, model + fields, confirmations, or SQL preview text. +- A target hostname or DNS answer may attempt SSRF, DNS rebinding, or TLS name + confusion. +- A target database may be slow, adversarial, drift concurrently, or expose + surprising catalog constructs. +- A worker may crash before a transaction, during execution, after commit but + before acknowledgement, or during verification. +- External database writers do not honor pg-erd-cloud advisory locks. +- The metadata database, application process, sandbox, and live target are + separate failure and privilege domains in the target deployment. + +Compromise of the application host or `APP_SECRET` is not contained by the +current at-rest DSN encryption, because decryption authority runs in the same +application trust domain. Key separation or an external secret manager is a +future hardening opportunity, not an implemented guarantee. + +## Abuse cases, controls, and residual risk + +| ID | Abuse case | Current control | Target control / decision | Status and residual risk | +|---|---|---|---|---| +| TM-01 | Inject SQL through a model identifier, type, default, or unknown field. | Canonicalizer enforces identifier/type bounds, rejects defaults and unknown fields; compiler quotes identifiers server-side; live-preflight accepts only three structured query kinds and prepares each server-owned query before reading boolean evidence. Hostile type/default tests exist. | Executor dispatches known structured operation kinds and version; it never executes browser text. | **Partially implemented:** compiler and read-only preflight boundaries exist; apply executor compatibility enforcement is Planned. | +| TM-02 | Send arbitrary SQL directly from the browser. | Legacy `apply-sql` rejects non-text transport controls without reflecting the body, then parses a small ASCII, unquoted snake-case DDL allowlist; persistent apply requires deployer plus explicit operator opt-in. Character filtering protects transport/log integrity and is not SQL authorization. | Browser-authored SQL is **Rejected** on the model-to-apply path; only a stored server plan is executable. | **High residual risk:** the default-deny transitional endpoint still exists, has no plan/dry-run/evidence binding, and must be retired. | +| TM-03 | Treat rollback-on-production as a safe dry run. | Legacy endpoint defaults to a transaction that rolls back. The forward preflight primitive accepts only three structured boolean reads and opens a read-only transaction, but is not worker-wired. | **Rejected:** exact DDL runs only in an isolated sandbox; live dry-run work is read-only. | **High residual risk until complete:** rollback can still lock, scan, rewrite, exhaust resources, or trigger external effects; sandbox and independently constrained live worker remain absent. | +| TM-04 | Cross-project IDOR using model, plan, connection, snapshot, or run UUIDs. | Membership checks and uniform 404 masking exist on current model/plan/connection/run-polling and apply-intent paths; binding rejects mismatched project/connection/snapshot/run inputs. | Apply the same masking and binding to every future executor/evidence route. | **Partially implemented:** focused route tests exist, but the full HTTP integration matrix does not. | +| TM-05 | An editor self-authorizes production DDL. | Role order is `viewer < editor < deployer < owner`; persistent legacy apply requires deployer plus explicit operator opt-in, while non-dispatched structured apply-intent creation requires deployer. The structured intent binds exact passed dry-run/base evidence, typed target name, plan digest, and the destructive decision, then creates no dispatch. | Add independently reviewed approval policy where required and keep executor authority separate from intent creation. | **Partially implemented:** deployer capability and exact intent binding exist; independent approval policy and all structured execution remain Planned. | +| TM-06 | Reuse approval after model edit, plan expiry, or target drift. | Revisions use `If-Match`; plans bind revision, target, base snapshot/digests and store 24-hour expiry; apply-intent creation locks the schema-model row `FOR UPDATE` and rejects a non-current exact revision as `stale_revision`; the internal idempotent dry-run writer rejects expired/tampered plans; terminal preflight CAS revalidates plan integrity, requires the canonical observed digest, persists it, rejects match/outcome contradictions, and rejects worker-authored aliases of the reserved digest evidence field. The target-free revalidation manifest verifies the stored plan digest and binds compatible version/base/target metadata to deterministic lock targets and lock-covered checks. Exact scopes compile to fixed parameterized privilege reads only after manifest re-derivation. A bounded caller-owned primitive captures a strict snapshot and every privilege/precondition observation in one read-only repeatable-read transaction, then applies the pure assessor. A separate single-query lookup binds the exact project-owned encrypted DSN ciphertext/nonce to exact succeeded snapshot scope while the bound run/plan/attempt remains active, uncancelled, unexpired, digest-consistent, and at the expected state version. The concrete provider decrypts only that result, opens through the guarded connector, and scopes capture to the same connection. | Preserve the exact binding through connection acquisition, deploy independently managed read-only credentials and network identity, acquire deterministic locks for future apply, and repeat all observations immediately before execution. | **High residual risk:** the provider cannot make metadata observation and external connection acquisition atomic or prove deployed target role/network controls; it is not wired into startup and cannot authorize apply. Advisory/object lock service, in-lock repetition, and live execution do not exist. | +| TM-07 | Silently omit an unsupported object and apply a partial schema. | Snapshot adapter and canonicalizer reject unsupported constructs; a blocker makes executable `statements` empty. Supported deltas remain only as `proposed_statements`, and their risk still appears in `risk_summary`. | Executor rejects blocked plans and never promotes proposals; a real PostgreSQL corpus proves complete dependency detection for every admitted construct. | **Partially implemented:** compiler proposal/blocker fixtures exist; realistic and adversarial catalog integration coverage and executor enforcement remain release gates. | +| TM-08 | SSRF or DNS rebinding through a stored DSN. | Before encryption or persistence, every supported dialect runs its non-connecting target guard and returns a fixed error for rejected input. Live probe/introspection/apply paths independently revalidate DNS; loopback/private/link-local/reserved targets are rejected and resolved IPs are pinned. PostgreSQL query `host`/`hostaddr` values are also validated. | Revalidate in every future worker path; network egress policy restricts reachable targets. | **Partially implemented:** pre-storage and connection-time application guards are tested; deployment-level egress evidence is absent. | +| TM-09 | Intercept credentials or connect to the wrong TLS peer. | DSN is AES-GCM encrypted at rest and decrypted in memory. `sslmode=verify-full` uses verified hostname context. | Require an approved TLS policy per environment and separate sandbox/live credentials. | **Residual risk:** verified TLS is conditional on DSN configuration; key authority is co-located with the app. | +| TM-10 | Exfiltrate DSN or row data through errors, logs, events, or metrics. | DSN-derived error redaction and fuzz/property tests exist; run evidence recursively rejects SQL/credential field names and PostgreSQL connection-string values; `migration_run_dispatch` has no payload column; the dedicated ready queue receives only the run UUID while the exact lease-token is isolated in processing metadata; the execution-neutral consumer and attempt adapter replace handler exceptions with fixed codes; the generic durable worker persists fixed failure codes instead of exception text or unknown job-type values; the live-preflight primitive returns only boolean check outcomes or canonical digests and replaces transaction creation/start, query, commit, and rollback-cleanup exceptions with one fixed message without chaining driver detail. | Queues prohibit secrets, SQL batches, and row values; handler-specific evidence and worker log review tests enforce bounds. | **Partially implemented:** identifier-only outbox/publisher/signal lease/consumer-contract primitives, dual-lease binding, run storage/polling, generic worker failure-storage boundaries, and execution-neutral preflight result sanitization exist; application startup wiring/worker/metrics paths remain Planned. | +| TM-11 | Exhaust API, metadata storage, sandbox capacity, or target locks. | API rate limiting exists; model payload is capped at 2 MiB; plan is capped at 1,000 statements and 4 MiB; live preflight caps reads at 1,000 and applies a parameter-bound transaction-local statement timeout plus a client timeout; legacy SQL is capped at 25 statements/256 KiB. | Per-project run quotas, sandbox admission control, bounded lock/statement/transaction timeouts, and operator kill switch. | **Partially implemented:** preflight query/statement timeout bounds exist; run quotas, worker transaction/lock timeouts, target lock bounds, and kill switch are Planned. | +| TM-12 | Duplicate apply after queue retry or an uncertain commit. | No structured apply worker exists. | Durable idempotency, compare-and-swap states, no automatic replay after `applying`, and reconciliation by re-introspection. | **Planned release blocker.** `outcome_unknown` requires operator handling. | +| TM-13 | Forge, reorder, or erase audit evidence. | Sequenced run events carry a versioned predecessor digest; the run anchors the latest digest; CAS writers and polling verify the canonical chain and fail closed on partial mutation. | Add retention protection and an independently anchored or signed audit sink for resistance to full metadata-database rewrite. | **Partially implemented:** in-database tamper evidence exists; privileged full-history rewrite and deletion remain residual risks. | +| TM-14 | Misrepresent commit or verification failure as success. | Plan compilation does not claim execution success. | Only a persisted verification snapshot equal to `target_digest` yields `verified`; all other terminal states use distinct UI semantics. | **Planned release blocker.** | +| TM-15 | Bypass controls through unsupported MySQL/Snowflake or non-transactional DDL. | Forward compiler is PostgreSQL 14–18 only and rejects unsupported model features. | MySQL/Snowflake live apply and non-transactional v1 operations remain **Rejected**. | **Residual risk:** every future compiler version needs a new compatibility and recovery review. | + +## Authorization and approval requirements + +| Action | Minimum role | Additional evidence | Status | +|---|---|---|---| +| Read model/current plan | Project member | Uniform cross-project 404 | Partially implemented | +| Create or revise model | Editor | Valid model; strong revision-UUID `ETag` in `If-Match` | Implemented | +| Compile a plan | Editor | Exact revision, same-project target and succeeded snapshot captured from that target | Implemented | +| Queue dry-run intent | Editor | Unexpired plan, exact digest, bounded idempotency key | Implemented; no worker authority | +| Execute isolated dry run | Worker identity | Queued intent, governed sandbox, compatible PostgreSQL version | Partial execution core only; worker-governed invocation Planned | +| Create non-dispatched live-apply intent | Deployer | Matching passed dry run/base observation, exact plan/digest, typed connection name, destructive acknowledgement equal to the plan requirement, and bounded idempotency key | Implemented intent boundary; independent approval/executor Planned | +| Persistent legacy `apply-sql` | Deployer plus explicit operator opt-in | Conservative SQL parser only | Implemented default-deny transitional path; not accepted target authority | + +The security-sensitive legacy apply endpoint resolves connection membership and +role from the primary metadata session; a lagging read replica is never an +authorization source for live DDL. + +Frontend visibility is never authorization. Mutations retain the repository's +authentication, credentialed CORS, and CSRF boundary. The `If-Match` header is +included in the current CORS allowlist and `ETag` is exposed to browser clients. + +## Threat-driven release gates + +Production enablement remains denied until all of the following are evidenced: + +- Real PostgreSQL 14–18 integration fixtures demonstrate lossless admitted + snapshot conversion and fail-closed dependency detection. +- The browser cannot submit execution SQL; plan/run requests bind exact + identifiers and digests, and the worker reloads immutable state. +- Sandbox and live-target credentials, routes, and database privileges are + independently verified; live preflight is technically incapable of DDL. +- Drift, expiry, IDOR, CSRF, role, tamper, destructive-confirmation, + double-submit, and cancellation tests produce no unauthorized DDL. +- Fault injection before execution, before commit, after commit, and during + verification produces the specified terminal state without automatic replay. +- Operational limits, alerts, kill switch, evidence retention, and the + [forward-engineering runbook](../runbooks/forward-engineering.md) are exercised + in a non-production environment. +- The legacy persistent apply route remains disabled by default for the product + workflow, and its explicit retirement decision is completed. + +## Residual risk ownership + +No current document accepts production data-loss, unbounded blocking, stale +apply, automatic replay, or false verification risk. Until the gates above are +closed, forward engineering remains **Partially implemented** and disabled as a +production-safe workflow. Any exception requires a new ADR naming the owner, +scope, expiry, detection, containment, and recovery evidence. + +## Related authority + +- [Architecture](../../ARCHITECTURE.md) +- [Forward-engineering v1 contract](../contracts/forward-engineering-v1.md) +- [UML and state machines](../UML.md) +- [Data model](../DATA_MODEL.md) +- [ADR index](../adr/README.md) +- [Test strategy](../TEST_STRATEGY.md) +- [Operational runbook](../runbooks/forward-engineering.md) diff --git a/docs/superpowers/specs/2026-08-09-forward-engineering-design.md b/docs/superpowers/specs/2026-08-09-forward-engineering-design.md new file mode 100644 index 000000000..8889770ae --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-forward-engineering-design.md @@ -0,0 +1,507 @@ +# Forward Engineering Workflow Design + +**Repository:** `ContextualWisdomLab/pg-erd-cloud` +**Base:** `main@72afe6db712b145baaba084f64a1ff4fb36d9fd0` +**Status:** Approved target architecture; Phase 1 control plane partially implemented +**Date:** 2026-08-09 + +## Implementation snapshot + +Canonical product, technical, and runtime truth is maintained in +[`docs/PRD.md`](../../PRD.md), [`docs/TRD.md`](../../TRD.md), the +[`docs/adr/`](../../adr/README.md) decision set, and the +[`forward-engineering-v1` contract](../../contracts/forward-engineering-v1.md). +This document retains the approved end-state design and implementation order. + +Implemented now: canonical model/revision persistence, `If-Match` concurrency, +strict snapshot adaptation for the proven subset, deterministic structured plan +compilation/persistence, plan bounds, fail-closed blockers, and the deployer +role on legacy persistent apply. Planned: plan retrieval, run/event persistence, +isolated dry run, live preflight, executor/recovery/convergence, and the frontend +workflow. The actual create route is +`POST /api/schema-models/by-project/{project_space_uuid}`; the project-nested +route below is a target compatibility shape, not current code. + +## Problem + +pg-erd-cloud can export DDL and snapshot-to-snapshot migration SQL, and the +backend exposes a conservative `apply-sql` endpoint. These pieces do not form a +working product flow: + +- the ERD canvas emits quoted identifiers, foreign keys, and + `CREATE INDEX CONCURRENTLY`, while the live-apply validator rejects quoted + identifiers, foreign keys, comments, and concurrent indexes; +- the frontend never calls the live-apply endpoint; +- arbitrary client-supplied SQL is the wrong trust boundary for a graphical + model editor; +- there is no persisted edited model, immutable migration revision, dry-run + evidence, drift precondition, apply audit trail, or post-apply verification. + +Connecting the existing button-sized pieces would therefore create a misleading +and unsafe feature. The product needs one server-authoritative workflow from an +edited model to a verified database state. + +## Goals + +1. Save an edited ERD as a project-scoped schema-model revision. +2. Compile an exact saved revision on the server into an immutable PostgreSQL + migration plan and risk report. +3. Require a successful disposable-database execution dry run and live + read-only preflight for the exact immutable plan before apply is enabled. +4. Detect live-schema drift before dry run and again immediately before apply. +5. Require explicit typed confirmation before queuing a live apply. +6. Execute live changes in the existing durable job queue with bounded timeouts, + redacted errors, and retry-safe state transitions. +7. Reverse-engineer the database after commit and compare it with the desired + model, preserving a verification snapshot and structural diff. +8. Preserve project membership and IDOR masking while adding an explicit live + deploy capability; retain DSN encryption, SSRF-pinned target connections, + CSRF protection, and exact-head test gates. + +## Non-goals for the first production slice + +- arbitrary SQL editing or execution; +- automatic rollback generation; +- data backfills or DML; +- heuristic rename detection (a remove/add remains explicit); +- Snowflake or MySQL live apply; +- online/non-transactional operations such as `CREATE INDEX CONCURRENTLY`; +- silently applying model features the compiler cannot represent; +- automatic or scheduled production apply. + +Unsupported changes are reported as blocking findings. They are never omitted +from the preview while the UI still claims the desired model can be reached. + +## Architecture + +### 1. Models, immutable revisions, plans, and runs + +Keep four responsibilities separate: + +- `schema_model` is the project-scoped editable design identity and points to + its current revision. +- `schema_model_revision` is immutable. It stores the canonical model JSON, + revision number/hash, base snapshot, actor, and timestamp. Saving uses + optimistic concurrency and creates a new row rather than rewriting history. +- `migration_plan` is an immutable compilation from one succeeded base snapshot + and exact model revision to one project connection and schema scope. It stores + base/target digests, detected PostgreSQL major version, compiler contract + version, structured operations, read-only SQL preview, structural diff, risk + report, blockers, and SHA-256 checksum. +- `migration_run` is one idempotent `dry_run` or `apply` attempt. It stores the + plan/checksum, idempotency key, actor, state, observed live digest, evidence, + verification snapshot/digest/diff, timestamps, and classified redacted error. + +Each structured operation records transaction capability, expected lock level, +possible scan/rewrite, destructive or conversion risk, preconditions, +postconditions, and automatic rollback boundary. The compiler rejects +operations unavailable on the detected PostgreSQL version. + +Add `migration_run_event` as an append-only audit record for queueing, +execution, confirmation, drift, commit, reconciliation, and verification. +Event payloads contain identifiers, hashes, counts, and sanitized diagnostics, +never DSNs or raw credential-bearing payloads. Queue payloads contain only a +`migration_run_uuid`, never DSNs or SQL. + +Changing the model creates a new revision and supersedes prior plans for apply +run creation. Enqueue uses one compare-and-swap transaction against the model's +current revision and plan checksum: if a concurrent save wins first, enqueue +returns `409`; if enqueue wins first, the accepted run is frozen to the exact +confirmed plan and a later save creates a successor without changing that run. +Once created, a plan and its SQL/checksum never mutate. Further work after apply +starts from the verification snapshot and a successor model revision rather +than rewriting audit history. + +### 2. Canonical model boundary + +Create a shared backend canonicalizer for the PostgreSQL schema subset the +product can edit. It removes volatile capture metadata and OIDs, keys objects by +qualified name, preserves meaningful column order, and deterministically sorts +constraints and indexes. It validates identifiers, types, references, +duplicates, missing endpoints, unsupported expressions, and payload bounds. + +The frontend introduces a `SchemaModel` domain with adapters +`snapshotToSchemaModel`, `schemaModelToGraph`, and `graphToSchemaModel`. React +Flow becomes a view/editing surface rather than the persistence or execution +contract. Conversion preserves non-editable metadata from the base snapshot and +replaces only objects controlled by the canvas. The backend treats every model +payload as untrusted, validates it independently, and owns canonicalization, +diffing, risk classification, compilation, and hashing. + +The editable graph contract gains explicit `schema_name`, `relation_name`, and +stable client object IDs instead of interpreting React Flow node IDs or display +titles as database identity. Foreign-key edge data gains a separate constraint +name and ordered endpoint columns instead of parsing the human-readable label. +The snapshot contract also exposes structured columns for simple indexes while +retaining `index_def` for lossless reverse export. Expression, partial, and +otherwise opaque indexes remain visible/read-only; attempting to change one +produces a blocker instead of reparsing SQL text. + +The current text `apply-sql` endpoint remains a compatibility surface but is +not used by the graphical workflow and is not broadened. New workflow execution +accepts a plan UUID and expected hash, never SQL from the browser. + +### 3. Server-side migration compiler + +Replace the split frontend-generator/live-validator contract with a structured +server compiler. Each statement has a kind, target objects, ordered SQL, +transactional flag, and risk linkage. The same operation objects drive SQL +rendering, executor dispatch, pre/postconditions, and the risk report so those +surfaces cannot disagree. The target compiler supports the model-editing subset +below. Phase 1 currently implements schema/table/column/nullability/type +operations and creation-time primary keys only; unique/FK/index/comment +operations remain unsupported and fail closed or produce explicit blockers. +When blocked, executable statements are empty while independent supported +deltas remain reviewable as digest-bound proposals: + +- create missing schemas represented by the plan; never drop a schema + automatically, and report desired schema removal as a blocker; +- create/drop tables; +- add/drop/alter columns and nullability; +- add/drop primary, unique, and foreign-key constraints when fully represented; +- create/drop ordinary PostgreSQL indexes with validated access-method tokens; +- table comments already represented by the snapshot model. + +Identifiers are rendered with the backend's PostgreSQL quoting utility. SQL is +never reparsed to infer whether it is safe: executable statements originate +from validated structured objects. Executable plans avoid `IF EXISTS` and +`IF NOT EXISTS` where those clauses would mask precondition drift; existence is +proved structurally before execution. The plan compiler emits ordinary +transactional `CREATE INDEX`, not `CONCURRENTLY`; the risk report warns that it +can block writes. Online-index mode is deferred because PostgreSQL prohibits +`CREATE INDEX CONCURRENTLY` inside a transaction, so it cannot share the same +atomic rollback guarantee and a failed concurrent build can leave an invalid +index behind. + +Every desired change must map to an executable statement or a blocking finding. +The plan cannot enter dry run while blocking findings exist. + +### 4. Dry run and live preflight + +Dry run never executes DDL on the live target, even inside a transaction. +Rollback would restore catalog state but would not undo the operational cost of +locks, scans, or table rewrites. + +The `forward_dry_run` job performs two bounded checks: + +1. **Disposable execution:** provision an isolated, short-lived PostgreSQL + database matching the target major-version capability contract, materialize + the complete operation-relevant dependency closure, execute the exact + compiled transactional plan, reverse-engineer the result, and require the + desired digest. The closure covers the editable table/column/PK/unique/FK, + simple-index, and comment subset plus every supported dependency required to + execute those operations. Affected views, triggers, checks/defaults, + partitions, domains/extensions, operator classes, RLS, grants, or other + dependencies that cannot be represented faithfully become blockers rather + than being omitted. The database is destroyed after bounded evidence is + persisted. +2. **Live read-only preflight:** re-introspect the target for the base digest and + run operation-specific bounded read queries. Examples include existing NULL + detection before `SET NOT NULL`, orphan detection before a foreign key, and + explicit conversion probes before a type change. A timeout classifies an + operation as unproven; it does not become success. Queries return only + bounded existence/count evidence, and row values are neither persisted nor + logged. + +Live preflight is review evidence, not a concurrency guarantee. Apply repeats +every data-aware precondition after acquiring the operation's complete child, +parent, and dependency table set at the compiler-declared lock modes. + +Deployment adds a dedicated migration-sandbox PostgreSQL service/credential +contract and isolated execution workload. Its runtime egress policy has no +route to production targets and it receives no target credentials; a separate +live-preflight worker retains only the required guarded target route. Production +operators can point the sandbox contract at an isolated cluster. The +application metadata database is never reused as a DDL sandbox. + +### 5. Drift and concurrency contract + +Create a canonical schema digest over all compiler-relevant objects and include +the PostgreSQL major version. The worker uses the same SSRF-guarded, +TLS-verified PostgreSQL connection path as reverse engineering. + +- On dry run, introspect the live target and compare its digest with the plan's + base digest before any preflight query. A mismatch records `drifted` with a + structural diff and performs no DDL. +- On apply, repeat that comparison for the immutable plan checksum. Prior + dry-run success is accepted only when its run references that same checksum + and observed base digest; enqueue-time compare-and-swap decides whether a + newer model revision has already superseded it. +- pg-erd-cloud writers are serialized with a deterministic target-database + advisory lock. Bounded PostgreSQL `lock_timeout` and `statement_timeout` + prevent indefinite blocking. External writers do not honor advisory locks; + the worker therefore starts the transaction, takes affected-object locks in + deterministic qualified-name order, and reruns schema and data-aware + preconditions on that same connection before executing. The selected lock + modes must prevent concurrent `INSERT`/`UPDATE` from invalidating NULL, + foreign-key, or conversion probes until commit. Conflicting external DDL/DML + must fail or block within the configured timeout and produce a redacted + non-success result. + +Apply executes the ordered transaction-capable statement plan in one +transaction and commits only if every statement and postcondition succeeds. +Non-transactional operations are blockers in this slice. + +### 6. Durable apply and verification + +Dry run and live apply both run as durable jobs. The API persists a +`migration_run` and queue record before external I/O, returns `202`, and the +frontend polls the run. This keeps long preflight, lock waits, disconnects, and +process restarts outside the HTTP request lifetime. + +Dry-run states are: + +`queued -> sandbox_running -> live_preflight_running -> passed | drifted | failed` + +Apply states are: + +`queued -> applying -> reconciling -> verifying -> verified | drifted_no_apply | not_applied | verification_failed | failed_rolled_back | applied_with_drift | outcome_unknown` + +Compare-and-swap transitions plus a unique idempotency key allow one winner for +concurrent submissions. Apply requires the explicitly referenced passed dry run +for the same immutable plan checksum and observed base digest, then repeats +schema and data-aware preconditions regardless. After apply begins, the job is +never automatically replayed because a process failure can make commit outcome +ambiguous. + +Terminal states distinguish whether live DDL ran: + +- `drifted_no_apply`: live base no longer matches; no DDL ran; +- `failed_rolled_back`: the live transaction rolled back; +- `not_applied`: reconciliation after a lost acknowledgement proves the exact + base digest still exists; +- `verification_failed`: commit succeeded but reverse verification could not + finish, so the UI must not describe the database as unchanged; +- `applied_with_drift`: commit succeeded and verification found a residual + diff; +- `outcome_unknown`: commit acknowledgement or reliable reconciliation evidence + is unavailable; the system forbids replay and makes no applied/not-applied + claim. + +After commit, the worker reverse-engineers the same connection and schema filter, +persists a normal `SchemaSnapshot`/`SchemaSnapshotData` verification record, and +compares its canonical digest with the desired digest. A matching digest marks +the run `verified`. Reconciliation after an uncertain post-commit failure first +introspects the target: desired digest becomes `verified`, exact base digest +becomes `not_applied`, and unavailable evidence or any third digest becomes +`outcome_unknown`. `applied_with_drift` is used only when commit success is +known and post-commit verification proves a residual diff. None of these paths +automatically replays DDL. + +### 7. API contract + +All model, plan, and run reads require project membership. Model creation, +revision, planning, and dry run require `editor`. Live apply requires the new +`deployer` capability, inherited by project owners; project role ordering is +`viewer < editor < deployer < owner`. Non-members receive the repository's +uniform not-found behavior. Mutations retain CSRF and credentialed-request +requirements. Planning also proves that the connection and succeeded base +snapshot belong to the same project and that the base snapshot was captured +from that exact connection. + +The legacy `apply-sql` request/response shape remains compatible, but +`dry_run=false` is tightened to the same `deployer` capability so it cannot +bypass the workflow's live-mutation boundary. Editors retain its default +rollback-only behavior. Project responses expose the current user's capability +so the frontend can gate controls without treating UI gating as authorization. + +- **Current:** `POST /api/schema-models/by-project/{project_space_uuid}` creates + a model from a succeeded snapshot or an explicit blank model. +- **Target compatibility shape:** `POST /api/projects/{project_uuid}/schema-models`. +- `GET /api/schema-models/{model_uuid}` + returns the model and current immutable revision. +- `PUT /api/schema-models/{model_uuid}` with `If-Match` + validates the edited model and creates a new revision; conflicts return `409`. +- `POST /api/schema-model-revisions/{revision_uuid}/migration-plans` + binds an exact connection/base snapshot and returns an immutable compiled + preview/checksum. +- **Planned:** `GET /api/migration-plans/{plan_uuid}` + returns structured operations, SQL preview, risk, blockers, and hashes. +- **Planned:** `POST /api/migration-plans/{plan_uuid}/dry-runs` + requires `Idempotency-Key` and the plan checksum, creates a `dry_run`, and + returns `202` with its run UUID. +- **Planned:** `POST /api/migration-plans/{plan_uuid}/apply-runs` + requires `Idempotency-Key`, plan checksum, matching passed dry-run UUID, + typed connection-name confirmation, and destructive acknowledgement when + applicable; it returns `202` with its run UUID. +- **Implemented:** `GET /api/migration-runs/{run_uuid}` with membership masking + and bounded event-history integrity verification + returns polling state and bounded evidence, including verification snapshot + and residual diff when terminal. + +Stale revisions/checksums return `409`. Invalid models return structured `422` +findings. Blocking compiler findings return a normal preview with +`can_dry_run=false`. Database diagnostics are classified and DSN-redacted. + +### 8. Frontend workflow + +Preserve the existing canvas, toolbar, modal language, spacing, typography, and +component patterns. This is a functional extension, not a redesign. + +Add one `DB 반영` action in the authenticated editable canvas. It opens an +accessible forward-engineering modal with progressive states: + +1. **변경안 저장:** name the model and save the current edited canvas as an + immutable revision. A saved model can be reopened through the + same graph-building primitives through a dedicated desired-schema adapter; + it does not manufacture database OIDs. +2. **변경 검토:** select the exact connection and succeeded base snapshot, + compile an immutable plan, and show target scope, base/target hashes, risk + counts, blocking findings, object-level changes, and read-only + server-generated SQL. +3. **Dry run:** execute against the disposable sandbox, then run live read-only + preflight; show each evidence source, redacted failure, or drift details. A + new model revision visibly supersedes the reviewed plan and its dry run. +4. **실제 적용:** require typing the exact connection name and reconfirm the + destructive/warning counts. A destructive plan also requires a separate + explicit acknowledgement. The action queues once and cannot be double + submitted. +5. **재검증:** poll the run and show the verification snapshot, exact-match + result, or residual drift. Distinguish rolled-back failure from + committed-but-unverified states. + +Editors can save, review, and dry-run. The live apply control requires the +project's server-reported `deployer` capability; insufficient capability is +explained in place and remains enforced by the API. + +Focus is trapped inside the modal; headings, risk summaries, errors, progress, +and terminal results have appropriate accessible names/live regions. Escape and +cancel work before apply is queued. Closing the modal never cancels an already +queued run. Demo mode allows preview-only behavior and clearly disables +live dry run/apply rather than simulating a successful production mutation. + +The existing `ExportModal` remains copy/download/share-only; live mutation is +kept in a separate `ForwardEngineeringModal`. Verification polling uses a +dedicated verification snapshot ID and never reuses the editor's loaded +snapshot state, so an in-progress check cannot overwrite unsaved canvas edits. + +## Error handling and operational safeguards + +- Bound plan/model size and statement count before persistence or target DB I/O. +- Store migration-sandbox credentials in the repository's credential-registry + boundary; do not add a new runtime `os.getenv()`/raw-environment secret path. +- Redact DSN-derived values at every worker/API boundary. +- Never log complete desired-schema JSON, raw SQL batches, or connection + secrets; log plan UUID, hash prefix, state, statement count, and durations. +- Use one active apply job per plan hash and reject duplicate queue requests. +- Persist actor, confirmation hash, and event timestamps for auditability. +- Expose metrics for plan outcomes and stage durations without high-cardinality + identifiers. +- Do not weaken the existing `apply-sql` validator, branch protections, + dependency scanning, or 100% production coverage contract. + +## Test strategy + +Implementation follows red-green-refactor in small vertical slices. + +### Backend unit and contract tests + +- canonicalization and digest stability across volatile OIDs/order, plus + sensitivity to every compiler-relevant mutation and PostgreSQL major version; +- desired-model validation for duplicates, invalid references, unsupported + expressions, identifiers, payload and statement bounds; +- literal expected SQL for each compiler operation, safe quoting, stable order, + risk linkage, and blocking-findings completeness; +- the mandatory contract that every generated executable plan is accepted by + the structured executor without browser-supplied SQL parsing; +- immutable model revision/plan checksums and supersession after every editable + input change; +- API role/IDOR/CSRF behavior, public-share mutation denial, revision/checksum + tampering, editor-versus-deployer capability, typed confirmation, duplicate + queue prevention, and redacted failures; +- job state transitions, sandbox lifecycle, live read-only preconditions, + timeouts, drift checks, rollback, commit, + post-commit verification, and retry-after-uncertain-commit recovery. + +### PostgreSQL integration tests + +Use an ephemeral PostgreSQL target to prove that: + +- dry run executes only in the sandbox and leaves the live target unchanged; +- sandbox execution reaches the desired digest and cleans up its disposable + database on success and failure; +- apply produces the desired schema and a matching verification snapshot; +- a concurrent base change blocks execution as drift; +- concurrent `INSERT`/`UPDATE` cannot invalidate apply-time data preconditions + after the compiler-declared locks are held; +- statement failure rolls back earlier statements; +- quoted/mixed-case/reserved identifiers represented by the model remain safe; +- foreign keys and indexes generated by the model execute under the same + structured contract; +- lock and statement timeouts terminate with a classified, redacted result; +- concurrent idempotent apply requests produce one winner; +- a model-save/apply-enqueue race either returns `409` before acceptance or + freezes the exact accepted plan; stale DDL never wins silently; +- crash-after-commit reconciliation verifies or reports drift without replay. + +### Frontend tests + +- graph-to-schema-model conversion preserves untouched base metadata and + reflects table, column, key, relationship, and index edits; +- model dirty/save state and `409` optimistic-concurrency recovery; +- typed API clients send CSRF-protected exact-hash requests and validate response + shapes; +- modal keyboard/focus behavior, loading, errors, drift, risk review, typed + confirmation, stale revision, double-submit prevention, polling, and all + terminal states; +- demo mode remains explicit preview-only behavior. + +### End-to-end acceptance + +Against the composed app and a disposable PostgreSQL target: reverse a schema, +edit the ERD, save a plan, review risk/SQL, dry-run, explicitly apply, wait for +reverse verification, and assert an empty residual diff. Repeat with injected +live drift and assert that no plan statement executes. + +## Acceptance criteria + +The feature is complete only when all of the following are true on the exact PR +head: + +1. An edited canvas can be persisted and reopened as a versioned schema model. +2. The server preview contains every supported change and blocks every + unsupported change without silent omission. +3. The UI cannot apply a stale, undry-run, drifted, or unconfirmed revision. +4. Dry run demonstrably executes in an isolated PostgreSQL sandbox while the + live target receives only bounded read-only preflight queries. +5. Apply is durable, single-queued, transactional, and retry-safe. +6. Post-apply reverse engineering persists evidence and reports exact match or + residual drift truthfully. +7. Existing exports remain backward compatible; the raw `apply-sql` payload is + unchanged and its live (`dry_run=false`) authorization is intentionally + tightened to `deployer`. +8. Backend mypy/pytest/coverage, frontend typecheck/tests/coverage/build, + security scans, and browser workflow verification pass on the exact head. + +## References to ground implementation + +Primary PostgreSQL contracts: + +- PostgreSQL Global Development Group. (n.d.). [Transactions](https://www.postgresql.org/docs/18/tutorial-transactions.html). +- PostgreSQL Global Development Group. (n.d.). [CREATE INDEX](https://www.postgresql.org/docs/18/sql-createindex.html). +- PostgreSQL Global Development Group. (n.d.). [ALTER TABLE](https://www.postgresql.org/docs/18/sql-altertable.html). +- PostgreSQL Global Development Group. (n.d.). [Explicit locking](https://www.postgresql.org/docs/18/explicit-locking.html). +- PostgreSQL Global Development Group. (n.d.). [Client connection defaults](https://www.postgresql.org/docs/18/runtime-config-client.html). +- PostgreSQL Global Development Group. (n.d.). [System information functions and decompiled definitions](https://www.postgresql.org/docs/18/functions-info.html). +- PostgreSQL Global Development Group. (n.d.). [`pg_index`](https://www.postgresql.org/docs/18/catalog-pg-index.html). + +Schema-evolution research: + +- Eckwert, T., Guckert, M., & Taentzer, G. (2025). EvolveDB: Evolving + relational database schemas in a model-driven way. *Software and Systems + Modeling*. [https://doi.org/10.1007/s10270-025-01341-x](https://doi.org/10.1007/s10270-025-01341-x) +- Curino, C. A., Moon, H. J., & Zaniolo, C. (2008). Graceful database schema + evolution: The PRISM workbench. *Proceedings of the VLDB Endowment, 1*(1), + 761-772. [https://doi.org/10.14778/1453856.1453939](https://doi.org/10.14778/1453856.1453939) +- Rae, I., Rollins, E., Shute, J., Sodhi, S., & Vingralek, R. (2013). Online, + asynchronous schema change in F1. *Proceedings of the VLDB Endowment, 6*(11), + 1045-1056. [https://doi.org/10.14778/2536222.2536230](https://doi.org/10.14778/2536222.2536230) +- Hu, T., Wang, T., & Zhou, Q. (2022). Online schema evolution is (almost) free + for snapshot databases. *Proceedings of the VLDB Endowment, 16*(2), 140-153. + [https://doi.org/10.14778/3565816.3565818](https://doi.org/10.14778/3565816.3565818) + +PRISM, F1, and the Hu et al. paper are link-only because their publication +terms do not support commercial-repository redistribution without additional +permission. EvolveDB is CC BY 4.0, but the implementation PR will prefer a DOI +link and relevance summary unless its PDF attribution and third-party material +can be verified completely. diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index c78944c31..728a0bf93 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] ### Added +- **Forward Engineering Apply 의도**: 통과한 exact-digest dry-run 증거와 대상 연결 + 이름을 묶어 실행 권한 없이 apply 의도만 등록하고, 모호한 응답은 같은 + idempotency key로 재확인하거나 명시적인 새 등록으로 편집을 다시 시작할 수 + 있도록 추가했습니다. - **테이블 및 컬럼 편집 기능**: UI 패널을 통해 노드를 선택하고, 테이블의 이름/코멘트를 수정하며, 컬럼을 추가/수정/삭제하거나 테이블을 삭제할 수 있는 기능 추가. - **테스트 추가**: 프론트엔드 테스트 커버리지 100% 목표 달성을 위해 `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 분기 및 함수 테스트 추가 (`cardinality_extra.test.ts` 등). - `.gitignore` 파일에 `coverage/` 폴더를 추가하여 불필요한 테스트 아티팩트가 커밋되지 않도록 보완. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2049d498a..451c5addd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1970,9 +1970,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/frontend/package.json b/frontend/package.json index db0eacb61..ada824372 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ }, "overrides": { "esbuild": "^0.25.0", + "nanoid": "^3.3.18", "postcss": "^8.5.18" } } diff --git a/frontend/src/App.coverage.test.tsx b/frontend/src/App.coverage.test.tsx index 0b9a20aa8..48332036c 100644 --- a/frontend/src/App.coverage.test.tsx +++ b/frontend/src/App.coverage.test.tsx @@ -325,6 +325,7 @@ describe('App orchestration coverage', () => { expect(screen.getByRole('heading', { name: '프로젝트' })).toBeInTheDocument() fireEvent.click(screen.getAllByRole('button', { name: '열기' })[1]!) expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument() + await screen.findByText('ERD_all_2') fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'no-match' } }) expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'failed' } }) @@ -610,8 +611,9 @@ describe('App orchestration coverage', () => { it('logs auto-layout failures and preserves nodes added after the undo snapshot', async () => { await renderReadyApp() fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + const openButtons = await screen.findAllByRole('button', { name: '열기' }) vi.useFakeTimers() - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) + fireEvent.click(openButtons[0]!) await act(async () => { vi.advanceTimersByTime(1000) await Promise.resolve() @@ -663,6 +665,10 @@ describe('App orchestration coverage', () => { await renderReadyApp() fireEvent.click(screen.getByRole('button', { name: '편집기' })) fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } }) + await waitFor(() => { + expect(api.listConnections).toHaveBeenCalledWith('p2') + expect(api.listSnapshots).toHaveBeenCalledWith('p2') + }) await act(async () => { rejectConnections(new Error('stale connections')) rejectSnapshots(new Error('stale snapshots')) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fd4660382..ba5bb4716 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,17 @@ import { snapshotDetailFromResponse } from './types' -import type { Connection, Project, ShareLink, Snapshot, SnapshotDetail, SnapshotDetailResponse, SnapshotJson } from './types' +import type { + Connection, + MigrationApplyIntent, + MigrationPlan, + MigrationRun, + MigrationRunAction, + Project, + ShareLink, + Snapshot, + SnapshotDetail, + SnapshotDetailResponse, + SnapshotJson, +} from './types' // Default to same-origin in production; set VITE_API_BASE_URL for dev. const API_BASE: string = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '' @@ -259,3 +271,75 @@ export async function getSnapshot(snapshotId: string): Promise { const response = (await r.json()) as SnapshotDetailResponse return snapshotDetailFromResponse(response) } + +export async function getMigrationPlan(planId: string): Promise { + const r = await fetch(`${API_BASE}/api/migration-plans/${encodeURIComponent(planId)}`, { + credentials: 'include', + }) + if (!r.ok) throw new Error(`getMigrationPlan failed: ${r.status}`) + return r.json() +} + +export async function createDryRun( + planId: string, + planDigest: string, + idempotencyKey: string, +): Promise { + const r = await fetch(`${API_BASE}/api/migration-plans/${encodeURIComponent(planId)}/dry-runs`, { + method: 'POST', + credentials: 'include', + headers: { + ...(await jsonHeaders()), + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ plan_digest: planDigest }), + }) + if (!r.ok) throw new Error(`createDryRun failed: ${r.status}`) + return r.json() +} + +export async function createApplyRun( + planId: string, + intent: MigrationApplyIntent, + idempotencyKey: string, +): Promise { + const requestBody: MigrationApplyIntent = { + plan_digest: intent.plan_digest, + passed_dry_run_uuid: intent.passed_dry_run_uuid, + target_connection_name: intent.target_connection_name, + destructive_acknowledged: intent.destructive_acknowledged, + } + const r = await fetch(`${API_BASE}/api/migration-plans/${encodeURIComponent(planId)}/apply-runs`, { + method: 'POST', + credentials: 'include', + headers: { + ...(await jsonHeaders()), + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(requestBody), + }) + if (!r.ok) throw new Error(`createApplyRun failed: ${r.status}`) + return r.json() +} + +export async function getMigrationRun(runId: string): Promise { + const r = await fetch(`${API_BASE}/api/migration-runs/${encodeURIComponent(runId)}`, { + credentials: 'include', + }) + if (!r.ok) throw new Error(`getMigrationRun failed: ${r.status}`) + return r.json() +} + +export async function cancelMigrationRun( + runId: string, + expectedStateVersion: number, +): Promise { + const r = await fetch(`${API_BASE}/api/migration-runs/${encodeURIComponent(runId)}/cancel`, { + method: 'POST', + credentials: 'include', + headers: await jsonHeaders(), + body: JSON.stringify({ expected_state_version: expectedStateVersion }), + }) + if (!r.ok) throw new Error(`cancelMigrationRun failed: ${r.status}`) + return r.json() +} diff --git a/frontend/src/components/forward/ApplyIntentPanel.test.tsx b/frontend/src/components/forward/ApplyIntentPanel.test.tsx new file mode 100644 index 000000000..f181ab8cf --- /dev/null +++ b/frontend/src/components/forward/ApplyIntentPanel.test.tsx @@ -0,0 +1,397 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MigrationPlan, MigrationRun } from '../../types' +import { ApplyIntentPanel } from './ApplyIntentPanel' + +function response(payload: unknown, ok = true, status = ok ? 200 : 500): Response { + return { + ok, + status, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, reject, resolve } +} + +const plan: MigrationPlan = { + migration_plan_uuid: 'plan-1', + project_space_uuid: 'project-1', + schema_model_revision_uuid: 'revision-1', + db_connection_uuid: 'connection-1', + base_schema_snapshot_uuid: 'snapshot-1', + plan_digest: 'a'.repeat(64), + base_digest: 'b'.repeat(64), + target_digest: 'c'.repeat(64), + compiler_version: 'pg-plan-v1', + snapshot_contract_version: 1, + postgresql_major: 16, + created_by_user_uuid: 'user-1', + created_at: '2026-08-12T05:00:00Z', + can_dry_run: true, + requires_destructive_confirmation: false, + statements: [], + proposed_statements: [], + blockers: [], + risk_summary: { safe: 0, warning: 0, destructive: 0 }, + expires_at: '2026-08-13T05:00:00Z', +} + +const passedRun: MigrationRun = { + migration_run_uuid: 'dry-run-1', + project_space_uuid: 'project-1', + migration_plan_uuid: 'plan-1', + run_kind: 'dry_run', + state: 'passed', + state_version: 4, + plan_digest: 'a'.repeat(64), + requested_by_user_uuid: 'user-1', + cancellation_requested: false, + observed_base_digest: 'b'.repeat(64), + evidence: {}, + error_code: null, + created_at: '2026-08-12T05:00:00Z', + updated_at: '2026-08-12T05:01:00Z', + started_at: '2026-08-12T05:00:10Z', + finished_at: '2026-08-12T05:01:00Z', + events: [], +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + vi.stubGlobal('crypto', { randomUUID: vi.fn(() => 'request-uuid') }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('ApplyIntentPanel', () => { + it('submits only exact reviewed evidence and an explicitly typed target name', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'apply-intent-1', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + const onRunCreated = vi.fn() + + render() + + const submit = screen.getByRole('button', { name: '비실행 apply 의도 등록' }) + expect(submit).toBeDisabled() + fireEvent.change(screen.getByLabelText('대상 연결 이름 확인'), { + target: { value: 'production-primary' }, + }) + expect(submit).toBeEnabled() + fireEvent.click(submit) + + await waitFor(() => expect(onRunCreated).toHaveBeenCalledWith('apply-intent-1')) + expect(fetch).toHaveBeenNthCalledWith(2, '/api/migration-plans/plan-1/apply-runs', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'csrf', + 'Idempotency-Key': 'web-apply-intent-request-uuid', + }, + body: JSON.stringify({ + plan_digest: plan.plan_digest, + passed_dry_run_uuid: passedRun.migration_run_uuid, + target_connection_name: 'production-primary', + destructive_acknowledged: false, + }), + }) + expect(String(vi.mocked(fetch).mock.calls[1]?.[1]?.body)).not.toContain('sql') + expect(screen.getByRole('status')).toHaveTextContent('apply-intent-1') + }) + + it('trims the target name and rejects a whitespace-only confirmation', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'apply-intent-trimmed', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + + render() + const input = screen.getByLabelText('대상 연결 이름 확인') + const submit = screen.getByRole('button', { name: '비실행 apply 의도 등록' }) + fireEvent.change(input, { target: { value: ' ' } }) + expect(submit).toBeDisabled() + + fireEvent.change(input, { target: { value: ' production-primary ' } }) + fireEvent.click(submit) + + await screen.findByRole('status') + expect(vi.mocked(fetch).mock.calls[1]?.[1]?.body).toContain( + '"target_connection_name":"production-primary"', + ) + }) + + it('requires an explicit destructive acknowledgement only for destructive plans', () => { + render( + , + ) + + fireEvent.change(screen.getByLabelText('대상 연결 이름 확인'), { + target: { value: 'production-primary' }, + }) + const submit = screen.getByRole('button', { name: '비실행 apply 의도 등록' }) + expect(submit).toBeDisabled() + fireEvent.click(screen.getByRole('checkbox', { name: '파괴적 변경을 검토하고 확인했습니다.' })) + expect(submit).toBeEnabled() + }) + + it('keeps direct form submission single-flight and complete for destructive plans', async () => { + const csrf = deferred() + const creation = deferred() + vi.mocked(fetch) + .mockReturnValueOnce(csrf.promise) + .mockReturnValueOnce(creation.promise) + const onRunCreated = vi.fn() + render( + , + ) + const form = screen.getByRole('button', { name: '비실행 apply 의도 등록' }).closest('form') + if (!form) throw new Error('apply intent form missing') + + fireEvent.submit(form) + expect(fetch).not.toHaveBeenCalled() + fireEvent.change(screen.getByLabelText('대상 연결 이름 확인'), { + target: { value: 'production-primary' }, + }) + fireEvent.submit(form) + expect(fetch).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('checkbox', { name: '파괴적 변경을 검토하고 확인했습니다.' })) + fireEvent.submit(form) + fireEvent.submit(form) + expect(fetch).toHaveBeenCalledTimes(1) + + csrf.resolve(response({ csrf_token: 'csrf' })) + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + creation.resolve(response({ + migration_run_uuid: 'destructive-intent', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + await waitFor(() => expect(onRunCreated).toHaveBeenCalledWith('destructive-intent')) + expect(vi.mocked(fetch).mock.calls[1]?.[1]?.body).toContain( + '"destructive_acknowledged":true', + ) + }) + + it.each([ + ['non-passed state', { state: 'queued' }], + ['wrong run kind', { run_kind: 'apply' }], + ['different plan', { migration_plan_uuid: 'plan-other' }], + ['different digest', { plan_digest: 'd'.repeat(64) }], + ['different observed base', { observed_base_digest: 'e'.repeat(64) }], + ])('fails closed for %s', (_label, override) => { + render( + , + ) + + expect(screen.getByText('apply 의도를 등록할 수 없습니다.')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(fetch).not.toHaveBeenCalled() + }) + + it('retries an ambiguous response with the same bounded idempotency key', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf-1' })) + .mockResolvedValueOnce(response({}, false, 503)) + .mockResolvedValueOnce(response({ csrf_token: 'csrf-2' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'apply-intent-reused', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: true, + }, true, 202)) + + render() + fireEvent.change(screen.getByLabelText('대상 연결 이름 확인'), { + target: { value: 'production-primary' }, + }) + fireEvent.click(screen.getByRole('button', { name: '비실행 apply 의도 등록' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('등록 결과를 확인하지 못했습니다') + expect(alert).not.toHaveTextContent(/503|secret/) + expect(screen.getByLabelText('대상 연결 이름 확인')).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: '같은 등록 다시 시도' })) + + expect(await screen.findByRole('status')).toHaveTextContent('기존 등록을 재사용했습니다') + const firstKey = (vi.mocked(fetch).mock.calls[1]?.[1]?.headers as Record)[ + 'Idempotency-Key' + ] + const retryKey = (vi.mocked(fetch).mock.calls[3]?.[1]?.headers as Record)[ + 'Idempotency-Key' + ] + expect(firstKey).toBe('web-apply-intent-request-uuid') + expect(retryKey).toBe(firstKey) + expect(vi.mocked(fetch).mock.calls[3]?.[1]?.body) + .toBe(vi.mocked(fetch).mock.calls[1]?.[1]?.body) + }) + + it('unlocks an ambiguous intent only through an explicit new registration', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf-1' })) + .mockResolvedValueOnce(response({}, false, 503)) + .mockResolvedValueOnce(response({ csrf_token: 'csrf-2' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'apply-intent-new', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + vi.mocked(crypto.randomUUID) + .mockReturnValueOnce('00000000-0000-4000-8000-000000000001') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000002') + + render() + const input = screen.getByLabelText('대상 연결 이름 확인') + fireEvent.change(input, { target: { value: 'production-primary' } }) + fireEvent.click(screen.getByRole('button', { name: '비실행 apply 의도 등록' })) + + await screen.findByRole('alert') + expect(input).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: '새 등록 시작' })) + expect(input).toBeEnabled() + fireEvent.change(input, { target: { value: 'production-secondary' } }) + fireEvent.click(screen.getByRole('button', { name: '비실행 apply 의도 등록' })) + + await screen.findByRole('status') + const firstHeaders = vi.mocked(fetch).mock.calls[1]?.[1]?.headers as Record + const secondHeaders = vi.mocked(fetch).mock.calls[3]?.[1]?.headers as Record + expect(firstHeaders['Idempotency-Key']) + .toBe('web-apply-intent-00000000-0000-4000-8000-000000000001') + expect(secondHeaders['Idempotency-Key']) + .toBe('web-apply-intent-00000000-0000-4000-8000-000000000002') + expect(vi.mocked(fetch).mock.calls[3]?.[1]?.body).toContain('production-secondary') + }) + + it('ignores an obsolete accepted response after the reviewed identities change', async () => { + const accepted = deferred() + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockReturnValueOnce(accepted.promise) + const onRunCreated = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByLabelText('대상 연결 이름 확인'), { + target: { value: 'production-primary' }, + }) + fireEvent.click(screen.getByRole('button', { name: '비실행 apply 의도 등록' })) + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + rerender( + , + ) + accepted.resolve(response({ + migration_run_uuid: 'obsolete-apply-intent', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + + await accepted.promise + await waitFor(() => expect(onRunCreated).not.toHaveBeenCalled()) + expect(screen.queryByText('obsolete-apply-intent')).not.toBeInTheDocument() + }) + + it('ignores an obsolete failure after the reviewed identities change', async () => { + const accepted = deferred() + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockReturnValueOnce(accepted.promise) + const { rerender } = render( + , + ) + + fireEvent.change(screen.getByLabelText('대상 연결 이름 확인'), { + target: { value: 'production-primary' }, + }) + fireEvent.click(screen.getByRole('button', { name: '비실행 apply 의도 등록' })) + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + rerender( + , + ) + accepted.reject(new Error('obsolete failure with secret')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '비실행 apply 의도 등록' })).toBeDisabled() + }) + }) + + it('fails closed when the browser cannot generate an apply intent identity', async () => { + vi.stubGlobal('crypto', { + randomUUID: vi.fn(() => { + throw new Error('browser diagnostic with secret') + }), + }) + render() + fireEvent.change(screen.getByLabelText('대상 연결 이름 확인'), { + target: { value: 'production-primary' }, + }) + fireEvent.click(screen.getByRole('button', { name: '비실행 apply 의도 등록' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('등록 결과를 확인하지 못했습니다') + expect(alert).not.toHaveTextContent('secret') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/forward/ApplyIntentPanel.tsx b/frontend/src/components/forward/ApplyIntentPanel.tsx new file mode 100644 index 000000000..11dbd82d0 --- /dev/null +++ b/frontend/src/components/forward/ApplyIntentPanel.tsx @@ -0,0 +1,189 @@ +import { type FormEvent, useEffect, useRef, useState } from 'react' + +import { createApplyRun } from '../../api' +import type { MigrationPlan, MigrationRun, MigrationRunAction } from '../../types' + +type ApplyIntentPanelProps = { + plan: MigrationPlan + passedDryRun: MigrationRun + onRunCreated: (runId: string) => void +} + +type RequestState = + | { status: 'idle' } + | { status: 'requesting' } + | { status: 'error' } + | { status: 'created'; action: MigrationRunAction } + +function isExactPassedDryRun(plan: MigrationPlan, run: MigrationRun): boolean { + return run.run_kind === 'dry_run' + && run.state === 'passed' + && run.migration_plan_uuid === plan.migration_plan_uuid + && run.plan_digest === plan.plan_digest + && run.observed_base_digest === plan.base_digest +} + +export function ApplyIntentPanel({ + plan, + passedDryRun, + onRunCreated, +}: ApplyIntentPanelProps) { + const [targetConnectionName, setTargetConnectionName] = useState('') + const [destructiveAcknowledged, setDestructiveAcknowledged] = useState(false) + const [requestState, setRequestState] = useState({ status: 'idle' }) + const requestKeyRef = useRef(null) + const submittedTargetNameRef = useRef(null) + const submittedAcknowledgementRef = useRef(null) + const inFlightRef = useRef(false) + const generationRef = useRef(0) + + useEffect(() => { + generationRef.current += 1 + requestKeyRef.current = null + submittedTargetNameRef.current = null + submittedAcknowledgementRef.current = null + inFlightRef.current = false + setTargetConnectionName('') + setDestructiveAcknowledged(false) + setRequestState({ status: 'idle' }) + + return () => { + generationRef.current += 1 + inFlightRef.current = false + } + }, [ + passedDryRun.migration_run_uuid, + passedDryRun.state_version, + plan.migration_plan_uuid, + plan.plan_digest, + ]) + + if (!isExactPassedDryRun(plan, passedDryRun)) { + return ( +
+

Apply 검토

+

apply 의도를 등록할 수 없습니다.

+
+ ) + } + + const submit = async () => { + const trimmedTargetConnectionName = targetConnectionName.trim() + if ( + inFlightRef.current + || trimmedTargetConnectionName.length === 0 + || (plan.requires_destructive_confirmation && !destructiveAcknowledged) + ) return + + inFlightRef.current = true + const generation = generationRef.current + setRequestState({ status: 'requesting' }) + + try { + const requestKey = requestKeyRef.current + ?? `web-apply-intent-${globalThis.crypto.randomUUID()}` + requestKeyRef.current = requestKey + submittedTargetNameRef.current ??= trimmedTargetConnectionName + submittedAcknowledgementRef.current ??= plan.requires_destructive_confirmation + ? destructiveAcknowledged + : false + const action = await createApplyRun( + plan.migration_plan_uuid, + { + plan_digest: plan.plan_digest, + passed_dry_run_uuid: passedDryRun.migration_run_uuid, + target_connection_name: submittedTargetNameRef.current, + destructive_acknowledged: submittedAcknowledgementRef.current, + }, + requestKey, + ) + if (generation !== generationRef.current) return + setRequestState({ status: 'created', action }) + onRunCreated(action.migration_run_uuid) + } catch { + if (generation === generationRef.current) setRequestState({ status: 'error' }) + } finally { + if (generation === generationRef.current) inFlightRef.current = false + } + } + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + void submit() + } + + const startNewRegistration = () => { + generationRef.current += 1 + requestKeyRef.current = null + submittedTargetNameRef.current = null + submittedAcknowledgementRef.current = null + inFlightRef.current = false + setRequestState({ status: 'idle' }) + } + + const submitDisabled = requestState.status === 'requesting' + || targetConnectionName.trim().length === 0 + || (plan.requires_destructive_confirmation && !destructiveAcknowledged) + const confirmationLocked = requestKeyRef.current !== null + + return ( +
+

Apply 검토

+

+ 이 단계는 검토 증거와 대상 이름을 서버에 묶어 비실행 apply 의도만 등록합니다. + 실제 DDL을 디스패치하거나 실행하지 않습니다. +

+
+ + {plan.requires_destructive_confirmation ? ( + + ) : null} + + {requestState.status === 'created' ? ( +

+ {requestState.action.reused ? '기존 등록을 재사용했습니다.' : '의도를 등록했습니다.'} + {' '}실행 {requestState.action.migration_run_uuid} +

+ ) : null} + + {requestState.status === 'error' ? ( +
+

등록 결과를 확인하지 못했습니다. 같은 등록을 안전하게 다시 확인할 수 있습니다.

+ + +
+ ) : null} + + {requestState.status === 'idle' || requestState.status === 'requesting' ? ( + + ) : null} +
+
+ ) +} diff --git a/frontend/src/components/forward/DryRunIntentPanel.test.tsx b/frontend/src/components/forward/DryRunIntentPanel.test.tsx new file mode 100644 index 000000000..7bb404207 --- /dev/null +++ b/frontend/src/components/forward/DryRunIntentPanel.test.tsx @@ -0,0 +1,231 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MigrationPlan } from '../../types' +import { DryRunIntentPanel } from './DryRunIntentPanel' + +function response(payload: unknown, ok = true, status = ok ? 200 : 500): Response { + return { + ok, + status, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +const plan: MigrationPlan = { + migration_plan_uuid: 'plan-1', + project_space_uuid: 'project-1', + schema_model_revision_uuid: 'revision-1', + db_connection_uuid: 'connection-1', + base_schema_snapshot_uuid: 'snapshot-1', + plan_digest: 'a'.repeat(64), + base_digest: 'b'.repeat(64), + target_digest: 'c'.repeat(64), + compiler_version: 'pg-plan-v1', + snapshot_contract_version: 1, + postgresql_major: 16, + created_by_user_uuid: 'user-1', + created_at: '2026-08-12T05:00:00Z', + can_dry_run: true, + requires_destructive_confirmation: false, + statements: [], + proposed_statements: [], + blockers: [], + risk_summary: { safe: 0, warning: 0, destructive: 0 }, + expires_at: '2026-08-13T05:00:00Z', +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + vi.stubGlobal('crypto', { randomUUID: vi.fn(() => 'request-uuid') }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('DryRunIntentPanel', () => { + it('submits only the server plan identity and exact digest, then returns the durable run', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'run-1', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + const onRunCreated = vi.fn() + + render() + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + + await waitFor(() => expect(onRunCreated).toHaveBeenCalledWith('run-1')) + expect(fetch).toHaveBeenNthCalledWith(2, '/api/migration-plans/plan-1/dry-runs', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'csrf', + 'Idempotency-Key': 'web-dry-run-request-uuid', + }, + body: JSON.stringify({ plan_digest: plan.plan_digest }), + }) + expect(String(vi.mocked(fetch).mock.calls[1]?.[1]?.body)).not.toContain('sql') + expect(screen.getByRole('status')).toHaveTextContent('run-1') + }) + + it('allows only one in-flight request when the action is activated repeatedly', async () => { + const csrf = deferred() + const creation = deferred() + vi.mocked(fetch) + .mockReturnValueOnce(csrf.promise) + .mockReturnValueOnce(creation.promise) + const onRunCreated = vi.fn() + + render() + const button = screen.getByRole('button', { name: '격리 dry-run 요청' }) + fireEvent.click(button) + fireEvent.click(button) + + expect(button).toBeDisabled() + expect(fetch).toHaveBeenCalledTimes(1) + csrf.resolve(response({ csrf_token: 'csrf' })) + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(fetch).toHaveBeenCalledTimes(2) + + creation.resolve(response({ + migration_run_uuid: 'run-single-flight', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + await waitFor(() => expect(onRunCreated).toHaveBeenCalledWith('run-single-flight')) + }) + + it('retries an ambiguous failure with the same bounded idempotency key', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf-1' })) + .mockResolvedValueOnce(response({}, false, 503)) + .mockResolvedValueOnce(response({ csrf_token: 'csrf-2' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'run-reused', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: true, + }, true, 202)) + + render() + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('요청 결과를 확인하지 못했습니다') + expect(alert).not.toHaveTextContent(/503|createDryRun|secret/) + fireEvent.click(screen.getByRole('button', { name: '같은 요청 다시 시도' })) + + expect(await screen.findByRole('status')).toHaveTextContent('기존 요청을 재사용했습니다') + const firstKey = (vi.mocked(fetch).mock.calls[1]?.[1]?.headers as Record)[ + 'Idempotency-Key' + ] + const retryKey = (vi.mocked(fetch).mock.calls[3]?.[1]?.headers as Record)[ + 'Idempotency-Key' + ] + expect(firstKey).toBe('web-dry-run-request-uuid') + expect(retryKey).toBe(firstKey) + }) + + it('does not expose an action when the server marks the plan as blocked', () => { + render( + , + ) + + expect(screen.getByText('격리 dry-run을 요청할 수 없습니다.')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(fetch).not.toHaveBeenCalled() + }) + + it('ignores an obsolete accepted response after the reviewed plan changes', async () => { + const accepted = deferred() + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockReturnValueOnce(accepted.promise) + const onRunCreated = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + rerender( + , + ) + accepted.resolve(response({ + migration_run_uuid: 'obsolete-run', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + + await accepted.promise + await waitFor(() => { + expect(onRunCreated).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: '격리 dry-run 요청' })).toBeEnabled() + }) + expect(screen.queryByText('obsolete-run')).not.toBeInTheDocument() + }) + + it('fails closed when a browser cannot generate a request identity', async () => { + vi.stubGlobal('crypto', { + randomUUID: vi.fn(() => { + throw new Error('browser diagnostic with secret') + }), + }) + + render() + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('요청 결과를 확인하지 못했습니다') + expect(alert).not.toHaveTextContent('secret') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/forward/DryRunIntentPanel.tsx b/frontend/src/components/forward/DryRunIntentPanel.tsx new file mode 100644 index 000000000..6e73b1bce --- /dev/null +++ b/frontend/src/components/forward/DryRunIntentPanel.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react' + +import { createDryRun } from '../../api' +import type { MigrationPlan, MigrationRunAction } from '../../types' + +type DryRunIntentPanelProps = { + plan: MigrationPlan + onRunCreated: (runId: string) => void +} + +type RequestState = + | { status: 'idle' } + | { status: 'requesting' } + | { status: 'error' } + | { status: 'created'; action: MigrationRunAction } + +export function DryRunIntentPanel({ plan, onRunCreated }: DryRunIntentPanelProps) { + const [requestState, setRequestState] = useState({ status: 'idle' }) + const requestKeyRef = useRef(null) + const inFlightRef = useRef(false) + const generationRef = useRef(0) + + useEffect(() => { + generationRef.current += 1 + requestKeyRef.current = null + inFlightRef.current = false + setRequestState({ status: 'idle' }) + + return () => { + generationRef.current += 1 + inFlightRef.current = false + } + }, [plan.migration_plan_uuid, plan.plan_digest]) + + const submit = async () => { + if (inFlightRef.current) return + inFlightRef.current = true + const generation = generationRef.current + setRequestState({ status: 'requesting' }) + + try { + const requestKey = requestKeyRef.current + ?? `web-dry-run-${globalThis.crypto.randomUUID()}` + requestKeyRef.current = requestKey + const action = await createDryRun( + plan.migration_plan_uuid, + plan.plan_digest, + requestKey, + ) + if (generation !== generationRef.current) return + setRequestState({ status: 'created', action }) + onRunCreated(action.migration_run_uuid) + } catch { + if (generation === generationRef.current) setRequestState({ status: 'error' }) + } finally { + if (generation === generationRef.current) inFlightRef.current = false + } + } + + if (!plan.can_dry_run || plan.blockers.length > 0) { + return ( +
+

격리 dry-run

+

격리 dry-run을 요청할 수 없습니다.

+
+ ) + } + + return ( +
+

격리 dry-run

+

+ 이 작업은 검토한 계획 다이제스트로 서버에 실행 의도만 등록합니다. + 브라우저는 SQL이나 대상 연결 정보를 보내지 않습니다. +

+ + {requestState.status === 'created' ? ( +

+ {requestState.action.reused ? '기존 요청을 재사용했습니다.' : '요청을 접수했습니다.'} + {' '}실행 {requestState.action.migration_run_uuid} +

+ ) : null} + + {requestState.status === 'error' ? ( +
+

요청 결과를 확인하지 못했습니다. 같은 요청을 안전하게 다시 확인할 수 있습니다.

+ +
+ ) : null} + + {requestState.status === 'idle' || requestState.status === 'requesting' ? ( + + ) : null} +
+ ) +} diff --git a/frontend/src/components/forward/ForwardEngineeringModal.test.tsx b/frontend/src/components/forward/ForwardEngineeringModal.test.tsx new file mode 100644 index 000000000..e8353e21a --- /dev/null +++ b/frontend/src/components/forward/ForwardEngineeringModal.test.tsx @@ -0,0 +1,366 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ForwardEngineeringModal } from './index' + +function response(payload: unknown): Response { + return { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response +} + +const plan = { + migration_plan_uuid: 'plan-modal', + project_space_uuid: 'project-1', + schema_model_revision_uuid: 'revision-1', + db_connection_uuid: 'connection-1', + base_schema_snapshot_uuid: 'snapshot-1', + plan_digest: 'a'.repeat(64), + base_digest: 'b'.repeat(64), + target_digest: 'c'.repeat(64), + compiler_version: 'pg-plan-v1', + snapshot_contract_version: 1, + postgresql_major: 16, + created_by_user_uuid: 'user-1', + created_at: '2026-08-12T05:00:00Z', + can_dry_run: true, + requires_destructive_confirmation: false, + statements: [], + proposed_statements: [], + blockers: [], + risk_summary: { safe: 0, warning: 0, destructive: 0 }, + expires_at: '2026-08-13T05:00:00Z', +} + +const run = { + migration_run_uuid: 'run-modal', + project_space_uuid: 'project-1', + migration_plan_uuid: 'plan-modal', + run_kind: 'dry_run', + state: 'queued', + state_version: 1, + plan_digest: 'a'.repeat(64), + requested_by_user_uuid: 'user-1', + cancellation_requested: false, + observed_base_digest: null, + evidence: {}, + error_code: null, + created_at: '2026-08-12T05:00:00Z', + updated_at: '2026-08-12T05:00:00Z', + started_at: null, + finished_at: null, + events: [], +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response(plan))) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('ForwardEngineeringModal', () => { + it('does not render while closed', () => { + render( + , + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(fetch).not.toHaveBeenCalled() + }) + + it('opens a dedicated labelled dialog containing the exact plan review', async () => { + render( + , + ) + + const dialog = screen.getByRole('dialog', { name: 'Forward Engineering' }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(await screen.findByText('plan-modal')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '격리 dry-run 요청' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /apply|적용/i })).not.toBeInTheDocument() + }) + + it('shows an exact read-only run audit surface when a run identity is supplied', async () => { + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + return Promise.resolve(response(url.includes('/migration-runs/') ? run : plan)) + }) + + render( + , + ) + + expect(await screen.findByText('run-modal')).toBeInTheDocument() + expect(screen.getByRole('status', { name: '마이그레이션 실행 상태' })).toHaveTextContent( + '대기 중', + ) + expect(screen.getByRole('button', { name: '격리 dry-run 요청' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /apply|적용/i })).not.toBeInTheDocument() + }) + + it('replaces the supplied audit surface when a new dry run is accepted', async () => { + const createdRun = { + ...run, + migration_run_uuid: 'run-created', + state: 'passed', + state_version: 4, + observed_base_digest: 'b'.repeat(64), + finished_at: '2026-08-12T05:01:00Z', + updated_at: '2026-08-12T05:01:00Z', + } + vi.mocked(fetch).mockImplementation((input, init) => { + const url = String(input) + if (url === '/api/csrf-token') return Promise.resolve(response({ csrf_token: 'csrf' })) + if (url.endsWith('/dry-runs') && init?.method === 'POST') { + return Promise.resolve(response({ + migration_run_uuid: 'run-created', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + })) + } + if (url.endsWith('/migration-runs/run-created')) { + return Promise.resolve(response(createdRun)) + } + if (url.endsWith('/migration-runs/run-modal')) return Promise.resolve(response(run)) + return Promise.resolve(response(plan)) + }) + + render( + , + ) + + await screen.findByText('run-modal') + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + + expect(await screen.findByText('run-created')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: '비실행 apply 의도 등록' })) + .toBeDisabled() + await waitFor(() => expect(screen.queryByText('run-modal')).not.toBeInTheDocument()) + expect(screen.getAllByRole('status', { name: '마이그레이션 실행 상태' })) + .toHaveLength(1) + }) + + it('preserves exact passed dry-run evidence after creating an apply intent', async () => { + const passedRun = { + ...run, + state: 'passed', + state_version: 4, + observed_base_digest: 'b'.repeat(64), + } + const applyRun = { + ...run, + migration_run_uuid: 'apply-intent-modal', + run_kind: 'apply', + state: 'queued', + } + vi.mocked(fetch).mockImplementation((input, init) => { + const url = String(input) + if (url === '/api/csrf-token') return Promise.resolve(response({ csrf_token: 'csrf' })) + if (url.endsWith('/apply-runs') && init?.method === 'POST') { + return Promise.resolve(response({ + migration_run_uuid: 'apply-intent-modal', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + })) + } + if (url.endsWith('/migration-runs/run-modal')) return Promise.resolve(response(passedRun)) + if (url.endsWith('/migration-runs/apply-intent-modal')) { + return Promise.resolve(response(applyRun)) + } + return Promise.resolve(response(plan)) + }) + + render( + , + ) + + const input = await screen.findByLabelText('대상 연결 이름 확인') + fireEvent.change(input, { target: { value: 'production-primary' } }) + fireEvent.click(screen.getByRole('button', { name: '비실행 apply 의도 등록' })) + + expect( + await screen.findByText(/apply-intent-modal/, {}, { timeout: 3000 }), + ).toBeInTheDocument() + expect(screen.getByLabelText('대상 연결 이름 확인')).toBeInTheDocument() + expect(screen.queryByText('apply 의도를 등록할 수 없습니다.')) + .not.toBeInTheDocument() + }) + + it('retires prior passed evidence when a successor dry run is requested', async () => { + const passedRun = { + ...run, + state: 'passed', + state_version: 4, + observed_base_digest: 'b'.repeat(64), + } + const successorRun = { + ...run, + migration_run_uuid: 'dry-run-successor', + state: 'queued', + } + vi.mocked(fetch).mockImplementation((input, init) => { + const url = String(input) + if (url === '/api/csrf-token') return Promise.resolve(response({ csrf_token: 'csrf' })) + if (url.endsWith('/dry-runs') && init?.method === 'POST') { + return Promise.resolve(response({ + migration_run_uuid: 'dry-run-successor', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + })) + } + if (url.endsWith('/migration-runs/run-modal')) return Promise.resolve(response(passedRun)) + if (url.endsWith('/migration-runs/dry-run-successor')) { + return Promise.resolve(response(successorRun)) + } + return Promise.resolve(response(plan)) + }) + + render( + , + ) + + expect(await screen.findByLabelText('대상 연결 이름 확인')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + + expect(await screen.findByText('dry-run-successor')).toBeInTheDocument() + expect(screen.queryByLabelText('대상 연결 이름 확인')).not.toBeInTheDocument() + }) + + it('restores the supplied run when the same modal is closed and reopened', async () => { + const createdRun = { + ...run, + migration_run_uuid: 'run-created', + state: 'passed', + state_version: 4, + observed_base_digest: 'b'.repeat(64), + finished_at: '2026-08-12T05:01:00Z', + updated_at: '2026-08-12T05:01:00Z', + } + vi.mocked(fetch).mockImplementation((input, init) => { + const url = String(input) + if (url === '/api/csrf-token') return Promise.resolve(response({ csrf_token: 'csrf' })) + if (url.endsWith('/dry-runs') && init?.method === 'POST') { + return Promise.resolve(response({ + migration_run_uuid: 'run-created', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + })) + } + if (url.endsWith('/migration-runs/run-created')) { + return Promise.resolve(response(createdRun)) + } + if (url.endsWith('/migration-runs/run-modal')) return Promise.resolve(response(run)) + return Promise.resolve(response(plan)) + }) + + const { rerender } = render( + , + ) + + await screen.findByText('run-modal') + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + expect(await screen.findByText('run-created')).toBeInTheDocument() + + rerender( + , + ) + rerender( + , + ) + + expect(await screen.findByText('run-modal')).toBeInTheDocument() + expect(screen.queryByText('run-created')).not.toBeInTheDocument() + }) + + it('closes with the explicit button or Escape', () => { + const onClose = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Forward Engineering 닫기' })) + expect(onClose).toHaveBeenCalledOnce() + + onClose.mockClear() + rerender() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('moves focus into the dialog and restores it on close', async () => { + const opener = document.createElement('button') + opener.textContent = 'Forward 열기' + document.body.append(opener) + opener.focus() + + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Forward Engineering 닫기' })).toHaveFocus() + }) + rerender( + , + ) + await waitFor(() => expect(opener).toHaveFocus()) + opener.remove() + }) +}) diff --git a/frontend/src/components/forward/ForwardEngineeringModal.tsx b/frontend/src/components/forward/ForwardEngineeringModal.tsx new file mode 100644 index 000000000..95172e36a --- /dev/null +++ b/frontend/src/components/forward/ForwardEngineeringModal.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' + +import type { MigrationPlan, MigrationRun } from '../../types' +import { useDialogAccessibility } from '../modals/useDialogAccessibility' +import { ApplyIntentPanel } from './ApplyIntentPanel' +import { PlanReviewSurface } from './PlanReviewSurface' +import { RunStatusSurface } from './RunStatusSurface' + +type ForwardEngineeringModalProps = { + isOpen: boolean + planId: string + runId?: string + onClose: () => void +} + +export function ForwardEngineeringModal({ + isOpen, + planId, + runId, + onClose, +}: ForwardEngineeringModalProps) { + const dialogRef = useDialogAccessibility(isOpen, onClose) + const [createdRun, setCreatedRun] = useState<{ + scope: string + runId: string + } | null>(null) + const [reviewedPlan, setReviewedPlan] = useState(null) + const [passedDryRun, setPassedDryRun] = useState<{ + scope: string + run: MigrationRun + } | null>(null) + const runScope = `${planId}\u0000${runId ?? ''}` + + useEffect(() => { + if (!isOpen) { + setCreatedRun(null) + setReviewedPlan(null) + setPassedDryRun(null) + } + }, [isOpen]) + + useEffect(() => { + setPassedDryRun(null) + }, [runScope]) + + if (!isOpen) return null + + const activeRunId = createdRun?.scope === runScope ? createdRun.runId : runId + const handleRunLoaded = (loadedRun: MigrationRun | null) => { + if ( + loadedRun?.run_kind === 'dry_run' + && loadedRun.state === 'passed' + && loadedRun.migration_plan_uuid === planId + ) { + setPassedDryRun({ scope: runScope, run: loadedRun }) + } + } + + return ( +
+
+
+

Forward Engineering

+ +
+
+ { + setPassedDryRun(null) + setCreatedRun({ + scope: runScope, + runId: newRunId, + }) + }} + renderCreatedRunStatus={false} + /> + {activeRunId ? ( + + ) : null} + {reviewedPlan + && passedDryRun?.scope === runScope + && reviewedPlan.migration_plan_uuid === planId ? ( + setCreatedRun({ + scope: runScope, + runId: newRunId, + })} + /> + ) : null} +
+
+
+ ) +} diff --git a/frontend/src/components/forward/PlanReviewPanel.test.tsx b/frontend/src/components/forward/PlanReviewPanel.test.tsx new file mode 100644 index 000000000..3bc3a34f7 --- /dev/null +++ b/frontend/src/components/forward/PlanReviewPanel.test.tsx @@ -0,0 +1,135 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { MigrationPlan } from '../../types' +import { PlanReviewPanel } from './index' + +const statement: MigrationPlan['statements'][number] = { + kind: 'add_column', + target: '판매.주문.배송지', + object_ref: { + database: null, + schema_name: '판매', + table_name: '주문', + column_name: '배송지', + }, + sql: 'ALTER TABLE "판매"."주문" ADD COLUMN "배송지" text;', + transactional: true, + dependencies: ['table:판매.주문'], + dependency_refs: [{ + database: null, + schema_name: '판매', + table_name: '주문', + column_name: null, + }], + reversible: true, + risk: { + severity: 'warning', + lock_mode: 'ACCESS EXCLUSIVE', + possible_rewrite: false, + table_scan: false, + data_loss: false, + detail: '기존 행은 변경하지 않지만 테이블 잠금이 필요합니다.', + }, + required_privileges: ['ALTER'], + preconditions: [{ + kind: 'table_is_empty', + object_ref: { + schema_name: '판매', + table_name: '주문', + }, + }], +} + +const plan: MigrationPlan = { + migration_plan_uuid: '11111111-1111-4111-8111-111111111111', + project_space_uuid: '22222222-2222-4222-8222-222222222222', + schema_model_revision_uuid: '33333333-3333-4333-8333-333333333333', + db_connection_uuid: '44444444-4444-4444-8444-444444444444', + base_schema_snapshot_uuid: '55555555-5555-4555-8555-555555555555', + plan_digest: 'a'.repeat(64), + base_digest: 'b'.repeat(64), + target_digest: 'c'.repeat(64), + compiler_version: 'pg-plan-v1', + snapshot_contract_version: 1, + postgresql_major: 16, + created_by_user_uuid: '66666666-6666-4666-8666-666666666666', + created_at: '2026-08-12T05:00:00Z', + can_dry_run: true, + requires_destructive_confirmation: false, + statements: [statement], + proposed_statements: [], + blockers: [], + risk_summary: { safe: 0, warning: 1, destructive: 0 }, + expires_at: '2026-08-13T05:00:00Z', +} + +afterEach(cleanup) + +describe('PlanReviewPanel', () => { + it('presents immutable provenance, risk, and structured executable statements', () => { + render() + + expect(screen.getByRole('heading', { name: '마이그레이션 계획 검토' })).toBeInTheDocument() + const provenance = screen.getByRole('region', { name: '계획 출처' }) + expect(provenance).toHaveTextContent(plan.migration_plan_uuid) + expect(provenance).toHaveTextContent(plan.schema_model_revision_uuid) + expect(provenance).toHaveTextContent(plan.base_schema_snapshot_uuid) + expect(provenance).toHaveTextContent('PostgreSQL 16') + expect(provenance).toHaveTextContent(plan.plan_digest) + + const risk = screen.getByRole('region', { name: '위험 요약' }) + expect(within(risk).getByText('경고 1')).toBeInTheDocument() + expect(within(risk).getByText('파괴적 0')).toBeInTheDocument() + + const executable = screen.getByRole('region', { name: '실행 가능한 문 1개' }) + expect(executable).toHaveTextContent('add_column') + expect(executable).toHaveTextContent('판매.주문.배송지') + expect(executable).toHaveTextContent('ACCESS EXCLUSIVE') + expect(executable).toHaveTextContent('ALTER') + expect(executable).toHaveTextContent('table:판매.주문') + expect(executable).toHaveTextContent('판매.주문') + expect(executable).toHaveTextContent('table_is_empty') + expect(executable).toHaveTextContent(statement.sql) + }) + + it('keeps blocked SQL review-only and exposes blockers as an alert', () => { + const hostileSql = 'ALTER TABLE x ADD COLUMN y text; ' + render( + , + ) + + expect(screen.getByRole('alert')).toHaveTextContent('generated_column_unsupported') + expect(screen.getByRole('region', { name: '실행 가능한 문 0개' })).toBeEmptyDOMElement() + const proposals = screen.getByRole('region', { name: '검토 전용 제안 1개' }) + expect(proposals).toHaveTextContent(hostileSql) + expect(proposals).toHaveTextContent('아니요') + expect(proposals).toHaveTextContent('불가') + expect(proposals.querySelector('img')).not.toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/forward/PlanReviewPanel.tsx b/frontend/src/components/forward/PlanReviewPanel.tsx new file mode 100644 index 000000000..d22d90c35 --- /dev/null +++ b/frontend/src/components/forward/PlanReviewPanel.tsx @@ -0,0 +1,114 @@ +import type { + MigrationPlan, + MigrationPlanObjectRef, + MigrationPlanStatement, +} from '../../types' + +type PlanReviewPanelProps = { + plan: MigrationPlan +} + +function objectRefLabel(objectRef: MigrationPlanObjectRef): string { + return [ + objectRef.database, + objectRef.schema_name, + objectRef.table_name, + objectRef.column_name, + ].filter(Boolean).join('.') +} + +function StatementList({ statements }: { statements: ReadonlyArray }) { + if (statements.length === 0) return null + + return ( +
    + {statements.map((statement, index) => ( +
  1. +
    + {statement.kind} + {statement.target} +
    +
    +
    위험
    {statement.risk.severity}
    +
    잠금
    {statement.risk.lock_mode}
    +
    트랜잭션
    {statement.transactional ? '예' : '아니요'}
    +
    되돌리기
    {statement.reversible ? '가능' : '불가'}
    +
    객체 참조
    {objectRefLabel(statement.object_ref)}
    +
    의존성
    {statement.dependencies.join(', ')}
    +
    +
    의존 객체
    +
    {statement.dependency_refs.map(objectRefLabel).join(', ')}
    +
    +
    필요 권한
    {statement.required_privileges.join(', ')}
    +
    +
    사전 조건
    +
    {statement.preconditions.map((value) => JSON.stringify(value)).join(', ')}
    +
    +
    +

    {statement.risk.detail}

    +
    {statement.sql}
    +
  2. + ))} +
+ ) +} + +export function PlanReviewPanel({ plan }: PlanReviewPanelProps) { + return ( +
+
+

마이그레이션 계획 검토

+

서버가 컴파일한 불변 계획입니다. 이 화면은 SQL 실행 권한을 갖지 않습니다.

+
+ +
+

계획 출처

+
+
계획
{plan.migration_plan_uuid}
+
모델 리비전
{plan.schema_model_revision_uuid}
+
기준 스냅샷
{plan.base_schema_snapshot_uuid}
+
대상 연결
{plan.db_connection_uuid}
+
호환 버전
PostgreSQL {plan.postgresql_major}
+
컴파일러
{plan.compiler_version}
+
계획 다이제스트
{plan.plan_digest}
+
만료
+
+
+ +
+

위험 요약

+
    +
  • 안전 {plan.risk_summary.safe}
  • +
  • 경고 {plan.risk_summary.warning}
  • +
  • 파괴적 {plan.risk_summary.destructive}
  • +
+
+ + {plan.blockers.length > 0 ? ( +
+

계획 차단 사유

+
    + {plan.blockers.map((blocker, index) => ( +
  • + {blocker.code}: {blocker.object} — {blocker.detail} +
  • + ))} +
+
+ ) : null} + +
+ {plan.statements.length > 0 ?

실행 가능한 문

: null} + +
+ + {plan.proposed_statements.length > 0 ? ( +
+

검토 전용 제안

+

차단 사유가 있어 이 SQL에는 실행 권한이 없습니다.

+ +
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/forward/PlanReviewSurface.test.tsx b/frontend/src/components/forward/PlanReviewSurface.test.tsx new file mode 100644 index 000000000..39df60cc9 --- /dev/null +++ b/frontend/src/components/forward/PlanReviewSurface.test.tsx @@ -0,0 +1,165 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MigrationPlan } from '../../types' +import { PlanReviewSurface } from './index' + +function response(payload: unknown, ok = true, status = ok ? 200 : 500): Response { + return { + ok, + status, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response +} + +function plan(id: string): MigrationPlan { + return { + migration_plan_uuid: id, + project_space_uuid: '22222222-2222-4222-8222-222222222222', + schema_model_revision_uuid: '33333333-3333-4333-8333-333333333333', + db_connection_uuid: '44444444-4444-4444-8444-444444444444', + base_schema_snapshot_uuid: '55555555-5555-4555-8555-555555555555', + plan_digest: 'a'.repeat(64), + base_digest: 'b'.repeat(64), + target_digest: 'c'.repeat(64), + compiler_version: 'pg-plan-v1', + snapshot_contract_version: 1, + postgresql_major: 16, + created_by_user_uuid: '66666666-6666-4666-8666-666666666666', + created_at: '2026-08-12T05:00:00Z', + can_dry_run: true, + requires_destructive_confirmation: false, + statements: [], + proposed_statements: [], + blockers: [], + risk_summary: { safe: 0, warning: 0, destructive: 0 }, + expires_at: '2026-08-13T05:00:00Z', + } +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('PlanReviewSurface', () => { + it('announces loading and renders the exact immutable plan', async () => { + vi.mocked(fetch).mockResolvedValue(response(plan('plan-1'))) + + render() + + expect(screen.getByRole('status')).toHaveTextContent('계획을 불러오는 중입니다') + expect(await screen.findByText('plan-1')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/api/migration-plans/plan-1', { + credentials: 'include', + }) + }) + + it('shows a fixed safe error and retries without exposing response data', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ detail: 'dsn=postgres://secret@host' }, false, 503)) + .mockResolvedValueOnce(response(plan('plan-retry'))) + + render() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('계획을 불러오지 못했습니다') + expect(alert).not.toHaveTextContent('secret') + fireEvent.click(screen.getByRole('button', { name: '다시 시도' })) + + expect(await screen.findByText('plan-retry')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('ignores an obsolete response after the requested plan changes', async () => { + let resolveFirst!: (value: Response) => void + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + vi.mocked(fetch) + .mockReturnValueOnce(first) + .mockResolvedValueOnce(response(plan('plan-current'))) + + const { rerender } = render() + rerender() + + expect(await screen.findByText('plan-current')).toBeInTheDocument() + resolveFirst(response(plan('plan-obsolete'))) + await waitFor(() => expect(screen.queryByText('plan-obsolete')).not.toBeInTheDocument()) + }) + + it('ignores an obsolete failure after the requested plan changes', async () => { + let rejectFirst!: (reason: Error) => void + const first = new Promise((_resolve, reject) => { + rejectFirst = reject + }) + vi.mocked(fetch) + .mockReturnValueOnce(first) + .mockResolvedValueOnce(response(plan('plan-current'))) + + const { rerender } = render() + rerender() + + expect(await screen.findByText('plan-current')).toBeInTheDocument() + rejectFirst(new Error('obsolete failure with secret response')) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + }) + + it('does not refetch the same plan when only the observer identity changes', async () => { + vi.mocked(fetch).mockResolvedValue(response(plan('plan-stable'))) + const { rerender } = render( + , + ) + + expect(await screen.findByText('plan-stable')).toBeInTheDocument() + rerender() + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) + }) + + it('hands an accepted exact-digest intent to terminal-aware run polling', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response(plan('plan-run'))) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'run-created', + state: 'queued', + state_version: 1, + cancellation_requested: false, + reused: false, + }, true, 202)) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'run-created', + project_space_uuid: 'project-1', + migration_plan_uuid: 'plan-run', + run_kind: 'dry_run', + state: 'passed', + state_version: 4, + plan_digest: 'a'.repeat(64), + requested_by_user_uuid: 'user-1', + cancellation_requested: false, + observed_base_digest: 'b'.repeat(64), + evidence: {}, + error_code: null, + created_at: '2026-08-12T05:00:00Z', + updated_at: '2026-08-12T05:01:00Z', + started_at: '2026-08-12T05:00:10Z', + finished_at: '2026-08-12T05:01:00Z', + events: [], + })) + + render() + await screen.findByText('plan-run') + fireEvent.click(screen.getByRole('button', { name: '격리 dry-run 요청' })) + + expect(await screen.findByRole('status', { name: '마이그레이션 실행 상태' })) + .toHaveTextContent('격리 검증 및 읽기 전용 사전 점검 통과') + expect(fetch).toHaveBeenNthCalledWith(4, '/api/migration-runs/run-created', { + credentials: 'include', + }) + }) +}) diff --git a/frontend/src/components/forward/PlanReviewSurface.tsx b/frontend/src/components/forward/PlanReviewSurface.tsx new file mode 100644 index 000000000..3ef158986 --- /dev/null +++ b/frontend/src/components/forward/PlanReviewSurface.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from 'react' + +import { getMigrationPlan } from '../../api' +import type { MigrationPlan } from '../../types' +import { DryRunIntentPanel } from './DryRunIntentPanel' +import { PlanReviewPanel } from './PlanReviewPanel' +import { RunStatusSurface } from './RunStatusSurface' + +type PlanReviewSurfaceProps = { + planId: string + onPlanLoaded?: (plan: MigrationPlan | null) => void + onRunCreated?: (runId: string) => void + renderCreatedRunStatus?: boolean +} + +type LoadState = + | { status: 'loading' } + | { status: 'ready'; plan: MigrationPlan } + | { status: 'error' } + +export function PlanReviewSurface({ + planId, + onPlanLoaded, + onRunCreated, + renderCreatedRunStatus = true, +}: PlanReviewSurfaceProps) { + const [attempt, setAttempt] = useState(0) + const [createdRunId, setCreatedRunId] = useState(null) + const [loadState, setLoadState] = useState({ status: 'loading' }) + const onPlanLoadedRef = useRef(onPlanLoaded) + + useEffect(() => { + onPlanLoadedRef.current = onPlanLoaded + }, [onPlanLoaded]) + + useEffect(() => { + let active = true + setCreatedRunId(null) + setLoadState({ status: 'loading' }) + onPlanLoadedRef.current?.(null) + + void getMigrationPlan(planId).then( + (plan) => { + if (active) { + setLoadState({ status: 'ready', plan }) + onPlanLoadedRef.current?.(plan) + } + }, + () => { + if (active) setLoadState({ status: 'error' }) + }, + ) + + return () => { + active = false + } + }, [attempt, planId]) + + if (loadState.status === 'loading') { + return

계획을 불러오는 중입니다.

+ } + + if (loadState.status === 'error') { + return ( +
+

계획을 불러오지 못했습니다.

+ +
+ ) + } + + const handleRunCreated = (runId: string) => { + setCreatedRunId(runId) + onRunCreated?.(runId) + } + + return ( + <> + + + {renderCreatedRunStatus && createdRunId + ? + : null} + + ) +} diff --git a/frontend/src/components/forward/RunCancellationControl.test.tsx b/frontend/src/components/forward/RunCancellationControl.test.tsx new file mode 100644 index 000000000..a7e1cb3fd --- /dev/null +++ b/frontend/src/components/forward/RunCancellationControl.test.tsx @@ -0,0 +1,174 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MigrationRun } from '../../types' +import { RunCancellationControl } from './RunCancellationControl' + +function response(payload: unknown, ok = true, status = ok ? 200 : 500): Response { + return { + ok, + status, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +const run: MigrationRun = { + migration_run_uuid: 'run-1', + project_space_uuid: 'project-1', + migration_plan_uuid: 'plan-1', + run_kind: 'dry_run', + state: 'sandbox_running', + state_version: 3, + plan_digest: 'a'.repeat(64), + requested_by_user_uuid: 'user-1', + cancellation_requested: false, + observed_base_digest: null, + evidence: {}, + error_code: null, + created_at: '2026-08-12T05:00:00Z', + updated_at: '2026-08-12T05:00:10Z', + started_at: '2026-08-12T05:00:05Z', + finished_at: null, + events: [], +} + +beforeEach(() => vi.stubGlobal('fetch', vi.fn())) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('RunCancellationControl', () => { + it('submits one exact optimistic state version and refreshes after acceptance', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce(response({ + migration_run_uuid: 'run-1', + state: 'sandbox_running', + state_version: 4, + cancellation_requested: true, + reused: false, + }, true, 202)) + const onRefresh = vi.fn() + + render() + fireEvent.click(screen.getByRole('button', { name: '실행 취소 요청' })) + + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()) + expect(fetch).toHaveBeenNthCalledWith(2, '/api/migration-runs/run-1/cancel', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'csrf', + }, + body: JSON.stringify({ expected_state_version: 3 }), + }) + }) + + it('admits only one request while cancellation is in flight', async () => { + const csrf = deferred() + const cancellation = deferred() + vi.mocked(fetch) + .mockReturnValueOnce(csrf.promise) + .mockReturnValueOnce(cancellation.promise) + const onRefresh = vi.fn() + + render() + const button = screen.getByRole('button', { name: '실행 취소 요청' }) + fireEvent.click(button) + fireEvent.click(button) + + expect(button).toBeDisabled() + expect(fetch).toHaveBeenCalledTimes(1) + csrf.resolve(response({ csrf_token: 'csrf' })) + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(fetch).toHaveBeenCalledTimes(2) + + cancellation.resolve(response({ + migration_run_uuid: 'run-1', + state: 'sandbox_running', + state_version: 4, + cancellation_requested: true, + reused: false, + }, true, 202)) + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()) + }) + + it('keeps the single-flight guard when polling advances the non-terminal version', async () => { + const cancellation = deferred() + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockReturnValueOnce(cancellation.promise) + const onRefresh = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '실행 취소 요청' })) + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + rerender( + , + ) + + const button = screen.getByRole('button', { name: '취소 요청 중…' }) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(fetch).toHaveBeenCalledTimes(2) + + cancellation.resolve(response({ + migration_run_uuid: 'run-1', + state: 'sandbox_running', + state_version: 4, + cancellation_requested: true, + reused: false, + }, true, 202)) + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()) + }) + + it('does not replay an ambiguous cancellation and offers status refresh only', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce(response({ detail: 'dsn=postgres://secret' }, false, 503)) + const onRefresh = vi.fn() + + render() + fireEvent.click(screen.getByRole('button', { name: '실행 취소 요청' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('취소 요청 결과를 확인하지 못했습니다') + expect(alert).not.toHaveTextContent(/503|secret|cancelMigrationRun/) + expect(screen.queryByRole('button', { name: '실행 취소 요청' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '실행 상태 새로고침' })) + + expect(onRefresh).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['terminal run', { ...run, state: 'passed' as const }], + ['existing intent', { ...run, cancellation_requested: true }], + ])('renders no mutation for %s', (_label, candidate) => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/forward/RunCancellationControl.tsx b/frontend/src/components/forward/RunCancellationControl.tsx new file mode 100644 index 000000000..750f87af3 --- /dev/null +++ b/frontend/src/components/forward/RunCancellationControl.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' + +import { cancelMigrationRun } from '../../api' +import type { MigrationRun } from '../../types' +import { isTerminalMigrationRunState } from './runStates' + +type RunCancellationControlProps = { + run: MigrationRun + onRefresh: () => void +} + +type CancellationState = 'idle' | 'requesting' | 'outcome_unknown' + +export function RunCancellationControl({ run, onRefresh }: RunCancellationControlProps) { + const [cancellationState, setCancellationState] = useState('idle') + const inFlightRef = useRef(false) + const generationRef = useRef(0) + + useEffect(() => { + generationRef.current += 1 + inFlightRef.current = false + setCancellationState('idle') + + return () => { + generationRef.current += 1 + inFlightRef.current = false + } + }, [run.migration_run_uuid]) + + const requestCancellation = async () => { + if (inFlightRef.current) return + inFlightRef.current = true + const generation = generationRef.current + setCancellationState('requesting') + + try { + await cancelMigrationRun(run.migration_run_uuid, run.state_version) + if (generation === generationRef.current) onRefresh() + } catch { + if (generation === generationRef.current) setCancellationState('outcome_unknown') + } finally { + if (generation === generationRef.current) inFlightRef.current = false + } + } + + if (run.cancellation_requested || isTerminalMigrationRunState(run.state)) return null + + if (cancellationState === 'outcome_unknown') { + return ( +
+
+

+ 취소 요청 결과를 확인하지 못했습니다. 요청을 자동으로 반복하지 말고 + 저장된 실행 상태를 다시 확인하세요. +

+ +
+
+ ) + } + + return ( +
+

+ 현재 상태 버전에 취소 의도를 기록합니다. 요청 접수는 즉시 완료 상태를 뜻하지 않습니다. +

+ +
+ ) +} diff --git a/frontend/src/components/forward/RunStatusPanel.test.tsx b/frontend/src/components/forward/RunStatusPanel.test.tsx new file mode 100644 index 000000000..3e4560998 --- /dev/null +++ b/frontend/src/components/forward/RunStatusPanel.test.tsx @@ -0,0 +1,118 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { MigrationRun } from '../../types' +import { RunStatusPanel } from './RunStatusPanel' +import { isTerminalMigrationRunState } from './runStates' + +const run: MigrationRun = { + migration_run_uuid: 'run-1', + project_space_uuid: 'project-1', + migration_plan_uuid: 'plan-1', + run_kind: 'dry_run', + state: 'passed', + state_version: 3, + plan_digest: 'a'.repeat(64), + requested_by_user_uuid: 'user-1', + cancellation_requested: false, + observed_base_digest: 'b'.repeat(64), + evidence: { request_id: 'must-not-render', secret: 'must-not-render' }, + error_code: null, + created_at: '2026-08-12T05:00:00Z', + updated_at: '2026-08-12T05:02:00Z', + started_at: '2026-08-12T05:01:00Z', + finished_at: '2026-08-12T05:02:00Z', + events: [ + { + sequence_number: 1, + event_type: 'run_queued', + state_before: null, + state_after: 'queued', + evidence: { request_id: 'event-must-not-render' }, + previous_event_digest: null, + event_digest: 'c'.repeat(64), + actor_user_uuid: 'user-1', + created_at: '2026-08-12T05:00:00Z', + }, + { + sequence_number: 2, + event_type: 'isolated_dry_run_succeeded', + state_before: 'sandbox_running', + state_after: 'live_preflight_running', + evidence: { statement_count: 1 }, + previous_event_digest: 'c'.repeat(64), + event_digest: 'd'.repeat(64), + actor_user_uuid: null, + created_at: '2026-08-12T05:01:30Z', + }, + ], +} + +afterEach(cleanup) + +describe('RunStatusPanel', () => { + it('announces the exact state and its bounded terminal meaning', () => { + render() + + expect(screen.getByRole('status', { name: '마이그레이션 실행 상태' })).toHaveTextContent( + '격리 검증 및 읽기 전용 사전 점검 통과', + ) + expect(screen.getByText('라이브 대상에서 DDL을 실행했다는 의미가 아닙니다.')).toBeInTheDocument() + expect(screen.getByText('run-1')).toBeInTheDocument() + expect(screen.getByText('b'.repeat(64))).toBeInTheDocument() + }) + + it('renders the append-only digest chain as text without exposing evidence payloads', () => { + const { container } = render() + + expect( + screen.getByRole('heading', { name: '#1 run_queued' }), + ).toBeInTheDocument() + expect(container.querySelector('img')).not.toBeInTheDocument() + expect(screen.getAllByText('c'.repeat(64))).toHaveLength(2) + expect(screen.getByText('d'.repeat(64))).toBeInTheDocument() + expect(screen.queryByText(/must-not-render/)).not.toBeInTheDocument() + expect(screen.getByText('서버가 검증한 이벤트 메타데이터만 표시합니다.')).toBeInTheDocument() + }) + + it('surfaces cancellation intent and a sanitized error code without inventing success', () => { + render( + , + ) + + expect(screen.getByRole('alert', { name: '취소 요청' })).toBeInTheDocument() + expect(screen.getByRole('alert', { name: '실행 오류' })).toHaveTextContent( + 'commit_outcome_unknown', + ) + expect(screen.getByText('결과가 불명확하며 자동 재실행이 금지됩니다.')).toBeInTheDocument() + }) + + it('announces acknowledged cancellation as terminal without implying live DDL', () => { + render( + , + ) + + expect(isTerminalMigrationRunState('cancelled')).toBe(true) + expect(screen.getByRole('status', { name: '마이그레이션 실행 상태' })).toHaveTextContent( + '취소 완료', + ) + expect(screen.getByText('취소가 확인됐으며 라이브 DDL을 실행하지 않았습니다.')).toBeInTheDocument() + expect(screen.getByRole('alert', { name: '취소 완료' })).toBeInTheDocument() + expect(screen.queryByRole('alert', { name: '취소 요청' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/forward/RunStatusPanel.tsx b/frontend/src/components/forward/RunStatusPanel.tsx new file mode 100644 index 000000000..5c1c024a3 --- /dev/null +++ b/frontend/src/components/forward/RunStatusPanel.tsx @@ -0,0 +1,122 @@ +import type { MigrationRun, MigrationRunState } from '../../types' + +type RunStatusPanelProps = { + run: MigrationRun +} + +const STATE_LABELS: Record = { + queued: '대기 중', + sandbox_running: '격리 환경에서 검증 중', + live_preflight_running: '라이브 대상 읽기 전용 사전 점검 중', + passed: '격리 검증 및 읽기 전용 사전 점검 통과', + drifted: '기준 스키마 변경 감지', + failed: '드라이런 실패', + cancelled: '취소 완료', + applying: '적용 중', + reconciling: '적용 결과 조정 중', + verifying: '적용 결과 검증 중', + verified: '목표 스키마 수렴 검증 완료', + drifted_no_apply: '변경 감지로 적용하지 않음', + not_applied: '적용되지 않음이 확인됨', + verification_failed: '적용 후 검증 실패', + failed_rolled_back: '실패 후 전체 롤백 확인', + applied_with_drift: '적용됐으나 목표와 불일치', + outcome_unknown: '적용 결과 불명확', +} + +const STATE_MEANINGS: Partial> = { + passed: '라이브 대상에서 DDL을 실행했다는 의미가 아닙니다.', + drifted: '기준 다이제스트가 달라 라이브 DDL을 실행하지 않았습니다.', + failed: '드라이런 단계가 실패했으며 라이브 DDL을 실행하지 않았습니다.', + cancelled: '취소가 확인됐으며 라이브 DDL을 실행하지 않았습니다.', + verified: '저장된 검증 스냅샷이 목표 다이제스트와 일치합니다.', + drifted_no_apply: '적용 전 변경이 감지되어 DDL을 실행하지 않았습니다.', + not_applied: '조정 결과 기존 기준 다이제스트가 유지된 것으로 확인됐습니다.', + verification_failed: '커밋은 알려졌지만 수렴 여부를 확인하지 못했습니다.', + failed_rolled_back: '트랜잭션 세그먼트 전체가 롤백된 것으로 확인됐습니다.', + applied_with_drift: '커밋 후 잔여 차이가 확인되어 성공으로 간주하지 않습니다.', + outcome_unknown: '결과가 불명확하며 자동 재실행이 금지됩니다.', +} + +function digestValue(value: string | null): string { + return value ?? '없음' +} + +export function RunStatusPanel({ run }: RunStatusPanelProps) { + const stateMeaning = STATE_MEANINGS[run.state] + + return ( +
+
+

마이그레이션 실행 상태

+

+ {STATE_LABELS[run.state]} +

+ {stateMeaning ?

{stateMeaning}

: null} +
+ + {run.cancellation_requested ? ( +

+ {run.state === 'cancelled' + ? '취소 요청이 작업자에 의해 확인됐습니다.' + : '취소 요청이 기록됐습니다. 다음 상태 전환 전까지 완료로 간주하지 않습니다.'} +

+ ) : null} + + {run.error_code ? ( +

+ 오류 코드: {run.error_code} +

+ ) : null} + +
+

실행 출처

+
+
실행
{run.migration_run_uuid}
+
종류
{run.run_kind}
+
계획
{run.migration_plan_uuid}
+
계획 다이제스트
{run.plan_digest}
+
관측 기준 다이제스트
{digestValue(run.observed_base_digest)}
+
상태 버전
{run.state_version}
+
요청 시각
+
갱신 시각
+
+
+ +
+

감사 이벤트

+

서버가 검증한 이벤트 메타데이터만 표시합니다.

+
    + {run.events.map((event) => ( +
  1. +

    #{event.sequence_number} {event.event_type}

    +
    +
    +
    상태 전환
    +
    {event.state_before ?? '없음'} → {event.state_after}
    +
    +
    이벤트 다이제스트
    {event.event_digest}
    +
    +
    이전 다이제스트
    +
    {digestValue(event.previous_event_digest)}
    +
    +
    +
    기록 시각
    +
    +
    +
    +
  2. + ))} +
+
+
+ ) +} diff --git a/frontend/src/components/forward/RunStatusSurface.test.tsx b/frontend/src/components/forward/RunStatusSurface.test.tsx new file mode 100644 index 000000000..0c8a77bb2 --- /dev/null +++ b/frontend/src/components/forward/RunStatusSurface.test.tsx @@ -0,0 +1,141 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MigrationRun } from '../../types' +import { RunStatusSurface } from './RunStatusSurface' + +const run: MigrationRun = { + migration_run_uuid: 'run-1', + project_space_uuid: 'project-1', + migration_plan_uuid: 'plan-1', + run_kind: 'dry_run', + state: 'queued', + state_version: 1, + plan_digest: 'a'.repeat(64), + requested_by_user_uuid: 'user-1', + cancellation_requested: false, + observed_base_digest: null, + evidence: {}, + error_code: null, + created_at: '2026-08-12T05:00:00Z', + updated_at: '2026-08-12T05:00:00Z', + started_at: null, + finished_at: null, + events: [], +} + +function response(payload: unknown): Response { + return { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +beforeEach(() => vi.stubGlobal('fetch', vi.fn())) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('RunStatusSurface', () => { + it('loads one exact run and renders the integrity-checked status', async () => { + vi.mocked(fetch).mockResolvedValueOnce(response(run)) + + render() + + expect(screen.getByRole('status')).toHaveTextContent('실행 상태를 불러오는 중입니다.') + expect(await screen.findByText('run-1')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/api/migration-runs/run%2F..%2Fother', { + credentials: 'include', + }) + }) + + it('shows a fixed error and retries without exposing a raw server response', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ ok: false, status: 500 } as Response) + .mockResolvedValueOnce(response(run)) + + render() + + expect(await screen.findByRole('alert')).toHaveTextContent('실행 상태를 불러오지 못했습니다.') + expect(screen.queryByText(/500|getMigrationRun/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '다시 시도' })) + expect(await screen.findByText('run-1')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('invalidates the last observed run when a polling request fails', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response(run)) + .mockResolvedValueOnce({ ok: false, status: 503 } as Response) + const onRunLoaded = vi.fn() + + render( + , + ) + + await waitFor(() => expect(onRunLoaded).toHaveBeenCalledWith(run)) + await screen.findByRole('alert') + expect(onRunLoaded).toHaveBeenLastCalledWith(null) + }) + + it('ignores a late predecessor response when the run identity changes', async () => { + const first = deferred() + vi.mocked(fetch) + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(response({ ...run, migration_run_uuid: 'run-2' })) + + const { rerender } = render() + rerender() + + expect(await screen.findByText('run-2')).toBeInTheDocument() + first.resolve(response(run)) + await first.promise + expect(screen.queryByText('run-1')).not.toBeInTheDocument() + }) + + it('does not refetch a terminal run when only the observer identity changes', async () => { + vi.mocked(fetch).mockResolvedValueOnce(response({ ...run, state: 'passed' })) + const { rerender } = render( + , + ) + + expect(await screen.findByText('run-1')).toBeInTheDocument() + rerender() + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) + }) + + it('polls one request at a time until the run reaches a terminal state', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(response(run)) + .mockResolvedValueOnce(response({ ...run, state: 'passed', state_version: 4 })) + + render() + + expect(await screen.findByText('run-1')).toBeInTheDocument() + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + expect(screen.getByRole('status', { name: '마이그레이션 실행 상태' })).toHaveTextContent( + '격리 검증 및 읽기 전용 사전 점검 통과', + ) + + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/components/forward/RunStatusSurface.tsx b/frontend/src/components/forward/RunStatusSurface.tsx new file mode 100644 index 000000000..10811d25f --- /dev/null +++ b/frontend/src/components/forward/RunStatusSurface.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from 'react' + +import { getMigrationRun } from '../../api' +import type { MigrationRun } from '../../types' +import { RunCancellationControl } from './RunCancellationControl' +import { RunStatusPanel } from './RunStatusPanel' +import { TERMINAL_RUN_STATES } from './runStates' + +type RunStatusSurfaceProps = { + runId: string + onRunLoaded?: (run: MigrationRun | null) => void + refreshIntervalMs?: number +} + +type LoadState = + | { status: 'loading' } + | { status: 'ready'; run: MigrationRun } + | { status: 'error' } + +export function RunStatusSurface({ + runId, + onRunLoaded, + refreshIntervalMs = 2_000, +}: RunStatusSurfaceProps) { + const [attempt, setAttempt] = useState(0) + const [loadState, setLoadState] = useState({ status: 'loading' }) + const onRunLoadedRef = useRef(onRunLoaded) + + useEffect(() => { + onRunLoadedRef.current = onRunLoaded + }, [onRunLoaded]) + + useEffect(() => { + let active = true + let refreshTimer: ReturnType | undefined + setLoadState({ status: 'loading' }) + onRunLoadedRef.current?.(null) + + const load = async () => { + try { + const run = await getMigrationRun(runId) + if (!active) return + setLoadState({ status: 'ready', run }) + onRunLoadedRef.current?.(run) + if (!TERMINAL_RUN_STATES.has(run.state)) { + refreshTimer = setTimeout(() => void load(), Math.max(1, refreshIntervalMs)) + } + } catch { + if (active) { + setLoadState({ status: 'error' }) + onRunLoadedRef.current?.(null) + } + } + } + + void load() + + return () => { + active = false + if (refreshTimer !== undefined) clearTimeout(refreshTimer) + } + }, [attempt, refreshIntervalMs, runId]) + + if (loadState.status === 'loading') { + return

실행 상태를 불러오는 중입니다.

+ } + + if (loadState.status === 'error') { + return ( +
+

실행 상태를 불러오지 못했습니다.

+ +
+ ) + } + + return ( + <> + + setAttempt((value) => value + 1)} + /> + + ) +} diff --git a/frontend/src/components/forward/index.ts b/frontend/src/components/forward/index.ts new file mode 100644 index 000000000..d2d56df67 --- /dev/null +++ b/frontend/src/components/forward/index.ts @@ -0,0 +1,8 @@ +export { ForwardEngineeringModal } from './ForwardEngineeringModal' +export { PlanReviewPanel } from './PlanReviewPanel' +export { PlanReviewSurface } from './PlanReviewSurface' +export { DryRunIntentPanel } from './DryRunIntentPanel' +export { RunStatusPanel } from './RunStatusPanel' +export { RunStatusSurface } from './RunStatusSurface' +export { RunCancellationControl } from './RunCancellationControl' +export { ApplyIntentPanel } from './ApplyIntentPanel' diff --git a/frontend/src/components/forward/runStates.ts b/frontend/src/components/forward/runStates.ts new file mode 100644 index 000000000..2f4642e51 --- /dev/null +++ b/frontend/src/components/forward/runStates.ts @@ -0,0 +1,19 @@ +import type { MigrationRunState } from '../../types' + +export const TERMINAL_RUN_STATES: ReadonlySet = new Set([ + 'passed', + 'drifted', + 'failed', + 'cancelled', + 'verified', + 'drifted_no_apply', + 'not_applied', + 'verification_failed', + 'failed_rolled_back', + 'applied_with_drift', + 'outcome_unknown', +]) + +export function isTerminalMigrationRunState(state: MigrationRunState): boolean { + return TERMINAL_RUN_STATES.has(state) +} diff --git a/frontend/src/forwardApi.test.ts b/frontend/src/forwardApi.test.ts new file mode 100644 index 000000000..42097c55f --- /dev/null +++ b/frontend/src/forwardApi.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' + +import { + cancelMigrationRun, + createApplyRun, + createDryRun, + getMigrationPlan, + getMigrationRun, +} from './api' +import type { MigrationPlan } from './types' + +function response(payload: unknown, ok = true, status = ok ? 200 : 500): Response { + return { + ok, + status, + json: vi.fn().mockResolvedValue(payload), + } as unknown as Response +} + +describe('Forward Engineering API client', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('exposes the server-authoritative structured statement contract', () => { + expectTypeOf().toMatchTypeOf<{ + kind: string + target: string + object_ref: { + database: string | null + schema_name: string | null + table_name: string | null + column_name: string | null + } + sql: string + transactional: boolean + dependencies: ReadonlyArray + dependency_refs: ReadonlyArray>> + reversible: boolean + risk: { + severity: 'safe' | 'warning' | 'destructive' + lock_mode: string + possible_rewrite: boolean + table_scan: boolean + data_loss: boolean + detail: string + } + required_privileges: ReadonlyArray + preconditions: ReadonlyArray>> + }>() + expectTypeOf().toMatchTypeOf<{ + code: string + object: string + object_ref: Readonly> + detail: string + }>() + }) + + it('reads immutable plans and durable run evidence with credentials', async () => { + const fetchMock = vi.mocked(fetch) + const plan = { migration_plan_uuid: 'plan-1', plan_digest: 'a'.repeat(64) } + const run = { migration_run_uuid: 'run-1', state: 'queued', events: [] } + fetchMock.mockResolvedValueOnce(response(plan)).mockResolvedValueOnce(response(run)) + + await expect(getMigrationPlan('plan-1')).resolves.toEqual(plan) + await expect(getMigrationRun('run-1')).resolves.toEqual(run) + + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/migration-plans/plan-1', { + credentials: 'include', + }) + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/migration-runs/run-1', { + credentials: 'include', + }) + }) + + it('encodes resource identifiers as single path segments', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(response({ migration_plan_uuid: 'plan/../other' })) + .mockResolvedValueOnce(response({ migration_run_uuid: 'run?tenant=other' })) + + await getMigrationPlan('plan/../other') + await getMigrationRun('run?tenant=other') + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/migration-plans/plan%2F..%2Fother', + { credentials: 'include' }, + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/migration-runs/run%3Ftenant%3Dother', + { credentials: 'include' }, + ) + }) + + it('creates exact dry-run and apply intents without accepting SQL', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(response({ csrf_token: 'csrf-dry' })) + .mockResolvedValueOnce(response({ migration_run_uuid: 'dry-1', state: 'queued' }, true, 202)) + .mockResolvedValueOnce(response({ csrf_token: 'csrf-apply' })) + .mockResolvedValueOnce(response({ migration_run_uuid: 'apply-1', state: 'queued' }, true, 202)) + const hostileIntent = { + plan_digest: 'a'.repeat(64), + passed_dry_run_uuid: 'dry-1', + target_connection_name: 'production-readonly', + destructive_acknowledged: false, + sql: 'DROP SCHEMA public CASCADE;', + } + + await createDryRun('plan-1', 'a'.repeat(64), 'dry-request-1') + await createApplyRun('plan-1', hostileIntent, 'apply-request-1') + + const dryRequest = fetchMock.mock.calls[1] + const applyRequest = fetchMock.mock.calls[3] + expect(dryRequest).toEqual([ + '/api/migration-plans/plan-1/dry-runs', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'csrf-dry', + 'Idempotency-Key': 'dry-request-1', + }, + body: JSON.stringify({ plan_digest: 'a'.repeat(64) }), + }), + ]) + expect(applyRequest).toEqual([ + '/api/migration-plans/plan-1/apply-runs', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'csrf-apply', + 'Idempotency-Key': 'apply-request-1', + }, + }), + ]) + expect(String(applyRequest?.[1]?.body)).not.toContain('sql') + }) + + it('cancels by exact optimistic state version', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce( + response({ + migration_run_uuid: 'run-1', + state: 'sandbox_running', + state_version: 4, + cancellation_requested: true, + reused: false, + }, true, 202), + ) + + await cancelMigrationRun('run-1', 3) + + expect(fetchMock.mock.calls[1]).toEqual([ + '/api/migration-runs/run-1/cancel', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({ expected_state_version: 3 }), + }), + ]) + }) + + it.each([ + ['getMigrationPlan', () => getMigrationPlan('plan-1')], + ['getMigrationRun', () => getMigrationRun('run-1')], + ])('reports %s failures using only the HTTP status', async (name, invoke) => { + vi.mocked(fetch).mockResolvedValue(response({}, false, 409)) + + await expect(invoke()).rejects.toThrow(`${name} failed: 409`) + }) + + it.each([ + ['createDryRun', () => createDryRun('plan-1', 'a'.repeat(64), 'dry-request-1')], + [ + 'createApplyRun', + () => createApplyRun( + 'plan-1', + { + plan_digest: 'a'.repeat(64), + passed_dry_run_uuid: 'dry-1', + target_connection_name: 'production-readonly', + destructive_acknowledged: false, + }, + 'apply-request-1', + ), + ], + ['cancelMigrationRun', () => cancelMigrationRun('run-1', 3)], + ])('reports %s write failures without echoing request data', async (name, invoke) => { + vi.mocked(fetch) + .mockResolvedValueOnce(response({ csrf_token: 'csrf' })) + .mockResolvedValueOnce(response({}, false, 409)) + + await expect(invoke()).rejects.toThrow(`${name} failed: 409`) + }) +}) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3605ce9fb..4717785a1 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -807,6 +807,181 @@ button:disabled { gap: 8px; } +.forwardPlanReview { + display: grid; + gap: 16px; + color: #172033; +} + +.forwardPlanReview > header, +.forwardPlanReview > section { + border: 1px solid #d8dfeb; + border-radius: 10px; + background: #fff; + padding: 16px; +} + +.forwardPlanReview dl, +.forwardPlanReview__statementList { + display: grid; + gap: 10px; + margin: 0; + padding: 0; +} + +.forwardPlanReview dl > div { + display: grid; + grid-template-columns: minmax(120px, 0.35fr) minmax(0, 1fr); + gap: 12px; +} + +.forwardPlanReview dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.forwardPlanReview__statementList { + list-style-position: inside; +} + +.forwardPlanReview__statementList > li { + border-left: 4px solid #f59e0b; + background: #f8fafc; + padding: 12px; +} + +.forwardPlanReview__statementList header { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; +} + +.forwardPlanReview pre { + max-width: 100%; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.forwardEngineeringModalOverlay { + position: fixed; + inset: 0; + z-index: 200; + display: grid; + place-items: center; + padding: 20px; + background: rgb(15 23 42 / 65%); +} + +.forwardEngineeringModal { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(1040px, 100%); + max-height: min(880px, 94vh); + border-radius: 12px; + background: #f8fafc; + box-shadow: 0 24px 70px rgb(15 23 42 / 35%); +} + +.forwardEngineeringModal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid #d8dfeb; + padding: 16px 20px; +} + +.forwardEngineeringModal__header h1 { + margin: 0; + font-size: 1.25rem; +} + +.forwardEngineeringModal__body { + display: grid; + gap: 20px; + overflow: auto; + padding: 20px; +} + +.forwardDryRunIntent { + display: grid; + gap: 12px; + border: 1px solid #d8dfeb; + border-radius: 10px; + background: #fff; + padding: 16px; + color: #172033; +} + +.forwardDryRunIntent h3, +.forwardDryRunIntent p { + margin: 0; +} + +.forwardDryRunIntent button { + justify-self: start; +} + +.forwardRunStatus { + display: grid; + gap: 16px; + color: #172033; +} + +.forwardRunStatus > header, +.forwardRunStatus > section { + border: 1px solid #d8dfeb; + border-radius: 10px; + background: #fff; + padding: 16px; +} + +.forwardRunStatus dl, +.forwardRunStatus ol { + display: grid; + gap: 10px; + margin: 0; +} + +.forwardRunStatus dl > div { + display: grid; + grid-template-columns: minmax(150px, 0.35fr) minmax(0, 1fr); + gap: 12px; +} + +.forwardRunStatus dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.forwardRunStatus ol > li { + border-left: 4px solid #0f766e; + background: #f8fafc; + padding: 12px; +} + +.forwardRunAction { + display: grid; + gap: 12px; + border: 1px solid #d8dfeb; + border-radius: 10px; + background: #fff; + padding: 16px; + color: #172033; +} + +.forwardRunAction p { + margin: 0; +} + +.forwardRunAction button { + justify-self: start; +} + .cardinalityWizard { width: min(760px, 96vw); padding: 20px; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6926aa6b2..bc1d884ce 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -98,6 +98,132 @@ export type SnapshotDetailResponse = Omit & { error_message: unknown } +export type MigrationRunState = + | 'queued' + | 'sandbox_running' + | 'live_preflight_running' + | 'passed' + | 'drifted' + | 'failed' + | 'cancelled' + | 'applying' + | 'reconciling' + | 'verifying' + | 'verified' + | 'drifted_no_apply' + | 'not_applied' + | 'verification_failed' + | 'failed_rolled_back' + | 'applied_with_drift' + | 'outcome_unknown' + +export type MigrationPlanObjectRef = Readonly<{ + database: string | null + schema_name: string | null + table_name: string | null + column_name: string | null +}> + +export type MigrationPlanRisk = Readonly<{ + severity: 'safe' | 'warning' | 'destructive' + lock_mode: string + possible_rewrite: boolean + table_scan: boolean + data_loss: boolean + detail: string +}> + +export type MigrationPlanStatement = Readonly<{ + kind: string + target: string + object_ref: MigrationPlanObjectRef + sql: string + transactional: boolean + dependencies: ReadonlyArray + dependency_refs: ReadonlyArray + reversible: boolean + risk: MigrationPlanRisk + required_privileges: ReadonlyArray + preconditions: ReadonlyArray>> +}> + +export type MigrationPlanBlocker = Readonly<{ + code: string + object: string + object_ref: MigrationPlanObjectRef + detail: string +}> + +export type MigrationPlan = { + migration_plan_uuid: string + project_space_uuid: string + schema_model_revision_uuid: string + db_connection_uuid: string + base_schema_snapshot_uuid: string + plan_digest: string + base_digest: string + target_digest: string + compiler_version: string + snapshot_contract_version: number + postgresql_major: number + created_by_user_uuid: string + created_at: string + can_dry_run: boolean + requires_destructive_confirmation: boolean + statements: ReadonlyArray + proposed_statements: ReadonlyArray + blockers: ReadonlyArray + risk_summary: Readonly<{ safe: number; warning: number; destructive: number }> + expires_at: string +} + +export type MigrationRunAction = { + migration_run_uuid: string + state: MigrationRunState + state_version: number + cancellation_requested: boolean + reused: boolean +} + +export type MigrationRunEvent = { + sequence_number: number + event_type: string + state_before: string | null + state_after: string + evidence: Readonly> + previous_event_digest: string | null + event_digest: string + actor_user_uuid: string | null + created_at: string +} + +export type MigrationRun = { + migration_run_uuid: string + project_space_uuid: string + migration_plan_uuid: string + run_kind: 'dry_run' | 'apply' + state: MigrationRunState + state_version: number + plan_digest: string + requested_by_user_uuid: string + cancellation_requested: boolean + observed_base_digest: string | null + evidence: Readonly> + error_code: string | null + created_at: string + updated_at: string + started_at: string | null + finished_at: string | null + events: ReadonlyArray +} + +export type MigrationApplyIntent = { + plan_digest: string + passed_dry_run_uuid: string + target_connection_name: string + destructive_acknowledged: boolean +} + export function snapshotDetailFromResponse(response: SnapshotDetailResponse): SnapshotDetail { return { ...response, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index a05b1e60e..d7e0fa48b 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig, mergeConfig } from 'vitest/config' -import viteConfig from './vite.config' +import viteConfig from './vite.config.ts' export default mergeConfig(viteConfig, defineConfig({ test: { + fileParallelism: false, + testTimeout: 15000, coverage: { provider: 'v8', reporter: ['text', 'json', 'json-summary', 'html'],