Files
ThothII/docs/superpowers/plans/2026-07-06-f4-schema-linking-column-curation-frontend.md
T
106a0de047 docs: Plan 1 (frontend + contract + replay) for F4 column curation
Bite-sized TDD tasks: schema-linking types + columns modal, gate widget with
staged per-table column selection, registry/summary wiring, and a replay
fixture generated from the real physical.yaml catalog. Offline-verifiable in
the replay server; harness wiring is Plan 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:25:00 +02:00

28 KiB

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 <file>.
  • 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<string>; onToggle: (column: string) => void; onClose: () => void }.

  • Step 1: Add the types

In frontend/src/api/types.ts, add above WidgetDescriptor:

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:

  tables?: { id: string; enacted: boolean; columns?: string[] }[];
  • Step 2: Write the failing test

Create frontend/src/widgets/SchemaColumnsDialog.test.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(
    <SchemaColumnsDialog
      table={table}
      open
      readOnly={false}
      selected={new Set(["cod_paz"])}
      onToggle={onToggle}
      onClose={() => {}}
    />
  );
  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(
    <SchemaColumnsDialog
      table={table}
      open
      readOnly
      selected={new Set()}
      onToggle={() => {}}
      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:

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<string>;
  onToggle: (column: string) => void;
  onClose: () => void;
}): ReactElement {
  return (
    <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent className="grid max-h-[70vh] w-[70vw] max-w-[46rem] grid-rows-[auto_1fr] gap-3 sm:max-w-[46rem]">
        <DialogTitle className="font-mono text-sm">{table.name}</DialogTitle>
        <div className="min-h-0 overflow-auto">
          <ul className="flex flex-col divide-y divide-border/50">
            {table.columns.map((col) => {
              const on = selected.has(col.name);
              const strong = on && !readOnly;
              return (
                <li key={col.name} className="flex items-start gap-3 py-2">
                  {!readOnly && (
                    <input
                      type="checkbox"
                      className="mt-0.5 size-4 shrink-0 accent-[oklch(var(--primary))]"
                      checked={on}
                      onChange={() => onToggle(col.name)}
                      aria-label={col.name}
                    />
                  )}
                  <div className="min-w-0">
                    <div className={`font-mono text-sm ${strong ? "font-bold text-foreground" : "text-foreground/90"}`}>
                      {col.name}
                    </div>
                    {col.description ? (
                      <p className={`text-sm ${strong ? "font-semibold text-foreground" : "text-muted-foreground"}`}>
                        {col.description}
                      </p>
                    ) : null}
                  </div>
                </li>
              );
            })}
          </ul>
        </div>
      </DialogContent>
    </Dialog>
  );
}
  • 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.

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<WidgetProps> 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:

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(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
  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(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
  // 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(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
  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(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
  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:

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<Set<string>>(
    () => new Set(tables.filter((t) => t.recommended).map((t) => t.id))
  );
  const [selected, setSelected] = useState<Map<string, Set<string>>>(() => {
    const m = new Map<string, Set<string>>();
    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<string | null>(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<string>();
        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 (
    <div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
      {descriptor.title && (
        <p className="text-[0.95rem] font-semibold leading-snug text-foreground">{descriptor.title}</p>
      )}

      <div className="flex flex-col gap-2">
        {tables.map((t) => {
          const on = enacted.has(t.id);
          const count = selected.get(t.id)?.size ?? 0;
          return (
            <div
              key={t.id}
              className={`rounded-lg border px-3 py-2.5 transition-colors ${on ? "border-border/70 bg-accent/40" : "border-transparent bg-muted/30"}`}
            >
              <div className="flex items-start gap-3">
                <input
                  type="checkbox"
                  className="mt-1 size-4 shrink-0 accent-[oklch(var(--primary))]"
                  checked={on}
                  onChange={() => toggleEnact(t.id)}
                  aria-label={t.name}
                />
                <div className="min-w-0 flex-1">
                  <div className="flex items-center gap-2">
                    <span className="font-mono text-sm font-semibold text-foreground">{t.name}</span>
                    <span className="thot-label">{t.kind === "promote" ? "Promuovi" : "Escludi"}</span>
                  </div>
                  {t.description && <p className="mt-0.5 text-sm text-muted-foreground">{t.description}</p>}
                  {t.rationale && <p className="mt-0.5 text-sm text-foreground/80">{t.rationale}</p>}
                </div>
                <button
                  className="shrink-0 self-center rounded-md border border-border/60 px-2.5 py-1 text-xs text-muted-foreground shadow-xs transition-colors hover:bg-muted hover:text-foreground"
                  onClick={() => setOpenId(t.id)}
                >
                  {t.kind === "promote" ? `Colonne ${count}/${t.columns.length}` : `Colonne ${t.columns.length}`}
                </button>
              </div>
            </div>
          );
        })}
      </div>

      {openTable && (
        <SchemaColumnsDialog
          table={openTable}
          open
          readOnly={openTable.kind === "exclude"}
          selected={selected.get(openTable.id) ?? new Set()}
          onToggle={(c) => toggleColumn(openTable.id, c)}
          onClose={() => setOpenId(null)}
        />
      )}

      <div>
        <button
          className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
          onClick={confirm}
        >
          Confirm
        </button>
      </div>

      <ReservedControls
        reserved={descriptor.reserved}
        onControl={(c, t) => onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })}
      />
    </div>
  );
}
  • 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.

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):

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):

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:

    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
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):

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:

// 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:

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:

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

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.