# Plan: ADR-0035 Phase 4, sub-phase 4g — `ALTER TABLE` add/drop constraint + add FK Add the advanced-mode SQL forms: - `ALTER TABLE ADD [CONSTRAINT ] CHECK ()` — table-level CHECK, **named or unnamed**. - `ALTER TABLE ADD UNIQUE (, …)` — composite UNIQUE (unnamed; see §2.4). - `ALTER TABLE ADD [CONSTRAINT ] FOREIGN KEY () REFERENCES

[()] [ON DELETE …] [ON UPDATE …]` — a relationship. - `ALTER TABLE DROP CONSTRAINT ` — drop a **named CHECK** or a **named FK** (relationship). Plus: fold the internal-`__rdbms_*` guard into `do_add_constraint` / `do_add_relationship` (the remaining executors of the 4d/4e/4f guard class). **User-confirmed scope (2026-05-25):** full 4g in one slice; ADD = CHECK + FK + composite UNIQUE (PRIMARY KEY refused); DROP CONSTRAINT = named CHECK + named FK; named-CHECK round-trip via a **project.yaml format extension** (the rebuild-only db-column arrival — pre-4g projects gain the column on `rebuild`). ## 1. Baseline (at handoff) - After 4f: **1865 passing, 0 failed, 0 skipped, 1 ignored**; clippy clean. Branch `main`, HEAD `5b76315` (4f). ## 2. Decisions (settled — user-confirmed 2026-05-25) 1. **ADD scope = CHECK + FOREIGN KEY + composite UNIQUE.** `ADD PRIMARY KEY` is **refused** with a clear message (every playground table already has a PK; adding one is near-always invalid). PRIMARY KEY is not in the grammar's ADD-constraint surface. 2. **CHECK migration = rebuild-only.** The `CHECK_TABLE` (`__rdbms_playground_table_checks`) CREATE schema gains a nullable `name` column; fresh + rebuilt databases get it. A pre-4g project on disk keeps the 3-column table until `rebuild`. Unnamed table-CHECK creation continues to work on an old DB (its INSERT never names the column); a **named** CHECK add on an old DB is refused with a friendly "this project predates named constraints — run `rebuild` first" message (a single PRAGMA-guarded column-presence check, **not** an auto-migration). 3. **Named-CHECK round-trip = project.yaml format extension.** Because `rebuild` reconstructs from `project.yaml` (`do_rebuild_from_text` parses the yaml, wipes the db, re-emits DDL via `schema_to_ddl`), the CHECK *name* must live in `project.yaml`, not just the db column. `check_constraints: Vec` becomes `Vec` where `TableCheck { name: Option, expr: String }`. The yaml reader accepts **both** the old bare-string form (`- "expr"`, name = None) and the new mapping form (`- {expr: "…", name: "…"}`), per the established "optional on read" backward-compat convention. 4. **DROP CONSTRAINT scope = named CHECK + named FK.** Resolution order: look up `` in `CHECK_TABLE` (named table-CHECK) → drop it (rebuild without it + delete the row); else in `REL_TABLE` (named relationship) → drop it via the existing drop-relationship machinery; else refuse "no such constraint `` on ``". An unnamed / column-level / UNIQUE constraint is **not** a DROP CONSTRAINT target. 5. **Composite UNIQUE is unnamed (asymmetry, intentional).** A composite UNIQUE constraint carries no user-facing name in our model (PRAGMA-detected via the `origin='u'` auto-index — ADR-0035 §4a.2), so `ADD UNIQUE (cols)` creates an anonymous constraint and **a `CONSTRAINT ` prefix on UNIQUE is refused** ("naming a UNIQUE constraint is not supported — use `alter table add unique (cols)`"). It therefore cannot be a `DROP CONSTRAINT ` target. This is consistent: ADD-UNIQUE is in scope, DROP-UNIQUE was never in scope (no name to target). 6. **FK reuses the existing relationship machinery.** `ADD [CONSTRAINT ] FOREIGN KEY () REFERENCES

[()] …` decomposes to `add_relationship` (the same executor `add 1:n relationship` uses): parent-PK validation, bare-`REFERENCES

