diff --git a/docs/superpowers/plans/2026-07-06-f4-schema-linking-column-curation-frontend.md b/docs/superpowers/plans/2026-07-06-f4-schema-linking-column-curation-frontend.md new file mode 100644 index 00000000..f7f35108 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-f4-schema-linking-column-curation-frontend.md @@ -0,0 +1,707 @@ +# F4 schema-linking column curation — Plan 1 (frontend + contract + replay) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render the F4 "tabelle/colonne/esclusioni" gate as a dedicated `schema-linking` widget: table rows with description + rationale, a per-table columns modal (checkbox, suggested pre-selected + bold), staged, emitting a structured response — verifiable offline in replay. + +**Architecture:** New inline gate widget (`SchemaLinkingGateWidget`) owns staged state (`enacted` set + per-table selected-columns map); a controlled modal (`SchemaColumnsDialog`) renders one table's columns. On the gate's Confirm the widget emits a structured `UiResponse.tables[]`. Phase 1 is frontend-only: no backend/harness wiring — the response is exercised through the zero-dep replay server, whose gate 7 is rewritten to the new descriptor from the real catalog. + +**Tech Stack:** React 18 + TypeScript, Vite, vitest + @testing-library/react + userEvent, base-ui Dialog (`components/ui/dialog`), Tailwind v3 (GSD tokens). Replay: zero-dep node (`tools/replay/`). + +## Global Constraints + +- `tsc` is the type gate — run `npx tsc -b` from `frontend/` before every commit (vitest does NOT type-check). +- Tests: vitest + MSW, **no network**. Single test run: `npx vitest run `. +- UI chrome/labels in **English**; data *content* (table/column names, Italian descriptions) stays as-is. +- Reuse look&feel v2 tokens only: cards `border-border/70` + `shadow-sm`, `.thot-label` (mono uppercase micro-label), radii `rounded-xl` cards / `rounded-lg` inputs / `rounded-md` controls / `2xl` dialogs; **no `#000`/`#fff`, no new color tokens**. First action renders as filled primary. +- No em dashes in copy you write (use commas/colons/parentheses). +- Response contract (verbatim): `{ id, kind: "schema-linking", tables: [{ id, enacted, columns? }] }`. `columns` present only for **enacted promote** tables; column order follows catalog order. +- Verify offline in **replay** only (no VPN): `tools/replay/` server on :5333. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `frontend/src/api/types.ts` (modify) | Add `SchemaColumn`, `SchemaTable`; extend `WidgetDescriptor.tables?`, `UiResponse.tables?`. | +| `frontend/src/widgets/SchemaColumnsDialog.tsx` (create) | Controlled modal listing one table's columns; checkbox first; bold-when-selected; `readOnly` hides checkboxes. | +| `frontend/src/widgets/SchemaColumnsDialog.test.tsx` (create) | Unit: bold on selected, toggle callback, readOnly non-selectable. | +| `frontend/src/widgets/SchemaLinkingGateWidget.tsx` (create) | Inline gate: staged `enacted`+`selected`, table rows, opens dialog, Confirm → structured response. | +| `frontend/src/widgets/SchemaLinkingGateWidget.test.tsx` (create) | Unit: staged suggestion, structured response, column toggle, enact toggle, reserved. | +| `frontend/src/widgets/index.ts` (modify) | `register("schema-linking", SchemaLinkingGateWidget)`. | +| `frontend/src/shell/WidgetHost.tsx` (modify) | Summarize the `schema-linking` response for `lastUserEntry`. | +| `tools/replay/schema-linking-fixture.json` (create, generated) | The 6-table `tables[]` array extracted from `physical.yaml` + session `schema_linking.json`. | +| `tools/replay/augment-schema-linking.mjs` (create) | Rewrite replay gate 7 → `schema-linking` descriptor. | + +--- + +## Task 1: Type contract + SchemaColumnsDialog + +**Files:** +- Modify: `frontend/src/api/types.ts` +- Create: `frontend/src/widgets/SchemaColumnsDialog.tsx` +- Test: `frontend/src/widgets/SchemaColumnsDialog.test.tsx` + +**Interfaces:** +- Produces: `SchemaColumn`, `SchemaTable` (in `api/types.ts`); `SchemaColumnsDialog(props)` where + `props = { table: SchemaTable; open: boolean; readOnly: boolean; selected: Set; onToggle: (column: string) => void; onClose: () => void }`. + +- [ ] **Step 1: Add the types** + +In `frontend/src/api/types.ts`, add above `WidgetDescriptor`: + +```ts +export interface SchemaColumn { + name: string; + description?: string; + type?: string; + pk?: boolean; + suggested?: boolean; +} + +export interface SchemaTable { + id: string; + name: string; + kind: "promote" | "exclude"; + recommended?: boolean; + description?: string; + rationale?: string; + columns: SchemaColumn[]; +} +``` + +Add `tables?: SchemaTable[];` to the `WidgetDescriptor` interface (before the `[k: string]: unknown` line), and add to `UiResponse`: + +```ts + tables?: { id: string; enacted: boolean; columns?: string[] }[]; +``` + +- [ ] **Step 2: Write the failing test** + +Create `frontend/src/widgets/SchemaColumnsDialog.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SchemaColumnsDialog } from "./SchemaColumnsDialog"; +import type { SchemaTable } from "../api/types"; + +const table: SchemaTable = { + id: "t-pat", + name: "dim_patient", + kind: "promote", + columns: [ + { name: "cod_paz", description: "Codice paziente", suggested: true }, + { name: "nome", description: "Nome", suggested: false }, + ], +}; + +test("selected column renders bold; unselected is normal; toggle fires", async () => { + const onToggle = vi.fn(); + render( + {}} + /> + ); + expect(screen.getByText("cod_paz").className).toContain("font-bold"); + expect(screen.getByText("nome").className).not.toContain("font-bold"); + await userEvent.click(screen.getByRole("checkbox", { name: "nome" })); + expect(onToggle).toHaveBeenCalledWith("nome"); +}); + +test("readOnly renders no checkboxes and no bold", () => { + render( + {}} + onClose={() => {}} + /> + ); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect(screen.getByText("cod_paz")).toBeInTheDocument(); + expect(screen.getByText("cod_paz").className).not.toContain("font-bold"); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/widgets/SchemaColumnsDialog.test.tsx` +Expected: FAIL — cannot resolve `./SchemaColumnsDialog`. + +- [ ] **Step 4: Implement the dialog** + +Create `frontend/src/widgets/SchemaColumnsDialog.tsx`: + +```tsx +import type { ReactElement } from "react"; +import type { SchemaTable } from "../api/types"; +import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog"; + +export function SchemaColumnsDialog({ + table, + open, + readOnly, + selected, + onToggle, + onClose, +}: { + table: SchemaTable; + open: boolean; + readOnly: boolean; + selected: Set; + onToggle: (column: string) => void; + onClose: () => void; +}): ReactElement { + return ( + { if (!o) onClose(); }}> + + {table.name} +
+
    + {table.columns.map((col) => { + const on = selected.has(col.name); + const strong = on && !readOnly; + return ( +
  • + {!readOnly && ( + onToggle(col.name)} + aria-label={col.name} + /> + )} +
    +
    + {col.name} +
    + {col.description ? ( +

    + {col.description} +

    + ) : null} +
    +
  • + ); + })} +
