Files
rdbms-playground/docs/plans/20260525-adr-0035-sql-ddl-4d.md
T
claude@clouddev1 701217d29f feat: ADR-0035 4d — CREATE [UNIQUE] INDEX / DROP INDEX
Advanced-mode SQL CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON
<T> (cols) -> SqlCreateIndex and DROP INDEX [IF EXISTS] <name> ->
SqlDropIndex, both reusing the ADR-0025 executors (do_add_index /
do_drop_index), like 4c reused do_drop_table.

- CREATE UNIQUE INDEX admitted in advanced mode (ADR-0025 Amendment 1):
  ADR-0025 deferred UNIQUE indexes for the simple-mode DSL, but advanced
  mode trusts the user like SQL does. Adds an additive IndexSchema.unique
  flag (project.yaml, serde-default, version stays 1); rebuild re-emits
  CREATE UNIQUE INDEX; the redundant-set guard keys on (columns, unique).
  Simple-mode `add unique index` stays deferred.
- IF [NOT] EXISTS on both forms reuses the 4c no-op-with-note skip
  (journalled, not snapshotted) via CreateIndexOutcome / DropIndexOutcome.
- Unnamed CREATE INDEX auto-named (ADR-0025 convention); the [UNIQUE]
  prefix is a concrete-keyword Choice and the optional name an on-led-first
  selector (the drop-index selector precedent) — trap-safe.
- create/drop each gain a second advanced node; the existing all-candidates
  dispatch handles it (locked by parse tests).
- Unique indexes marked [unique] in the structure view and items panel.
- do_add_index refuses internal __rdbms_* tables as "no such table",
  closing a latent exposure on both the simple `add index` and the new
  SQL CREATE INDEX surfaces (ADR-0025 Amendment 1).

Docs: ADR-0035 status + §13 4d + 4i; ADR-0025 Amendment 1; ADR README;
requirements.md Q1/C3. Plan: docs/plans/20260525-adr-0035-sql-ddl-4d.md.

Tests: 1834 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
2026-05-25 18:54:32 +00:00

24 KiB
Raw Permalink Blame History

Plan: ADR-0035 Phase 4, sub-phase 4d — CREATE [UNIQUE] INDEX / DROP INDEX

Add advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [] ON