` → parent single-PK resolution, `fk_target_type` compatibility, auto-naming when unnamed, name-uniqueness, one undo step. No new FK executor. 7. **CREATE TABLE table-CHECKs stay unnamed (out of 4g).** Naming a CHECK declared *inside* `CREATE TABLE` is a separate consistency item; 4g only introduces names via `ALTER … ADD CONSTRAINT CHECK`. `do_create_table` writes `name = NULL` for its table-CHECKs. (Verify the CREATE grammar does not silently swallow a `CONSTRAINT ` on a table CHECK; if it parses one, it is currently dropped — leave as a noted follow-up, do not expand CREATE here.) 8. **Internal-`__rdbms_*` guard** folded into `do_add_constraint` and `do_add_relationship` (both the `table`/`parent_table` and the `child_table` for relationships). Closes the 4d/4e/4f guard class. ## 3. Phase 1 — Requirements checklist (4g) ### Round-trip backbone (named table-CHECK) - [ ] `CHECK_TABLE` CREATE schema gains nullable `name TEXT` (rebuild-only arrival). `read_table_checks` reads `(name, check_expr)` ordered by `seq`. `ReadSchema.check_constraints: Vec`. - [ ] `schema_to_ddl` emits `CONSTRAINT CHECK ()` when named, bare `CHECK ()` when not. - [ ] `persistence::TableSchema.check_constraints: Vec`; the db→persistence capture (db.rs:~2549) carries names. - [ ] yaml **writes** the mapping form for named, bare string for unnamed; yaml **reads** both old (bare string) and new (mapping) forms. - [ ] `do_create_table` writes `name = NULL` for its table-CHECKs. - [ ] Round-trip test: a named table-CHECK survives save→load and `rebuild`; an old-format yaml (bare strings) still loads. ### Grammar / dispatch - [ ] `AlterTableAction` gains `AddTableConstraint { name: Option, constraint: TableConstraint }` and `DropConstraint { name: String }`. - [ ] New `TableConstraint` enum: `Check { expr_sql: String }` (raw text — `sql_expr` is validate-only), `Unique { columns: Vec }`, `ForeignKey(Box)` (reuse 4b struct). - [ ] Grammar: `AT_ADD_TABLE_CONSTRAINT` (`add [constraint ] (check (…) | unique (…) | foreign key (…) references …)`) and `AT_DROP_CONSTRAINT` (`drop constraint `), added to `AT_ACTION_CHOICES`. Reuse `sql_create_table`'s table-element nodes for CHECK / UNIQUE / FK where possible. - [ ] Builder discrimination order in `build_sql_alter_table`: `type` → (`column` ⇒ add/rename/drop **column**) → `add` ⇒ add-table-constraint → `drop` ⇒ drop-constraint. (Checking `column` before the bare `add`/`drop` keeps `add column … unique`/`… check` routing to AddColumn.) - [ ] Sub-discriminate the table-constraint by `check` / `unique` / `foreign`. A `CONSTRAINT ` on UNIQUE refuses (§2.5). - [ ] Trailing `;` tolerated; four existing AlterTableAction branches still route; `alter` stays advanced-only; table slot rejects `__rdbms_*` at parse. ### Execution - [ ] **ADD CHECK** (named/unnamed): dry-run guard (existing rows satisfy the CHECK — reuse `dry_run_check`), rebuild with the table-CHECK in the DDL, write the `CHECK_TABLE` row (`table_name, seq=next, check_expr, name`); auto-show; one undo step. Named add on an old DB (no `name` column) → friendly rebuild-needed refusal. - [ ] **ADD UNIQUE (cols)**: dry-run guard (no duplicate tuples — reuse `dry_run_unique`/the composite equivalent), rebuild adding the composite UNIQUE; one undo step. Survives rebuild (existing `unique_constraints` yaml path). - [ ] **ADD FOREIGN KEY**: decompose to `add_relationship` (name, parent, child, actions, `create_fk = true`?). Reuse 4b resolution (bare `REFERENCES

