fix: make join review read only

This commit is contained in:
User
2026-07-14 15:06:16 +02:00
parent c7474f3852
commit 27af6733c8
11 changed files with 378 additions and 22 deletions
@@ -104,20 +104,20 @@ Expected: PASS.
- Consumes: options `{ id, label, detail?, rationale? }`. - Consumes: options `{ id, label, detail?, rationale? }`.
- Produces: response `{ id, kind: "join-review", choices: allOptionIds }` on `Continue`. - Produces: response `{ id, kind: "join-review", choices: allOptionIds }` on `Continue`.
- [ ] **Step 1: Write failing builder and gate tests** - [x] **Step 1: Write failing builder and gate tests**
Assert the builder emits read-only option details, `confirm_label: "Continue"`, and reserved controls. Assert the builder emits read-only option details, `confirm_label: "Continue"`, and reserved controls.
Exercise `reviewer_decide` with only `join_modified` decisions; queue a Continue response containing Exercise `reviewer_decide` with only `join_modified` decisions; queue a Continue response containing
all ids and assert every `tht decision add` call occurs. Queue `control:"freetext"` and assert no all ids and assert every `tht decision add` call occurs. Queue `control:"freetext"` and assert no
decision is written. decision is written.
- [ ] **Step 2: Run harness tests and verify RED** - [x] **Step 2: Run harness tests and verify RED**
Run: `cd harness && node --test .pi/extensions/gate/__tests__/builders.test.js .pi/extensions/gate/__tests__/gate_join_review.test.js` Run: `cd harness && node --test .pi/extensions/gate/__tests__/builders.test.js .pi/extensions/gate/__tests__/gate_join_review.test.js`
Expected: builder/export/widget contract is missing and join calls still emit `multiselect`. Expected: builder/export/widget contract is missing and join calls still emit `multiselect`.
- [ ] **Step 3: Implement builder and gate routing** - [x] **Step 3: Implement builder and gate routing**
Add `buildJoinReviewRequest`. In `reviewer_decide`, detect a non-empty, join-only merit list: Add `buildJoinReviewRequest`. In `reviewer_decide`, detect a non-empty, join-only merit list:
@@ -128,24 +128,24 @@ const joinOnly = opts.length > 0 && opts.every((o) => o.decision.type === "join_
Emit `join-review` with `detail` and `rationale`; after Continue persist all original decisions. Emit `join-review` with `detail` and `rationale`; after Continue persist all original decisions.
On free text, return feedback without persistence. Keep all other decisions on `multiselect`. On free text, return feedback without persistence. Keep all other decisions on `multiselect`.
- [ ] **Step 4: Write failing frontend widget tests** - [x] **Step 4: Write failing frontend widget tests**
Render two join cards and assert there are no checkboxes. Click `Continue` and expect all ids in the Render two join cards and assert there are no checkboxes. Click `Continue` and expect all ids in the
response. Open `Other — specify`, submit correction text, and expect a freetext control response. response. Open `Other — specify`, submit correction text, and expect a freetext control response.
- [ ] **Step 5: Run frontend widget tests and verify RED** - [x] **Step 5: Run frontend widget tests and verify RED**
Run: `cd frontend && npx vitest run src/widgets/JoinReviewWidget.test.tsx src/widgets/registry.test.tsx` Run: `cd frontend && npx vitest run src/widgets/JoinReviewWidget.test.tsx src/widgets/registry.test.tsx`
Expected: widget and registry entry are missing. Expected: widget and registry entry are missing.
- [ ] **Step 6: Implement and register JoinReviewWidget** - [x] **Step 6: Implement and register JoinReviewWidget**
Render semantic cards with label, detail, and rationale, one `Continue` primary button, and Render semantic cards with label, detail, and rationale, one `Continue` primary button, and
`ReservedControls`. Register `join-review` and extend `WidgetOption` with optional `detail` and `ReservedControls`. Register `join-review` and extend `WidgetOption` with optional `detail` and
`rationale` strings. `rationale` strings.
- [ ] **Step 7: Update model instructions and verify targeted tests** - [x] **Step 7: Update model instructions and verify targeted tests**
Document that joins must be a separate join-only `reviewer_decide` call; the reviewer cannot remove Document that joins must be a separate join-only `reviewer_decide` call; the reviewer cannot remove
individual joins and textual corrections require a complete revised proposal. individual joins and textual corrections require a complete revised proposal.
+3 -1
View File
@@ -1,6 +1,8 @@
export interface WidgetOption { export interface WidgetOption {
id: string; id: string;
label: string; label: string;
detail?: string;
rationale?: string;
meta?: Record<string, unknown>; meta?: Record<string, unknown>;
selected?: boolean; selected?: boolean;
recommended?: boolean; recommended?: boolean;
@@ -32,7 +34,7 @@ export interface WidgetDescriptor {
phase?: string; phase?: string;
title?: string; title?: string;
intro?: string; intro?: string;
widget: "info" | "select" | "multiselect" | "freetext" | "artifact-gate" | "artifact" | string; widget: "info" | "select" | "multiselect" | "join-review" | "freetext" | "artifact-gate" | "artifact" | string;
options?: WidgetOption[]; options?: WidgetOption[];
reserved?: string[]; reserved?: string[];
allow_empty?: boolean; allow_empty?: boolean;
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { WidgetDescriptor } from "../api/types";
import { JoinReviewWidget } from "./JoinReviewWidget";
const descriptor = {
id: "u-joins",
widget: "join-review",
title: "F4 — Proposed joins",
confirm_label: "Continue",
reserved: ["other"],
options: [
{
id: "procedure-event",
label: "Procedure to event",
detail: "JOIN procedure p ON p.patient_id = e.patient_id",
rationale: "Patient grain",
},
{
id: "event-time",
label: "Event to time",
detail: "JOIN dim_time d ON d.day_key = e.time_key",
rationale: "DWH time key",
},
],
} satisfies WidgetDescriptor;
test("renders joins as read-only information and Continue accepts the complete set", async () => {
const onRespond = vi.fn();
render(<JoinReviewWidget descriptor={descriptor} onRespond={onRespond} />);
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
expect(screen.getByText("Procedure to event")).toBeInTheDocument();
expect(screen.getByText(descriptor.options[0].detail)).toBeInTheDocument();
expect(screen.getByText("Patient grain")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
expect(onRespond).toHaveBeenCalledWith({
id: "u-joins",
kind: "join-review",
choices: ["procedure-event", "event-time"],
});
});
test("Other specify sends textual feedback instead of a join selection", async () => {
const onRespond = vi.fn();
render(<JoinReviewWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
await userEvent.type(screen.getByPlaceholderText("Describe your alternative…"), "Use the episode key");
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onRespond).toHaveBeenCalledWith({
id: "u-joins",
control: "freetext",
text: "Use the episode key",
});
});
+87
View File
@@ -0,0 +1,87 @@
import { Link2 } from "lucide-react";
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
const options = descriptor.options ?? [];
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
<div className="space-y-1.5">
{descriptor.title && (
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
{descriptor.title}
</p>
)}
<p className="text-sm leading-relaxed text-muted-foreground">
These joins are required by the selected tables and are shown for review.
To request a correction, use Other — specify.
</p>
</div>
<div className="space-y-2.5">
{options.map((option, index) => (
<article
key={option.id}
className="rounded-lg border border-border/70 bg-background px-3.5 py-3"
>
<div className="flex items-start gap-2.5">
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Link2 className="size-3.5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-2.5">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium tabular-nums text-muted-foreground">
{index + 1}
</span>
<h3 className="text-sm font-semibold leading-snug text-foreground">
{option.label}
</h3>
</div>
{option.detail && (
<code className="block whitespace-pre-wrap break-words rounded-md bg-muted/70 px-3 py-2 font-mono text-xs leading-relaxed text-foreground">
{option.detail}
</code>
)}
{option.rationale && (
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:gap-3">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Rationale
</span>
<p className="text-sm leading-relaxed text-muted-foreground">
{option.rationale}
</p>
</div>
)}
</div>
</div>
</article>
))}
</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={() =>
onRespond({
id: descriptor.id,
kind: "join-review",
choices: options.map((option) => option.id),
})
}
>
{descriptor.confirm_label ?? "Continue"}
</button>
<ReservedControls
reserved={descriptor.reserved}
onControl={(control, text) =>
onRespond({
id: descriptor.id,
control,
...(text !== undefined ? { text } : {}),
})
}
/>
</div>
);
}
+2
View File
@@ -6,6 +6,7 @@ import { MultiselectWidget } from "./MultiselectWidget";
import { ArtifactGateWidget } from "./ArtifactGateWidget"; import { ArtifactGateWidget } from "./ArtifactGateWidget";
import { ArtifactWidget } from "./ArtifactWidget"; import { ArtifactWidget } from "./ArtifactWidget";
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget"; import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
import { JoinReviewWidget } from "./JoinReviewWidget";
register("select", SelectWidget); register("select", SelectWidget);
register("info", InfoWidget); register("info", InfoWidget);
register("freetext", FreetextWidget); register("freetext", FreetextWidget);
@@ -13,4 +14,5 @@ register("multiselect", MultiselectWidget);
register("artifact-gate", ArtifactGateWidget); register("artifact-gate", ArtifactGateWidget);
register("artifact", ArtifactWidget); register("artifact", ArtifactWidget);
register("schema-linking", SchemaLinkingGateWidget); register("schema-linking", SchemaLinkingGateWidget);
register("join-review", JoinReviewWidget);
export { resolve } from "./registry"; export { resolve } from "./registry";
+6
View File
@@ -19,3 +19,9 @@ test("index registers the schema-linking widget", async () => {
const Comp = resolve("schema-linking"); const Comp = resolve("schema-linking");
expect(Comp.name).toBe("SchemaLinkingGateWidget"); expect(Comp.name).toBe("SchemaLinkingGateWidget");
}); });
test("index registers the read-only join review widget", async () => {
await import("./index");
const Comp = resolve("join-review");
expect(Comp.name).toBe("JoinReviewWidget");
});
@@ -16,6 +16,7 @@ const {
buildInfoRequest, buildInfoRequest,
buildFreetextRequest, buildFreetextRequest,
buildSchemaLinkingRequest, buildSchemaLinkingRequest,
buildJoinReviewRequest,
withChildLinkage, withChildLinkage,
} = require("../builders.js"); } = require("../builders.js");
@@ -130,6 +131,34 @@ test("buildSchemaLinkingRequest carries tables + reserved", () => {
assert.deepEqual(out.reserved, ["back", "exit", "other"]); assert.deepEqual(out.reserved, ["back", "exit", "other"]);
}); });
test("buildJoinReviewRequest exposes joins as read-only review items", () => {
const out = buildJoinReviewRequest({
id: "u8",
phase: "F4",
title: "F4 — Proposed joins",
options: [
{
id: "patient-events",
label: "Patient to events",
detail: "JOIN dim_patient p ON p.cod_paz = e.cod_paz",
rationale: "Standard patient key",
},
],
});
assert.equal(out.widget, "join-review");
assert.equal(out.confirm_label, "Continue");
assert.deepEqual(out.options, [
{
id: "patient-events",
label: "Patient to events",
detail: "JOIN dim_patient p ON p.cod_paz = e.cod_paz",
rationale: "Standard patient key",
},
]);
assert.deepEqual(out.reserved, ["back", "exit", "other"]);
});
test("withChildLinkage sets option.opens and returns the option", () => { test("withChildLinkage sets option.opens and returns the option", () => {
const child = { widget: "freetext", title: "Motivazione del rifiuto" }; const child = { widget: "freetext", title: "Motivazione del rifiuto" };
const option = withChildLinkage({ id: "reject", label: "Rifiuta" }, child); const option = withChildLinkage({ id: "reject", label: "Rifiuta" }, child);
@@ -0,0 +1,122 @@
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
const JOIN_OPTIONS = [
{
id: "procedure-event",
label: "Procedure to event",
decision: {
type: "join_modified",
subject: "procedure-event",
detail: "JOIN procedure p ON p.patient_id = e.patient_id",
rationale: "Patient grain",
},
},
{
id: "event-time",
label: "Event to time",
decision: {
type: "join_modified",
subject: "event-time",
detail: "JOIN dim_time d ON d.day_key = e.time_key",
rationale: "DWH time key",
},
},
];
function shellStub(calls) {
return (_file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta") {
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
}
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n";
return "";
};
}
test("join-only reviewer_decide is read-only and Continue persists every join", async () => {
const calls = [];
const original = cp.execFileSync;
cp.execFileSync = shellStub(calls);
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd";
gate.default(pi);
let descriptor;
ctx.ui.input = async (title) => {
descriptor = JSON.parse(title);
return JSON.stringify({
id: descriptor.id,
kind: "join-review",
choices: descriptor.options.map((option) => option.id),
});
};
const tool = tools.get("reviewer_decide");
await tool.def.execute(
"call-joins",
{ session: "s1", title: "Review joins", options: JOIN_OPTIONS, advance: false },
null,
null,
ctx,
);
assert.equal(descriptor.widget, "join-review");
assert.equal(descriptor.options[0].detail, JOIN_OPTIONS[0].decision.detail);
assert.equal(descriptor.options[0].rationale, JOIN_OPTIONS[0].decision.rationale);
const decisions = calls.filter((call) => call.startsWith("decision add"));
assert.equal(decisions.length, 2);
assert.ok(decisions.every((call) => call.includes("--type join_modified")));
} finally {
cp.execFileSync = original;
}
});
test("Other specify rejects the current join proposal without persisting it", async () => {
const calls = [];
const original = cp.execFileSync;
cp.execFileSync = shellStub(calls);
try {
const gate = require(GATE);
const { createFakePi } = require("./fake_pi_runtime.js");
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-thothii-test-cwd";
gate.default(pi);
ctx.ui.input = async (title) => {
const descriptor = JSON.parse(title);
return JSON.stringify({
id: descriptor.id,
control: "freetext",
text: "Use the episode composite key",
});
};
const tool = tools.get("reviewer_decide");
const result = await tool.def.execute(
"call-other",
{ session: "s1", title: "Review joins", options: JOIN_OPTIONS, advance: false },
null,
null,
ctx,
);
assert.equal(calls.filter((call) => call.startsWith("decision add")).length, 0);
assert.match(result.content[0].text, /Use the episode composite key/);
} finally {
cp.execFileSync = original;
}
});
+23
View File
@@ -177,6 +177,28 @@ function buildSchemaLinkingRequest({ id, phase, title, tables }) {
}; };
} }
// Build a blocking, read-only join review. The reviewer can accept the complete
// proposal or use Other to request a textual correction; individual joins are
// deliberately not selectable because omitting one could create a Cartesian product.
function buildJoinReviewRequest({ id, phase, title, options }) {
requireString(title, "title", "join-review");
const opts = requireArray(options, "options", "join-review");
if (opts.length === 0) {
throw new Error("builders: join-review requires at least one option");
}
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "join-review",
title,
options: [...opts],
confirm_label: "Continue",
reserved: RESERVED,
};
}
// Attach a child-widget spec to an option (linkage, §4.2). Returns the option. // Attach a child-widget spec to an option (linkage, §4.2). Returns the option.
function withChildLinkage(option, widgetSpec) { function withChildLinkage(option, widgetSpec) {
option.opens = widgetSpec; option.opens = widgetSpec;
@@ -190,6 +212,7 @@ module.exports = {
buildInfoRequest, buildInfoRequest,
buildFreetextRequest, buildFreetextRequest,
buildSchemaLinkingRequest, buildSchemaLinkingRequest,
buildJoinReviewRequest,
withChildLinkage, withChildLinkage,
SCHEMA_VERSION, SCHEMA_VERSION,
RESERVED, RESERVED,
+29 -9
View File
@@ -31,6 +31,7 @@ import {
buildMultiselectRequest, buildMultiselectRequest,
buildArtifactGate, buildArtifactGate,
buildSchemaLinkingRequest, buildSchemaLinkingRequest,
buildJoinReviewRequest,
} from "./gate/builders.js"; } from "./gate/builders.js";
import { import {
validateCtePlanV2, validateCtePlanV2,
@@ -728,6 +729,9 @@ export default function (pi) {
const meritOptions = opts const meritOptions = opts
.filter((o) => !isReserved(o.label)) .filter((o) => !isReserved(o.label))
.map((o) => ({ id: o.id, label: o.label })); .map((o) => ({ id: o.id, label: o.label }));
const joinOnly = meritOptions.length > 0 && opts
.filter((o) => !isReserved(o.label))
.every((o) => o.decision.type === "join_modified");
if (shouldSkipEmptyDecide({ meritCount: meritOptions.length, allowEmpty: params.allow_empty ?? false, advance })) { if (shouldSkipEmptyDecide({ meritCount: meritOptions.length, allowEmpty: params.allow_empty ?? false, advance })) {
await ctx.ui.notify( await ctx.ui.notify(
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.", "Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
@@ -738,14 +742,28 @@ export default function (pi) {
"Fase memoria vuota: nessuna decisione da registrare, avanzamento automatico alla fase successiva.", "Fase memoria vuota: nessuna decisione da registrare, avanzamento automatico alla fase successiva.",
); );
} }
const widget = buildMultiselectRequest({ const widget = joinOnly
id: `u${Date.now()}`, ? buildJoinReviewRequest({
phase, id: `u${Date.now()}`,
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title, phase,
allowEmpty: params.allow_empty ?? false, title,
options: meritOptions, options: opts
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}), .filter((o) => !isReserved(o.label))
}); .map((o) => ({
id: o.id,
label: o.label,
detail: o.decision.detail ?? "",
rationale: o.decision.rationale ?? "",
})),
})
: buildMultiselectRequest({
id: `u${Date.now()}`,
phase,
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
});
const resp = await emitAndWait(ctx, widget); const resp = await emitAndWait(ctx, widget);
if (resp.control === "freetext") { if (resp.control === "freetext") {
return textResult( return textResult(
@@ -756,7 +774,9 @@ export default function (pi) {
return textResult("Il reviewer vuole tornare indietro."); return textResult("Il reviewer vuole tornare indietro.");
if (resp.control === "exit") if (resp.control === "exit")
return textResult("Il reviewer vuole uscire."); return textResult("Il reviewer vuole uscire.");
const chosen = opts.filter((o) => (resp.choices ?? []).includes(o.id)); const chosen = joinOnly
? opts.filter((o) => !isReserved(o.label))
: opts.filter((o) => (resp.choices ?? []).includes(o.id));
for (const c of chosen) { for (const c of chosen) {
const d = c.decision; const d = c.decision;
const err = relayIfThtFails(ctx, decisionAddArgs(session, d), ""); const err = relayIfThtFails(ctx, decisionAddArgs(session, d), "");
+11 -5
View File
@@ -13,8 +13,8 @@ NEVER advance a phase or record a decision without explicit reviewer confirmatio
The reviewer answers via the gate's **widgets** (built by `tht-gate.js`): The reviewer answers via the gate's **widgets** (built by `tht-gate.js`):
`reviewer_select` (single pick; a chosen option carrying a `decision` payload IS the `reviewer_select` (single pick; a chosen option carrying a `decision` payload IS the
confirmation and is persisted directly — an option without a payload only asks), confirmation and is persisted directly — an option without a payload only asks),
`reviewer_decide` (multiselect, each selected option IS a decision — the choice is the `reviewer_decide` (normally a multiselect; a join-only proposal is rendered read-only and
confirmation), `reviewer_confirm` (gate on an artifact / phase transition). Free text Continue records the complete join set), `reviewer_confirm` (gate on an artifact / phase transition). Free text
arrives via the "Altro/Other" option or by prefixing `!` in chat. arrives via the "Altro/Other" option or by prefixing `!` in chat.
**Language contract (from the workspace `language` field):** the table/column **Language contract (from the workspace `language` field):** the table/column
@@ -36,7 +36,7 @@ substantive decisions.
|-------|--------------|--------------------| |-------|--------------|--------------------|
| F1 chiarimento | — | `reviewer_confirm kind:"phase"` | | F1 chiarimento | — | `reviewer_confirm kind:"phase"` |
| F2 memoria | — | `advance:true` only if nothing recorded; else `reviewer_confirm kind:"phase"` | | F2 memoria | — | `advance:true` only if nothing recorded; else `reviewer_confirm kind:"phase"` |
| F3 riscrittura | `question.md` | `reviewer_confirm kind:"phase"` (after `rewrite_question`) | | F3 riscrittura | `question.md` | `rewrite_question` records approval and advances automatically |
| F4 schema_linking | `schema_linking.json` | `reviewer_confirm kind:"phase"` (after `reviewer_schema_linking` + `write_schema_linking`). Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. | | F4 schema_linking | `schema_linking.json` | `reviewer_confirm kind:"phase"` (after `reviewer_schema_linking` + `write_schema_linking`). Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. |
| F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) | | F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) |
| F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` | | F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` |
@@ -278,8 +278,14 @@ Prerequisite: Phase 3 closed.
columns: project exactly those in the final SELECT (Phase 6/7); you remain free columns: project exactly those in the final SELECT (Phase 6/7); you remain free
to reference other columns as join keys or filter predicates when the query to reference other columns as join keys or filter predicates when the query
requires them. requires them.
Propose joins separately in `reviewer_decide(advance:false)`, registering Propose **all required joins together in a separate, join-only**
`join_modified`. Ground them in the `【Foreign keys】` section of the mschema-text `reviewer_decide(advance:false)`, registering `join_modified`. Do not mix
`join_modified` with other decision types in that call. The gate renders this proposal
as read-only information: **Continue records every proposed join**; the reviewer cannot
remove individual joins (which could create an accidental Cartesian product). If the
reviewer uses **Other — specify**, none of the current joins is recorded: incorporate
the textual correction and present the complete revised join set again.
Ground joins in the `【Foreign keys】` section of the mschema-text
render: it lists the curated logical FKs of the workspace (e.g. render: it lists the curated logical FKs of the workspace (e.g.
`fact_x.cod_paz=dim_patient.cod_paz`, `*_time_key=dim_time.day_key`) — prefer `fact_x.cod_paz=dim_patient.cod_paz`, `*_time_key=dim_time.day_key`) — prefer
those to joins you derive yourself, and flag to the reviewer any join you need those to joins you derive yourself, and flag to the reviewer any join you need