(, …)` → `SqlCreateIndex` and `DROP INDEX [IF EXISTS] ` → `SqlDropIndex` (ADR-0035 §1/§4/§13). Both reuse the existing ADR-0025 low-level executors (`do_add_index` / `do_drop_index`), like 4c reused `do_drop_table`. Two genuinely new things beyond 4c's pattern:
  1. UNIQUE index → an IndexSchema.unique model extension (the escalation; user-approved 2026-05-25). ADR-0025 deferred UNIQUE indexes for the simple-mode DSL (add unique index); ADR-0035 §4 supersedes that for the advanced SQL surface. Simple-mode add unique index stays deferred. An ADR-0025 Amendment records this.
  2. A second advanced node per entry word. create gains SQL_CREATE_INDEX alongside SQL_CREATE_TABLE; drop gains SQL_DROP_INDEX alongside SQL_DROP_TABLE. The dispatcher already tries all advanced candidates (decide: advanced.chain(simple) + the first-full-match loop), so no dispatch change is needed — but a parse test locks it.

IF [NOT] EXISTS on both forms (user-approved 2026-05-25), reusing the 4c no-op-with-note skip path.

1. Baseline

  • Tests: 1805 passing, 0 failing, 0 skipped, 1 ignored (the friendly/mod.rs ignore doctest); clippy clean. Branch main, HEAD e52e90c (4c), 3 local-only commits since origin/main. 4d starts here.

2. Decisions (settled — escalated + user-confirmed 2026-05-25)

  1. CREATE UNIQUE INDEX is in; the model extension lands now. IndexSchema gains unique: bool. ADR-0025's UNIQUE-index exclusion was a statement about the simple-mode DSL teaching surface (the only surface that existed on 2026-05-16); advanced mode "trusts the user like SQL does" (ADR-0035 §7), so the concept-separation argument does not transfer. The constraint track it deferred to (ADR-0018 → 0029 → 0035 4a.2) has since shipped. ADR-0025 gets an Amendment; simple-mode add unique index stays deferred (not in this slice).
  2. IF [NOT] EXISTS on both forms — universal cross-vendor idiom (ADR-0035 §4 reclassification), reusing the 4c CreateOutcome/DropOutcome-style no-op-with-note. Skip is journalled, not snapshotted.
  3. Reuse the ADR-0025 executors. do_add_index / do_drop_index are the single executors; the SQL path differs only at the unique flag and the IF [NOT] EXISTS skip branch. No fork.
  4. DROP INDEX <name> is name-only. SQL has no positional on T (cols) drop form (that is the simple DSL drop index on …, which keeps falling back to the simple drop node). SqlDropIndex carries { name, if_exists }; the worker builds IndexSelector::Named { name } for do_drop_index.
  5. Unnamed CREATE INDEX is supported and auto-named (ADR-0035 §4 [<name>]; the ADR-0025 <T>_<cols>_idx convention). A playground convenience over SQLite (which requires a name). See §4.1 for the grammar-disambiguation risk this carries.

3. Phase 1 — Requirements checklist (4d)

Grammar / parse:

  • CREATE INDEX <name> ON <T> (<cols>) parses in advanced mode → SqlCreateIndex { unique: false, if_not_exists: false }.
  • CREATE UNIQUE INDEX <name> ON … sets unique: true.
  • CREATE INDEX ON <T> (<cols>) (no name) parses → name: None (auto-named at execution). The optional name must not swallow on.
  • CREATE [UNIQUE] INDEX IF NOT EXISTS <name> ON … sets if_not_exists: true.
  • DROP INDEX <name>SqlDropIndex { if_exists: false }; DROP INDEX IF EXISTS <name>if_exists: true.
  • Trailing ; tolerated on both (ADR-0035 §12).
  • Dispatch (second advanced node): in advanced mode create table …SqlCreateTable, create [unique] index …SqlCreateIndex; drop table …SqlDropTable, drop index …SqlDropIndex. Simple add index / drop index on T(…) / drop column etc. still parse (fallback).

Execution / model:

  • CREATE INDEX creates a plain index (parity with add index); one undo step; undo removes it.
  • CREATE UNIQUE INDEX creates a unique index; it round-trips through project.yaml (the unique flag) and survives rebuild (re-emitted as CREATE UNIQUE INDEX); enforces uniqueness (a duplicate insert is refused by the engine).
  • DROP INDEX removes the index; one undo step; undo restores it; the affected table's structure is auto-shown (ADR-0014).
  • IF NOT EXISTS on an existing index name → success + note, no error, no snapshot, journalled. IF EXISTS on an absent index → ditto.
  • Plain CREATE INDEX on a duplicate name / DROP INDEX on an unknown name → the existing friendly errors (unchanged).
  • Redundant-column-set guard refined for uniqueness: a plain and a unique index over the same columns are not mutual duplicates (different semantics); an exact duplicate (same columns and same uniqueness) is still refused. (Test must use explicit distinct names for the plain+unique pair — the auto-name <T>_<cols>_idx is identical for both, so an unnamed second would collide on the name guard regardless. DA finding D.)
  • CREATE UNIQUE INDEX on a column that already holds duplicate values is refused by the engine at creation; the failure routes through the friendly layer, engine-neutral. (Test.)
  • IF NOT EXISTS only short-circuits a name collision into a skip; a different-named but redundant-column-set create still hits the ADR-0025 redundant-set refusal (the playground's pedagogical guard, not raw-SQL semantics). (Test + one-line doc note. DA finding H.)
  • Errors/notes engine-neutral.

Persistence round-trip:

  • IndexSchema.unique round-trips: save → YAML unique: true → load → identical snapshot. Older project files (no unique field) default to false (#[serde(default)]); version stays 1.
  • The rebuild-table primitive preserves a unique index's uniqueness (it re-creates captured indexes; IndexInfo.unique already read — verify the re-emit honours it).

Display (unique marker — user-confirmed for 4d):

  • Structure view marks a unique index — … (cols) [unique] — and leaves a plain index unmarked.
  • Items list (left panel) marks a unique index name.

Testing

  • Tier 1 (in-crate sql_create_index_tests / sql_drop_index_tests in ddl.rs, mirroring sql_drop_table_tests): the parse + flag cases above + the dispatch (second-advanced-node) cases.
  • Tier 3 (tests/sql_create_index.rs, tests/sql_drop_index.rs): create plain/unique, the YAML round-trip + rebuild survival of a unique index, uniqueness enforcement, IF [NOT] EXISTS skip + journal, plain duplicate/unknown errors, one undo step + restore, drop auto-show.
  • Tier 2 (insta): a project.yaml snapshot carrying a unique: true index, if an existing yaml snapshot test covers indexes (extend it; else a focused new one).
  • Catalog lockstep + vocab audit for the new note + help/usage keys.

4. Architecture & design

4.1 Grammar (src/dsl/grammar/ddl.rs)

DROP INDEX (easy, mirrors SQL_DROP_TABLE).

  • SQL_DROP_INDEX_SHAPE = Seq[ Word("index"), SQL_DROP_IF_EXISTS_OPT, INDEX_NAME_EXISTING, Optional(Punct(';')) ]. Reuse the existing SQL_DROP_IF_EXISTS_OPT and INDEX_NAME_EXISTING nodes.
  • pub static SQL_DROP_INDEX: CommandNode { entry: "drop", shape, ast_builder: build_sql_drop_index, help_id, usage_ids }.
  • build_sql_drop_index: name = require_ident("index_name"), if_exists = path.contains_word("if").

CREATE INDEX. The shape, after the create entry word, must lead with a concrete keyword (the leading-Optional trap, handoff §3 / pattern 5). Both sub-problems are settled by existing precedent (DA pass verified against the walker code, not reasoned):

  • [UNIQUE] optional prefix. Lead with a Choice whose every branch starts on a concrete keyword: UNIQUE_INDEX_LEAD = Choice[ Seq[Word("unique"), Word("index")], Word("index") ]. A leading Choice of concrete-keyword branches is exactly the sanctioned pattern (the §3 rule forbids a leading Optional, not a Choice); unique presence is read in the builder via path.contains_word("unique"). Smoke-probe only.
  • Optional [<name>] before on — use the DI_SELECTOR precedent, not a bare Optional. Verified: walk_identconsume_ident does not reject reserved keywords, so a bare Optional(<name ident>) would greedily eat the on keyword and break the unnamed form. The drop-index selector already solves this exact shape: a Choice with the on-led branch first, relying on Choice backtracking. Mirror it:
    • CI_UNNAMED = Seq[ Word("on"), TABLE_NAME_EXISTING, Punct('('), INDEX_COLUMN_LIST, Punct(')') ]
    • CI_NAMED = Seq[ INDEX_NAME_NEW, Word("on"), TABLE_NAME_EXISTING, Punct('('), INDEX_COLUMN_LIST, Punct(')') ]
    • CI_SELECTOR = Choice[ CI_UNNAMED, CI_NAMED ] (unnamed first, like DI_SELECTOR's DI_POSITIONAL-first). create index on T (c)CI_UNNAMED (no name-slot to eat on); create index ix on T (c)CI_UNNAMED fails at Word(on) vs ix, backtracks to CI_NAMED.
  • Full shape: Seq[ UNIQUE_INDEX_LEAD, SQL_CREATE_INDEX_IF_NOT_EXISTS_OPT, CI_SELECTOR, Optional(Punct(';')) ]. The IF NOT EXISTS opt is mid-Seq (not leading) — exactly like 4c's SQL_DROP_TABLE IF EXISTS opt, so it is trap-safe.
  • build_sql_create_index: unique = contains_word("unique"), if_not_exists = contains_word("if"), name = optional_ident( "index_name") (present iff the CI_NAMED branch matched), table = require_ident("table_name"), columns from the list.

The single-node form is now the primary approach (no probe gamble): the two-node TABLE_FK-style split is not needed — kept only as a contingency if the smoke-probe of the leading Choice surprises.

REGISTRY (mod.rs): add (&ddl::SQL_CREATE_INDEX, CommandCategory::Advanced) and (&ddl::SQL_DROP_INDEX, CommandCategory::Advanced). Verify decide tries all advanced candidates (it does — locked by a parse test).

4.2 Command (src/dsl/command.rs)

  • SqlCreateIndex { name: Option<String>, table: String, columns: Vec<String>, unique: bool, if_not_exists: bool } — verb "create index", target_table()table.
  • SqlDropIndex { name: String, if_exists: bool } — verb "drop index", target_table()name (the index name is the identifying thing for logging; mirrors DropIndex/SqlDropTable).
  • Exhaustive-match fallout (compiler-found): verb, target_table in command.rs; app.rs failure-translate; runtime.rs dispatch + outcome→event; tests/typing_surface/mod.rs command_label arms (SqlCreateIndex/SqlDropIndex, alongside the existing SqlCreateTable/SqlDropTable). Add the SQL index forms to the tests/typing_surface/index_ops.rs matrix (+ insta snaps) next to the DSL add index/drop index cases.
  • IndexSchema struct-literal fallout (compiler-found, the new unique field): db.rs:2414, yaml.rs:299, the yaml round-trip test (~478) — each adds unique: ….

4.3 Model extension — IndexSchema.unique (src/persistence/, src/db.rs)

  • persistence/mod.rs: IndexSchema gains pub unique: bool (doc-note mirroring TableSchema.unique_constraints: additive, defaults false for older files, version stays 1).
  • db.rs read_schema_snapshot (~2414): carry idx.unique (already read into IndexInfo by read_table_indexes).
  • yaml.rs: the raw deserialize struct gains #[serde(default)] unique: bool; the reader (~299) carries it; write_index (~66) emits unique: true only when true (omit when false — keeps existing project files byte-stable and matches the minimal-noise house style; confirm against any golden-yaml test).
  • db.rs rebuild_from_text (~7814): emit CREATE UNIQUE INDEX when index.unique, else CREATE INDEX.
  • do_add_index gains unique: bool: emit CREATE UNIQUE INDEX when set; refine the redundant-set guard to compare (columns, unique) so a plain vs unique index over the same columns is not a false duplicate. Simple add index passes unique: false (call-site update in the worker arm).

4.4 Worker (src/db.rs)

  • Request::SqlCreateIndex { name, table, columns, unique, if_not_exists, source, reply: oneshot<Result<CreateIndexOutcome>> } + db.sql_create_index(...).
    • Skip branch: resolve the index name (given, or the <T>_<cols>_idx auto-name — extract a tiny resolve_index_name helper shared with do_add_index), and if if_not_exists && index_name_exists(resolved) → journal the line (no snapshot), reply CreateIndexOutcome::Skipped( resolved). Else snapshot_then(do_add_index(..., unique) → Created(desc)).
  • Request::SqlDropIndex { name, if_exists, source, reply: oneshot<Result<DropIndexOutcome>> } + db.sql_drop_index(...).
    • Skip branch: if if_exists && !index_name_exists(name) → journal (no snapshot), reply Skipped. Else snapshot_then(do_drop_index( IndexSelector::Named { name }) → Dropped(desc)).
  • New outcome enums (the index peers of CreateOutcome/DropOutcome):
    • CreateIndexOutcome { Created(TableDescription), Skipped(String) } (skip carries the resolved name — the auto-name is unknown to the command for the unnamed form).
    • DropIndexOutcome { Dropped(TableDescription), Skipped } (drop-skip note uses the command's name directly).
  • index_name_exists(conn, name) helper (the sqlite_master WHERE type='index' AND name=? lookup do_add_index step 5 already does).

4.5 Runtime + event + app

  • runtime.rs: SqlCreateIndexdb.sql_create_indexCreated(d)CommandOutcome::Schema(Some(d)), Skipped(name) → new CommandOutcome::SchemaCreateIndexSkipped(name). SqlDropIndexDropped(d)Schema(Some(d)) (auto-show the de-indexed table), Skipped → new CommandOutcome::SchemaDropIndexSkipped.
  • event.rs: DslCreateIndexSkipped { command, name }, DslDropIndexSkipped { command }.
  • app.rs: note ddl.create_index_skipped_exists (name from the event) / ddl.drop_index_skipped_absent (name from command.target_table()). Both note-only, no structure (mirrors the drop-table skip — the index already exists / never existed; showing the whole table is noise). Add the failure-translate arms for the two new commands.

4.7 Unique-index display (user-confirmed for 4d, 2026-05-25)

A learner must be able to tell a UNIQUE index from a plain one.

  • Structure view (cheap — IndexInfo.unique already populated): output_render.rs:143 appends a [unique] marker when index.unique, e.g. Customers_email_idx (Email) [unique]. A render test + any insta snapshot update. No new threading.
  • Items list (left panel): SchemaCache.table_indexes is HashMap<String, Vec<String>> (names only). Carry uniqueness — change the value to a small { name, unique } entry (or Vec<(String, bool)>), populate it from the schema-cache refresh (the unique bit is on the read), and render name [unique] in ui.rs:516. Update the items_panel_nests_indexes_under_their_table test + any callers constructing table_indexes.
  • Marker wording engine-neutral (no PRAGMA/SQLite leakage) — [unique] is a plain English adjective, fine.

4.6 Friendly catalog (keys.rs + en-US.yaml)

New keys (engine-neutral; lockstep + vocab audit enforce both):

  • ddl.create_index_skipped_exists"index '{name}' already exists — skipped (no changes made)" (&["name"]).
  • ddl.drop_index_skipped_absent"index '{name}' doesn't exist — skipped (no changes made)" (&["name"]).
  • help.ddl.sql_create_index + parse.usage.sql_create_index, help.ddl.sql_drop_index + parse.usage.sql_drop_index (fresh nodes; their keys must exist now — unlike the deferred refresh of the create-table skeleton in 4i(a)).

5. Phase 2 — Candidate approaches

(C1) CREATE INDEX grammar — single node w/ leading Choice (lead). One SQL_CREATE_INDEX node; [UNIQUE] via a leading Choice of concrete-keyword-led branches; unique read in the builder.

  • Good: one node, one builder, one REGISTRY entry; matches how a single statement maps to a single command.
  • Risk: a leading Choice in a top-level shape is untested here; the unnamed-form on disambiguation. Both are probe-or-fall-back, not blockers.

(C2) CREATE INDEX grammar — two nodes split on unique/index (the 4c TABLE_FK split). SQL_CREATE_INDEX (index-led) + SQL_CREATE_INDEX_UNIQUE (unique-led).

  • Good: each node leads on a concrete keyword — guaranteed safe past the trap; create then has three advanced nodes, exercising the all-candidates dispatch even harder (a feature for the test).
  • Bad: duplicated shape/builder; more REGISTRY noise. Adopt only if C1's leading Choice probes badly.

(M1) Model extension — IndexSchema.unique flag (lead; the only real option). Mirrors TableSchema.unique_constraints exactly (additive, serde-default, version 1). The read side is already half-done (IndexInfo.unique). (M2) Separate __rdbms_playground_indexes metadata tablerejected. ADR-0025 deliberately stores nothing app-specific for indexes (SQLite owns the index namespace, incl. uniqueness via pragma_index_list). A metadata table would duplicate engine-owned state and create a consistency hazard. The flag is read straight from the pragma.

(S1) Skip plumbing — dedicated index skip outcomes/events (lead). New CreateIndexOutcome/DropIndexOutcome + DslCreate/DropIndexSkipped events + index-specific notes. Consistent with 4a/4c (one event per skip kind). (S2) Generalise the existing skip events to be object-agnosticrejected. Would rewrite 4a/4c wording/behaviour for marginal saving; churn on shipped code.

6. Phase 3 — Selection vs the checklist

C1 + M1 + S1 satisfy every §3 item: parse/flags (C1 grammar + builder), dispatch (REGISTRY + the existing all-candidates decide), execution (reuse do_add_index/do_drop_index + the unique param), persistence round-trip + rebuild survival (M1), skip semantics (S1 + the 4c skip branch), engine-neutral strings (catalog keys under the vocab audit). C1's two risks are bounded with C2 as the pre-identified fallback — neither leaves a requirement unmet. Selected: C1 (probe; C2 fallback) + M1 + S1.

7. Devil's Advocate review of this plan

  • The escalation actually escalated? Yes — IndexSchema.unique + the ADR-0025 supersession were put to the user (2026-05-25) and approved, with simple-mode add unique index explicitly kept deferred. The IF [NOT] EXISTS scope was also confirmed (both forms). No autonomous scope decision. ✓
  • Reuse vs fork? do_add_index/do_drop_index stay the single executors; the SQL path adds only unique + the skip branch. The redundant-set guard refinement is a correctness fix the model extension forces (plain vs unique are not duplicates), not a fork. ✓
  • Grammar risk owned — verified in code, not reasoned. (1) INDEX_NAME_EXISTING/TABLE_NAME_EXISTING have validator: None, so DROP INDEX IF EXISTS <absent> parses and reaches the skip path (no hard existence check at parse). (2) walk_identconsume_ident does not reject keywords, so a bare Optional(<name>) would eat on — hence the DI_SELECTOR-style on-led-first Choice (§4.1), proven by the shipped drop-index selector. (3) Trailing input after a matched shape is a Mismatch (not Match), and decide commits only on a full Match, so drop index on T (c) fails the name-only SQL_DROP_INDEX and falls back to the simple DI_POSITIONAL — the advanced-mode fallback for the positional drop is intact. A parse test for create index on T (c)name: None and a fallback test for drop index on T (c) (advanced) → simple DropIndex are on the checklist. ✓
  • Second advanced node verified, not assumed? decide is read (advanced.chain(simple) + first-full-match loop) and a dispatch parse test is on the checklist (create table/create index/drop table/drop index each to the right command in advanced mode). ✓
  • Round-trip + rebuild for the new flag? Explicit checklist items: YAML unique: true save/load identity, older-file default, rebuild re-emits CREATE UNIQUE INDEX, uniqueness actually enforced after rebuild. Two DDL generators stay in sync (pattern 1): do_add_index and rebuild_from_text both gain the UNIQUE emit. ✓
  • Undo parity? One step per statement (snapshot_then); skip is journalled-not-snapshotted (4c pattern). Undo tests for both create and drop. ✓
  • Anything silently dropped? Simple-mode add unique index is deferred by user decision, recorded in the ADR-0025 Amendment — not silently. The 4i list grows by one (help/usage skeleton refresh for the new index forms — but these nodes' keys land now, only the unified CREATE TABLE skeleton refresh stays in 4i(a)). Completion merge for the now-two-advanced-node create/drop widens 4i(d) — already tracked, user-confirmed deferred. ✓
  • describe/items-list of a unique index — RESOLVED (user chose "add in 4d", 2026-05-25). The structure view rendered every index as name (cols) with no uniqueness marker — a pedagogy gap ("can't tell a UNIQUE index from a plain one"). Now in scope: §4.7. Structure view is cheap (IndexInfo.unique already populated); the items list needs the SchemaCache.table_indexes value to carry the bit. Both on the checklist. ✓

8. Out of 4d scope (tracked, not dropped)

  • Simple-mode add unique index — deferred by user decision (ADR-0025 Amendment).
  • ALTER TABLE (4e4h).
  • 4i(a) CREATE TABLE help/usage skeleton refresh; 4i(b) describe of table-level constraints (composite UNIQUE + table CHECK) — the unique-index display moves into 4d (§4.7), but the table-level constraint display stays 4i(b); 4i(d) shared-entry-word completion merge (now widened by create/drop having two advanced nodes); 4i(e) the simple/advanced completion-colour UX discussion.

9. Implementation sequence (test-first)

  1. Model extension first (M1), isolated — add IndexSchema.unique + yaml round-trip + read_schema_snapshot + rebuild emit + do_add_index unique param (default-false call site) + the dup-guard refinement. Red tests: YAML round-trip of a unique index, rebuild survival, uniqueness enforced, plain≠unique not-a-duplicate → green. No SQL surface yet — this keeps the model change reviewable on its own and green before any grammar lands.
  2. DROP INDEX (simpler grammar) — Tier-1 parse + dispatch tests → command/grammar/builder/REGISTRY/worker(DropIndexOutcome+skip)/ runtime/event/app/catalog → Tier-3 (tests/sql_drop_index.rs) → green.
  3. CREATE INDEX — smoke-probe the leading Choice; Tier-1 parse + flag + dispatch tests (incl. create index on T (c)name: None) → command/grammar/builder/REGISTRY/worker(CreateIndexOutcome+skip)/ runtime/event/app/catalog → Tier-3 (tests/sql_create_index.rs, incl. unique create + round-trip + rebuild + IF NOT EXISTS skip + duplicate-data refusal) → green.
  4. Unique-index display (§4.7) — structure-view [unique] marker (render test) + items-list marker (SchemaCache.table_indexes carries the bit; ui test) → green.
  5. Full sweepcargo test (no regression from 1805) + cargo clippy --all-targets -- -D warnings.
  6. Docs — ADR-0035 Status note + §13 4d; ADR-0025 Amendment (the UNIQUE-index supersession for advanced mode) + README index-upkeep; requirements.md Q1/C3. Run /runda over this slice. Propose commit; wait for approval.

10. Exit gate

  • All §3 items satisfied; four tiers green, zero skips; no regression from the 1805 baseline; /runda / written-DA PASS; clippy clean; ADR-0025 Amendment + ADR-0035 §13 4d + README + requirements.md updated lockstep.