`, self-ref, type compat). One undo step. - [ ] **DROP CONSTRAINT **: resolve name in `CHECK_TABLE` then `REL_TABLE`; drop accordingly; refuse unknown. One undo step. - [ ] Internal-`__rdbms_*` guard in `do_add_constraint` / `do_add_relationship` (both surfaces) + a test. ### Testing - [ ] **Tier 1** (`sql_alter_table_tests` in ddl.rs): parse each new form → the right `AlterTableAction`; the six-branch dispatch still routes the four column actions; named-UNIQUE refusal; `ADD PRIMARY KEY` refusal. - [ ] **Tier 2/round-trip** (persistence/yaml unit tests): named CHECK serialize + parse; old bare-string parse; `Vec` save/load. - [ ] **Tier 3** (`tests/sql_alter_table.rs` via `run_replay`): ADD named CHECK enforced + survives rebuild with its name; ADD UNIQUE enforced + survives rebuild; ADD FOREIGN KEY creates the relationship; DROP CONSTRAINT removes a named CHECK and a named FK; DROP unknown refused; one undo step each. - [ ] **Internal guard** (`tests/column_op_guards.rs`): simple `add_constraint` / `add_relationship` on `__rdbms_*` refused. - [ ] **Catalog** lockstep + vocab audit for the refreshed `sql_alter_table` help/usage (now listing add/drop constraint + add FK). ## 4. Architecture & change list (file by file) - **`src/persistence/mod.rs`**: add `pub struct TableCheck { pub name: Option, pub expr: String }`; `TableSchema.check_constraints: Vec`. Update the `csv_io.rs` / `mod.rs` constructors that set `check_constraints: Vec::new()` (type still compiles — empty Vec). - **`src/persistence/yaml.rs`**: serialize a `TableCheck` as a bare string when `name` is None, else a `{expr, name}` mapping; parse both forms (back-compat). Update the parser struct + the round-trip tests. - **`src/db.rs`**: - `CHECK_TABLE` CREATE schema += `name TEXT` (nullable). - `read_table_checks` → `Vec` (read name; tolerate a missing `name` column on an old DB via a column-presence check → name = None). - `ReadSchema.check_constraints: Vec`; `schema_to_ddl` emits `CONSTRAINT ` when named. - db→persistence capture (≈2549) maps `Vec`. - `build_read_schema` (yaml variant, ≈8125) maps persistence `TableCheck` → `ReadSchema` `TableCheck`. - `do_create_table` table-CHECK INSERT writes `name = NULL`. - New executors: `do_alter_add_table_check`, `do_alter_add_unique`, `do_drop_constraint_by_name`. Worker methods + `Request` variants + handler dispatch (wrapped in `snapshot_then`). - `reject_internal_table_name` at the top of `do_add_constraint` / `do_add_relationship` (+ child_table for the latter). - A `check_table_has_name_column(conn)` helper for the rebuild-needed refusal. - **`src/dsl/command.rs`**: `AlterTableAction::{AddTableConstraint, DropConstraint}`; `TableConstraint` enum. - **`src/dsl/grammar/ddl.rs`**: `AT_ADD_TABLE_CONSTRAINT`, `AT_DROP_CONSTRAINT`, builder branches + sub-discrimination, the named-UNIQUE / ADD-PRIMARY-KEY refusals. - **`src/dsl/grammar/sql_create_table.rs`**: expose the table-CHECK / UNIQUE / FK element nodes for reuse if not already `pub(crate)`. - **`src/runtime.rs`**: `SqlAlterTable` arm → the new executors; `AddTableConstraint::ForeignKey` → `add_relationship`. - **`src/app.rs`**: `build_translate_context` arms for the two new actions (Operation::AddConstraint / DropConstraint / AddRelationship). - **`src/friendly/{keys.rs,strings/en-US.yaml}`**: refresh `sql_alter_table` help/usage; any new refusal message keys. ## 5. Phase 2 — Candidate approaches (key forks) **Round-trip representation.** (R1) `Vec` threaded through ReadSchema + persistence *(lead — single source of truth, clean rebuild)*. (R2) parallel `Vec>` names alongside the existing `Vec` — *rejected* (two vectors to keep aligned, error prone). (R3) store names only in the db, not yaml — *rejected* (names lost on rebuild; breaks DROP CONSTRAINT after rebuild). **Executor structure.** (E1) one `do_alter_add_table_check` + one `do_alter_add_unique` + FK via `add_relationship` + one `do_drop_constraint_by_name` *(lead — each maps to one rebuild, mirrors the 4e/4f decomposition)*. (E2) a single mega-executor switching on a constraint enum — *rejected* (a fat function; the three adds have genuinely different dry-run guards + metadata writes). **Grammar.** (G1) separate Choice branches `AT_ADD_TABLE_CONSTRAINT` / `AT_DROP_CONSTRAINT` added to `AT_ACTION_CHOICES`, builder discriminates by `column` then `add`/`drop` then the constraint keyword *(lead — consistent with the existing five branches; reuses the create-table element nodes)*. (G2) a nested sub-Choice under a single `add` branch — *rejected* (complicates the builder more than separate branches). ## 6. Phase 3 — Selection R1 + E1 + G1. Satisfies every §3 item with the smallest faithful change: the round-trip backbone is a typed extension (not a parallel array), the executors each reduce to one rebuild + one metadata write (one undo step), and the grammar mirrors the established branch structure. The named-UNIQUE refusal and ADD-PRIMARY-KEY refusal keep the surface honest about what the model can persist. ## 7. Devil's Advocate review of this plan - **Forks escalated?** ADD scope, the migration approach, and DROP scope were put to the user (2026-05-25) and answered (CHECK+FK+UNIQUE / rebuild-only / CHECK+FK). The newly-discovered yaml-format-change implication was surfaced and the user chose "Full 4g now". The named-UNIQUE-refusal and CREATE-CHECK-stays-unnamed micro-decisions are consequences of the model (anonymous composite UNIQUE; ALTER-only naming) — noted here, to be confirmed in the combined `/runda`. ✓ - **Back-compat of the yaml change?** The reader accepts both the bare string and the mapping form; a test covers an old-format file. The field stays "optional on read". ✓ - **Old-DB named-CHECK add?** Guarded by a column-presence check → a friendly engine-neutral rebuild-needed refusal, not a raw engine error (ADR-0035 §9). Unnamed CHECK adds keep working on an old DB. ✓ - **One undo step each?** Each add/drop is one executor call = one rebuild = one snapshot, like 4e/4f. e2e undo checks. ✓ - **Grammar trap?** Six concrete-keyword-led branches; the builder keys on `column` (column ops) then `add`/`drop` then the constraint keyword. `add column … unique/check` still routes to AddColumn (checked via `column` first). A parse test for every branch + the discrimination edges. ✓ - **Engine neutrality?** New refusal messages say "the database" / "constraint" in the abstract; vocab audit + catalog lockstep tests guard it. ✓ - **Anything dropped?** ADD PRIMARY KEY (refused, stated), named UNIQUE (refused, stated), CREATE-TABLE CHECK naming (out of scope, noted). No silent drops. ## 8. Implementation sequence (test-first) 1. **Internal guards** — `reject_internal_table_name` in `do_add_constraint` / `do_add_relationship`; `column_op_guards.rs` tests (red → green). Isolated, lands first. 2. **Round-trip backbone** — `TableCheck` type; `CHECK_TABLE` +`name`; `read_table_checks` / `ReadSchema` / `schema_to_ddl` / capture / `do_create_table` / yaml serialize+parse. Persistence/yaml round-trip tests (incl. old-format read). No behaviour change yet (all CHECKs still unnamed until the grammar lands) → full suite stays green. 3. **Command + grammar + builder** — the two actions + `TableConstraint`; `AT_ADD_TABLE_CONSTRAINT` / `AT_DROP_CONSTRAINT`; the discrimination + refusals; Tier-1 parse tests → exhaustive arms (compiler) → green (parse only). 4. **Executors + runtime + catalog** — `do_alter_add_table_check`, `do_alter_add_unique`, `do_drop_constraint_by_name`, FK via `add_relationship`; wire `SqlAlterTable`; refresh help/usage; Tier-3 e2e (ADD CHECK/UNIQUE/FK, DROP CHECK/FK, refusals, rebuild survival, undo) → green. 5. **Full sweep** — `cargo test` (no regression from 1865) + `cargo clippy --all-targets -- -D warnings`. 6. **Docs** — ADR-0035 Status + §13 4g; README; requirements Q1. Defer the formal `/runda` to the combined pass (user steer). Propose commit; wait for approval. ## 9. Exit gate - All §3 items satisfied; all tiers green, zero skips; no regression from 1865; written-DA PASS (combined `/runda` to follow); clippy clean; ADR-0035 §13 4g + README + requirements.md lockstep.