+
+
+
+ ); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/widgets/SchemaColumnsDialog.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 6: Typecheck + commit** + +Run: `cd frontend && npx tsc -b` +Expected: no errors. + +```bash +git add frontend/src/api/types.ts frontend/src/widgets/SchemaColumnsDialog.tsx frontend/src/widgets/SchemaColumnsDialog.test.tsx +git commit -m "feat(frontend): schema-linking types + columns modal" +``` + +--- + +## Task 2: SchemaLinkingGateWidget + +**Files:** +- Create: `frontend/src/widgets/SchemaLinkingGateWidget.tsx` +- Test: `frontend/src/widgets/SchemaLinkingGateWidget.test.tsx` + +**Interfaces:** +- Consumes: `WidgetProps` (`{ descriptor, onRespond }`), `SchemaTable`/`SchemaColumn` from `api/types.ts`, `SchemaColumnsDialog` from Task 1, `ReservedControls` from `./ReservedControls`. +- Produces: `SchemaLinkingGateWidget: React.FC` handling `descriptor.widget === "schema-linking"`, emitting `onRespond({ id, kind: "schema-linking", tables: [{ id, enacted, columns? }] })`. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/widgets/SchemaLinkingGateWidget.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget"; +import type { WidgetDescriptor } from "../api/types"; + +const descriptor: WidgetDescriptor = { + id: "u7", + widget: "schema-linking", + title: "F4 — Schema linking", + tables: [ + { + id: "t-pat", + name: "dim_patient", + kind: "promote", + recommended: true, + description: "Anagrafica", + rationale: "Output paziente", + columns: [ + { name: "cod_paz", description: "Codice", suggested: true }, + { name: "nome", description: "Nome", suggested: false }, + ], + }, + { + id: "x-sub", + name: "fact_sostituzione", + kind: "exclude", + recommended: true, + description: "Sostituzione", + rationale: "Non impianto iniziale", + columns: [{ name: "cod_paz" }], + }, + ], + reserved: ["back", "exit", "other"], +}; + +test("Confirm emits enacted tables with suggested columns (catalog order)", async () => { + const onRespond = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /confirm/i })); + expect(onRespond).toHaveBeenCalledWith({ + id: "u7", + kind: "schema-linking", + tables: [ + { id: "t-pat", enacted: true, columns: ["cod_paz"] }, + { id: "x-sub", enacted: true }, + ], + }); +}); + +test("selecting a column in the modal adds it to the response", async () => { + const onRespond = vi.fn(); + render(); + // dim_patient starts with 1 suggested of 2 -> its button reads "Colonne 1/2". + await userEvent.click(screen.getByRole("button", { name: /colonne 1\/2/i })); + await userEvent.click(screen.getByRole("checkbox", { name: "nome" })); + await userEvent.click(screen.getByRole("button", { name: /close/i })); // dialog X button + await userEvent.click(screen.getByRole("button", { name: /confirm/i })); + expect(onRespond).toHaveBeenCalledWith({ + id: "u7", + kind: "schema-linking", + tables: [ + { id: "t-pat", enacted: true, columns: ["cod_paz", "nome"] }, + { id: "x-sub", enacted: true }, + ], + }); +}); + +test("declining a table's enact checkbox drops its columns", async () => { + const onRespond = vi.fn(); + render(); + await userEvent.click(screen.getByRole("checkbox", { name: /dim_patient/i })); + await userEvent.click(screen.getByRole("button", { name: /confirm/i })); + expect(onRespond).toHaveBeenCalledWith({ + id: "u7", + kind: "schema-linking", + tables: [ + { id: "t-pat", enacted: false }, + { id: "x-sub", enacted: true }, + ], + }); +}); + +test("reserved control emits control", async () => { + const onRespond = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /go back/i })); + expect(onRespond).toHaveBeenCalledWith({ id: "u7", control: "back" }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/widgets/SchemaLinkingGateWidget.test.tsx` +Expected: FAIL — cannot resolve `./SchemaLinkingGateWidget`. + +- [ ] **Step 3: Implement the widget** + +Create `frontend/src/widgets/SchemaLinkingGateWidget.tsx`: + +```tsx +import { useState } from "react"; +import type { WidgetProps } from "./types"; +import type { SchemaTable } from "../api/types"; +import { SchemaColumnsDialog } from "./SchemaColumnsDialog"; +import { ReservedControls } from "./ReservedControls"; + +export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps) { + const tables: SchemaTable[] = descriptor.tables ?? []; + const [enacted, setEnacted] = useState>( + () => new Set(tables.filter((t) => t.recommended).map((t) => t.id)) + ); + const [selected, setSelected] = useState>>(() => { + const m = new Map>(); + for (const t of tables) { + m.set( + t.id, + new Set(t.kind === "promote" ? t.columns.filter((c) => c.suggested).map((c) => c.name) : []) + ); + } + return m; + }); + const [openId, setOpenId] = useState(null); + + function toggleEnact(id: string) { + setEnacted((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function toggleColumn(tableId: string, column: string) { + setSelected((prev) => { + const next = new Map(prev); + const set = new Set(next.get(tableId) ?? []); + if (set.has(column)) set.delete(column); + else set.add(column); + next.set(tableId, set); + return next; + }); + } + + function confirm() { + const payload = tables.map((t) => { + const on = enacted.has(t.id); + if (on && t.kind === "promote") { + const sel = selected.get(t.id) ?? new Set(); + return { id: t.id, enacted: true, columns: t.columns.filter((c) => sel.has(c.name)).map((c) => c.name) }; + } + return { id: t.id, enacted: on }; + }); + onRespond({ id: descriptor.id, kind: "schema-linking", tables: payload }); + } + + const openTable = tables.find((t) => t.id === openId) ?? null; + + return ( +
+ {descriptor.title && ( +

{descriptor.title}

+ )} + +
+ {tables.map((t) => { + const on = enacted.has(t.id); + const count = selected.get(t.id)?.size ?? 0; + return ( +
+
+ toggleEnact(t.id)} + aria-label={t.name} + /> +
+
+ {t.name} + {t.kind === "promote" ? "Promuovi" : "Escludi"} +
+ {t.description &&

{t.description}

} + {t.rationale &&

{t.rationale}

} +
+ +
+
+ ); + })} +
+ + {openTable && ( + toggleColumn(openTable.id, c)} + onClose={() => setOpenId(null)} + /> + )} + +
+ +
+ + onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })} + /> +
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/widgets/SchemaLinkingGateWidget.test.tsx` +Expected: PASS (4 tests). + +Note: the modal's X button (rendered by `DialogContent`, accessible name "Close") fires `onOpenChange(false)` → `onClose`; the widget's Confirm is only reachable once the modal is closed. + +- [ ] **Step 5: Typecheck + commit** + +Run: `cd frontend && npx tsc -b` +Expected: no errors. + +```bash +git add frontend/src/widgets/SchemaLinkingGateWidget.tsx frontend/src/widgets/SchemaLinkingGateWidget.test.tsx +git commit -m "feat(frontend): schema-linking gate widget with staged column curation" +``` + +--- + +## Task 3: Register widget + response summary + +**Files:** +- Modify: `frontend/src/widgets/index.ts` +- Modify: `frontend/src/shell/WidgetHost.tsx` + +**Interfaces:** +- Consumes: `SchemaLinkingGateWidget` (Task 2), the `UiResponse.tables` field (Task 1). +- Produces: registry mapping `"schema-linking" → SchemaLinkingGateWidget`; a `lastUserEntry` summary for the new kind. + +- [ ] **Step 1: Register the widget** + +In `frontend/src/widgets/index.ts`, add the import and registration (alongside the others): + +```ts +import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget"; +register("schema-linking", SchemaLinkingGateWidget); +``` + +- [ ] **Step 2: Write the failing test for the registry** + +Append to `frontend/src/widgets/registry.test.tsx` a case (adapt to that file's existing imports — it already imports `resolve` and the widgets via `./index`): + +```tsx +test("resolves the schema-linking widget", async () => { + await import("./index"); + const Comp = resolve("schema-linking"); + expect(Comp.name).toBe("SchemaLinkingGateWidget"); +}); +``` + +Run: `cd frontend && npx vitest run src/widgets/registry.test.tsx` +Expected: PASS (if `registry.test.tsx` imports `./index` already; if it imports widgets individually, mirror that pattern instead). If the file does not exist or the pattern differs, skip this micro-test and rely on the Task 4 replay check. + +- [ ] **Step 3: Summarize the response in WidgetHost** + +In `frontend/src/shell/WidgetHost.tsx`, replace the `setLastUserEntry({ ... })` call with: + +```tsx + setLastUserEntry({ + kind: "choice", + text: + r.kind === "schema-linking" + ? `${r.tables?.filter((t) => t.enacted).length ?? 0} tables curated` + : r.text ?? r.choices?.join(", ") ?? r.decision?.type ?? r.control ?? "(choice)", + }); +``` + +- [ ] **Step 4: Full suite + typecheck** + +Run: `cd frontend && npx vitest run && npx tsc -b` +Expected: whole suite green, no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/widgets/index.ts frontend/src/shell/WidgetHost.tsx frontend/src/widgets/registry.test.tsx +git commit -m "feat(frontend): register schema-linking widget + response summary" +``` + +--- + +## Task 4: Replay fixture + visual verification + +**Files:** +- Create: `tools/replay/schema-linking-fixture.json` (generated, committed) +- Create: `tools/replay/augment-schema-linking.mjs` +- Modify: `tools/replay/replay.json` (regenerated by the script) + +**Interfaces:** +- Consumes: the real catalog `physical.yaml` and the session `schema_linking.json` (workspace, local only) to produce the fixture; the `schema-linking` descriptor shape (Task 1). +- Produces: replay gate 7 rewritten so the widget renders in the browser. + +- [ ] **Step 1: Generate the fixture from the real catalog** + +Run (uses the harness venv for a YAML parser; paths are the psd workspace): + +```bash +cd /Users/mp/projects/ThothII && harness/.venv/bin/python - <<'PY' +import yaml, json +CAT = "/Users/mp/projects/tht-workspace-psd/artifacts/mschema/physical.yaml" +SL = "/Users/mp/projects/tht-workspace-psd/sessions/2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul/schema_linking.json" +tables_meta = yaml.safe_load(open(CAT))["tables"] +sl = json.load(open(SL)) +suggested = {} +for c in sl["candidates"]: + if c["kind"] == "column" and c.get("decision") == "promoted": + tbl, _, col = c["name"].partition(".") + suggested.setdefault(tbl, set()).add(col) +replay = json.load(open("tools/replay/replay.json")) +opts = replay["gates"][7]["descriptor"]["options"] +info = {o["decision"]["subject"]: o["decision"] for o in opts + if o["decision"]["type"] in ("table_promoted", "table_excluded")} +def build(tbl, kind, oid): + meta = tables_meta.get(tbl, {"comment": "", "columns": {}}) + sug = suggested.get(tbl, set()) + d = info.get(tbl, {}) + return { + "id": oid, "name": tbl, "kind": kind, "recommended": True, + "description": meta.get("comment") or d.get("detail", ""), + "rationale": d.get("rationale", ""), + "columns": [ + {"name": cn, "description": (cm.get("comment") or ""), + "type": cm.get("type"), "pk": bool(cm.get("pk")), + "suggested": cn in sug} + for cn, cm in meta.get("columns", {}).items() + ], + } +tables = [ + build("fact_studio_elettrofisiologico_endocavitario_ablazione", "promote", "t-ablazione"), + build("fact_impianto_defibrillatore_impiantabile", "promote", "t-impianto"), + build("dim_patient", "promote", "t-patient"), + build("dim_time", "promote", "t-time"), + build("fact_sostituzione_impianto_defibrillatore_impiantabile", "exclude", "x-sostituzione"), + build("fact_controllo_defibrillatore_impiantabile", "exclude", "x-controllo"), +] +json.dump(tables, open("tools/replay/schema-linking-fixture.json", "w"), ensure_ascii=False, indent=2) +print("wrote", len(tables), "tables;", + sum(len(t["columns"]) for t in tables), "columns;", + sum(1 for t in tables for c in t["columns"] if c["suggested"]), "suggested") +PY +``` + +Expected: prints e.g. `wrote 6 tables; 21 columns; 15 suggested`. + +- [ ] **Step 2: Write the augment script** + +Create `tools/replay/augment-schema-linking.mjs`: + +```js +// Rewrite replay gate F4 "tabelle/colonne/esclusioni" as a `schema-linking` +// descriptor carrying the real catalog columns (schema-linking-fixture.json), +// so the SchemaLinkingGateWidget renders in replay. Run after editing the +// fixture: node tools/replay/augment-schema-linking.mjs +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const replayPath = join(HERE, "replay.json"); +const replay = JSON.parse(readFileSync(replayPath, "utf8")); +const tables = JSON.parse(readFileSync(join(HERE, "schema-linking-fixture.json"), "utf8")); + +const idx = replay.gates.findIndex( + (g) => + /tabelle\/colonne\/esclusioni/.test(g.descriptor?.title ?? "") && + (g.descriptor?.widget === "multiselect" || g.descriptor?.widget === "schema-linking") +); +if (idx < 0) { + console.error("F4 tables gate not found in replay.json"); + process.exit(1); +} +const d = replay.gates[idx].descriptor; +replay.gates[idx].descriptor = { + type: "ui_request", + id: d.id, + phase: d.phase, + schema_version: d.schema_version, + widget: "schema-linking", + title: d.title, + tables, + reserved: ["back", "exit", "other"], +}; +writeFileSync(replayPath, JSON.stringify(replay, null, 2) + "\n"); +console.log(`Rewrote gate ${idx} -> schema-linking (${tables.length} tables)`); +``` + +- [ ] **Step 3: Run the augment script and assert** + +Run: + +```bash +cd /Users/mp/projects/ThothII && node tools/replay/augment-schema-linking.mjs && \ +node -e 'const j=require("./tools/replay/replay.json");const g=j.gates.find(x=>/tabelle\/colonne/.test(x.descriptor?.title||""));if(g.descriptor.widget!=="schema-linking"||!Array.isArray(g.descriptor.tables))throw new Error("not rewritten");console.log("OK",g.descriptor.tables.length,"tables")' +``` + +Expected: `Rewrote gate 7 -> schema-linking (6 tables)` then `OK 6 tables`. + +- [ ] **Step 4: Rebuild the replay bundle and start the server** + +Run: + +```bash +cd /Users/mp/projects/ThothII && rm -rf tools/replay/web && bash scripts/replay.sh build && \ +( lsof -ti tcp:5333 | xargs kill -9 2>/dev/null; true ) && nohup node tools/replay/server.mjs >/tmp/replay-5333.log 2>&1 & +sleep 2 && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5333/ +``` + +Expected: `200`. + +- [ ] **Step 5: Visually verify in the browser** + +Open http://localhost:5333 → click the "Replay 170732" session → **Resume** → answer the 3 F1 clarifications (recommended option each) → the F4 gate now renders as table rows with description + rationale and "Colonne k/n" buttons. Open a promote table's modal: suggested columns are pre-checked and bold; toggling a column updates bold and the row count. Open an exclude table's modal: fields listed, no checkboxes. Confirm advances the replay. + +Confirm each requirement: +- [ ] Promote rows show description + rationale under the name. +- [ ] Columns modal: suggested pre-selected + bold; toggle updates bold; no column rows in the list itself. +- [ ] Exclude modal: read-only (no checkboxes). + +- [ ] **Step 6: Commit** + +```bash +git add tools/replay/schema-linking-fixture.json tools/replay/augment-schema-linking.mjs tools/replay/replay.json +git commit -m "feat(replay): render F4 as schema-linking gate from real catalog columns" +``` + +--- + +## Self-Review + +**Spec coverage (Phase 1 slice of `2026-07-06-f4-schema-linking-column-curation-design.md`):** +- §1a descriptor `tables[]` → Task 1 types + Task 4 fixture. ✅ +- §1b structured response → Task 2 `confirm()` + tests. ✅ +- §2 interaction (inline gate, rows with desc+rationale, columns modal, bold rule, staged, exclude read-only, no column rows) → Task 2 + Task 1 + Task 4 verify. ✅ +- §3 components (`SchemaLinkingGateWidget`, `SchemaColumnsDialog`, registry, WidgetHost) → Tasks 1-3. ✅ +- §6 replay fixture from `physical.yaml` → Task 4. ✅ +- §4/§5 harness + downstream → **Plan 2 (out of scope here)**, by design. + +**Placeholder scan:** none — every step has concrete code/commands. The Task 3 registry micro-test has a documented fallback if `registry.test.tsx` differs. + +**Type consistency:** `SchemaTable`/`SchemaColumn`/`UiResponse.tables` defined in Task 1 and used verbatim in Tasks 2-4. Response shape `{ id, enacted, columns? }` identical across widget, tests, and contract. `descriptor.tables` typed in Task 1, read in Task 2. + +**Note for implementer:** Task 3 Step 2 depends on the exact contents of `frontend/src/widgets/registry.test.tsx`; read it first and mirror its import style, or rely on the Task 4 replay render as the integration check.