You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/09/04 18:55:38 UTC

(juneau) branch master updated: Add an enforceable in-dialog result receipt (ModalDef.keepOpenOnSubmit + ActionResult.resultForm) and a dialog-scoped child-action catalog (ModalDef.childActions) for stacked wizard steps.

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new e991410d79 Add an enforceable in-dialog result receipt (ModalDef.keepOpenOnSubmit + ActionResult.resultForm) and a dialog-scoped child-action catalog (ModalDef.childActions) for stacked wizard steps.
e991410d79 is described below

commit e991410d79ecc7fa760fcdb1c295f61990510728
Author: James Bognar <ja...@apache.org>
AuthorDate: Fri Sep 4 14:55:16 2026 -0400

    Add an enforceable in-dialog result receipt (ModalDef.keepOpenOnSubmit + ActionResult.resultForm) and a dialog-scoped child-action catalog (ModalDef.childActions) for stacked wizard steps.
---
 .../juneau/rest/server/views/ActionResult.java     |  38 +-
 .../org/apache/juneau/views/juneau-views.js        | 668 +++++++++++++++++++--
 .../server/views/ActionResult_Contract_Test.java   |  36 +-
 .../views/ViewsJs_DialogChildCatalog_Test.java     | 378 ++++++++++++
 .../rest/server/views/ViewsJs_DialogForm_Test.java |  12 +
 .../server/views/ViewsJs_DialogReceipt_Test.java   | 550 +++++++++++++++++
 .../server/views/ViewsJs_ModalResult_Test.java     |   5 +-
 .../server/views/ViewsJs_RibbonStrip_Test.java     |   4 +-
 .../src/test/js/dialog-child-catalog.cjs           | 375 ++++++++++++
 .../src/test/js/dialog-form.cjs                    |  13 +
 .../src/test/js/dialog-receipt.cjs                 | 509 ++++++++++++++++
 .../juneau/rest/server/widgets/ModalDef.java       | 336 ++++++++++-
 .../server/widgets/ModalDef_ResultHost_Test.java   | 282 +++++++++
 13 files changed, 3164 insertions(+), 42 deletions(-)

diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
index 9d42ab20aa..39b6aed5f8 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
@@ -87,7 +87,7 @@ import org.apache.juneau.commons.bean.*;
  *
  * @since 10.0.0
  */
-@BeanType(properties="contractVersion,outcome,replay,refusalCode,message,row")
+@BeanType(properties="contractVersion,outcome,replay,refusalCode,message,row,resultForm")
 @SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
 public class ActionResult {
 
@@ -171,6 +171,31 @@ public class ActionResult {
 	 */
 	public Object row;
 
+	/**
+	 * The follow-up <b>read-only</b> form-source URL a receipt is painted from; omitted from the wire when unset.
+	 *
+	 * <h5 class='section'>Honored on {@link Outcome#SUCCESS success} only</h5>
+	 * <p>
+	 * This is the mechanical form of <i>a receipt must never look like a receipt for a write that didn't happen</i>.
+	 * On a failure, a refusal, an unknown, or a transport failure this field is <b>ignored</b>, no follow-up GET is
+	 * issued, and nothing is swapped &mdash; so no result host can appear for a write that did not commit.
+	 *
+	 * <h5 class='section'>The served payload must not carry a form</h5>
+	 * <p>
+	 * The URL serves a {@code ModalDef}, and that payload <b>must not</b> declare a {@code form}.  A result host is
+	 * terminal by definition: the write already committed, so there is nothing left to gate.  A payload carrying
+	 * inputs is refused <i>visibly</i> rather than rendered read-only or silently stripped, because it is a consumer
+	 * authoring bug and should be visible as one.
+	 *
+	 * <h5 class='section'>It is a swap, not a new dialog</h5>
+	 * <p>
+	 * The runtime paints the receipt into the dialog that is <b>already open</b>, at the same layer depth, and only
+	 * when that dialog opted in with {@code ModalDef.keepOpenOnSubmit}.  Set on a result whose dialog did not opt in,
+	 * the swap is dropped, the success is reported unchanged, and the runtime emits one non-alarming diagnostic
+	 * rather than either opening a layer or making a successful write look failed.
+	 */
+	public String resultForm;
+
 	/**
 	 * Starts a {@link Outcome#SUCCESS success} result carrying the authoritative row read back from the remote system.
 	 *
@@ -267,4 +292,15 @@ public class ActionResult {
 		refusalCode = value;
 		return this;
 	}
+
+	/**
+	 * Sets the follow-up read-only form-source URL a receipt is painted from (see {@link #resultForm}).
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public ActionResult resultForm(String value) {
+		resultForm = value;
+		return this;
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 91da35a399..faf487d412 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -3600,7 +3600,10 @@
 		const req = buildActionRequest(
 			action, resolveCsrfToken(table), resolveCsrfHeaderName(table), extra, rowDataForTr(ctx, tr));
 		if (req.refuse) {
-			renderActionRefusalFor(tr, action, req.reason, ctx);
+			// T1 - the ONE terminal where nothing was even SENT: this guard returns before fetch is ever called, so
+			// a held dialog can re-enable Confirm and let the operator retry once the declaration is corrected.
+			if (settleDialogResultHold(ctx, "retryable")) renderHeldResultNotice(actionRefusalMessage(req.reason));
+			else renderActionRefusalFor(tr, action, req.reason, ctx);
 			return;
 		}
 		setRowInFlight(tr, true);
@@ -3612,7 +3615,11 @@
 				// A network-level failure is itself a terminal outcome: clear the marker (so polling resumes) and
 				// render a visible refusal rather than leaving the row stuck in-flight.
 				setRowInFlight(tr, false);
-				renderActionRefusalFor(tr, action, "request-failed", ctx);
+				// T2 - AMBIGUOUS, not retryable.  fetch rejects on a connection dropped AFTER the request was fully
+				// sent and processed, so this does NOT prove the write didn't land; re-enabling Confirm would hand
+				// the operator a one-click duplicate of a non-idempotent write.  Terminal + Close-only instead.
+				if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(REQUEST_UNCONFIRMED_NOTICE);
+				else renderActionRefusalFor(tr, action, "request-failed", ctx);
 			});
 	}
 
@@ -3643,15 +3650,26 @@
 	 */
 	function settleActionResponse(resp, action, table, tr, ctx) {
 		setRowInFlight(tr, false);   // EVERY terminal outcome clears the marker first - polling must always resume.
-		if (! resp) { renderActionOutcomeFor(tr, { outcome: "unknown" }, ctx); return; }
+		if (! resp) {
+			// T3: an absent response does not prove the write didn't land, so `unknown` never re-enables Confirm.
+			const o = { outcome: "unknown" };
+			if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(actionOutcomeMessage(o));
+			else renderActionOutcomeFor(tr, o, ctx);
+			return;
+		}
 
 		if (! resp.ok) {
 			const boundaryReason = (typeof resp.headers?.get === "function")
 				? resp.headers.get("X-Loopback-Boundary") : null;
 			readBodyText(resp).then(function (text) {
 				const t = transportRefusal(resp.status, boundaryReason, parseJsonSafe(text));
-				renderActionOutcomeFor(tr, { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message }, ctx);
-			});
+				const o = { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message };
+				// T4: a boundary/transport refusal is a gate saying no - nothing was written, so retry is safe and
+				// is the point.  The hand-off sits INSIDE this callback, next to the paint it replaces: at the
+				// arm's outer `return` the refusal text does not exist yet and the notice would race the button.
+				if (settleDialogResultHold(ctx, "retryable")) renderHeldResultNotice(actionOutcomeMessage(o));
+				else renderActionOutcomeFor(tr, o, ctx);
+			}).catch(function () { settleBodyReadFailure(action, table, tr, ctx); });   // T9
 			return;
 		}
 
@@ -3663,30 +3681,75 @@
 			// duration (HIGH-9).
 			const started = parseJobStarted(text);
 			if (started) {
+				// T5: CLOSE FIRST, then stream.  The order is load-bearing, not cosmetic - startJobStream paints
+				// setRowJobRunning/renderJobProgress into the ROW, so a still-open backdrop would hide the whole
+				// progress affordance behind a dialog that can never settle (a job pointer is not a terminal
+				// result and can never carry a resultForm), and a throw in stream setup would strand that busy
+				// modal permanently over a running job.
+				settleDialogResultHold(ctx, "closeCommitted");
 				startJobStream(started, action, table, tr, ctx);
 				return;
 			}
 			const result = parseActionResult(text);
 			if (! result) {                    // bare 2xx, no typed result -> pre-416 success behavior.
 				applySuccessBehavior(action, table, tr, ctx, null);
+				// T6: a bare 2xx IS a success carrying no typed body and therefore no resultForm - the matrix's
+				// "success, no resultForm" cell.  Whole-stack close, and the row render is left exactly as today
+				// (closeCommitted returns false, so nothing downstream is skipped).
+				settleDialogResultHold(ctx, "closeCommitted");
 				return;
 			}
 			if (result.contractVersion != null && result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION) {
-				renderActionOutcomeFor(tr, { outcome: "unknown",
+				const o = { outcome: "unknown",
 					message: "action-result contract mismatch (page='" + result.contractVersion +
-						"', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" }, ctx);
+						"', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" };
+				// T7: same family as T3 - the mismatch text is carried into the dialog notice verbatim.
+				if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(actionOutcomeMessage(o));
+				else renderActionOutcomeFor(tr, o, ctx);
 				return;
 			}
 			const outcome = normalizeOutcome(result);
-			if (outcome === "success") {
+			// T8, the one terminal with more than one disposition.  Keyed on the hold's ABSENCE rather than on a
+			// falsy hand-off return, because closeCommitted also returns false and must emit NO F4 diagnostic.
+			const held = ctx?._resultHold != null;
+			if (outcome === "success")
 				applySuccessBehavior(action, table, tr, ctx, result);
-				renderActionOutcomeFor(tr, { outcome: "success", replay: !! result.replay, message: result.message }, ctx);
-			} else {
-				renderActionOutcomeFor(tr, {
-					outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay
-				}, ctx);
+			const o = (outcome === "success")
+				? { outcome: "success", replay: !! result.replay, message: result.message }
+				: { outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay };
+			if (held && outcome === "success" && ! isBlankToken(result.resultForm)) {
+				// The RECEIPT branch, and it FETCHES BEFORE IT SETTLES.  Settling here would clear ctx._resultHold
+				// before the GET resolves, so every arm inside fetchResultForm would take the layer-gone escape and
+				// a committed write with a perfectly good receipt payload would paint no receipt at all, silently.
+				fetchResultForm(result.resultForm, action, table, tr, ctx);
+				return;
 			}
-		});
+			const disposition = (outcome === "success")
+				? "closeCommitted" : (outcome === "refusal" ? "retryable" : "terminal");
+			if (settleDialogResultHold(ctx, disposition)) renderHeldResultNotice(actionOutcomeMessage(o));
+			else renderActionOutcomeFor(tr, o, ctx);
+			// F4: an UNHELD success carrying a resultForm keeps its success banner byte-identical and adds ONE
+			// non-alarming diagnostic - a consumer authoring mistake must not make a committed write look failed.
+			if (! held && outcome === "success" && ! isBlankToken(result.resultForm))
+				renderResultFormIgnored(tr, ctx);
+		}).catch(function () { settleBodyReadFailure(action, table, tr, ctx); });   // T9
+	}
+
+	/**
+	 * T9 - the shared terminal for a REJECTED response-body read, on either of settleActionResponse's two
+	 * readBodyText(...) chains.  readBodyText is defensive about `resp.text` being absent or throwing
+	 * synchronously, but it returns the real {@code Response.text()} promise for a real response, and that
+	 * REJECTS on a truncated / aborted body stream, a decode error, or an already-consumed body.  With no handler
+	 * such a failure bypasses T4 on the non-2xx chain and T5-T8 on the 2xx chain entirely: no hand-off runs,
+	 * ctx._resultHold is never cleared, and a held dialog sits disabled + busy forever.
+	 *
+	 * <p>Terminal, for the same reason T2 is: to have a body to read at all the response HEADERS were already
+	 * received, so the request reached the server and the server answered - the write is not provably absent and
+	 * Confirm must not re-enable.  Unheld, it falls back to today's honest non-optimistic `unknown` render.
+	 */
+	function settleBodyReadFailure(action, table, tr, ctx) {
+		if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(BODY_UNREADABLE_NOTICE);
+		else renderActionOutcomeFor(tr, { outcome: "unknown", message: BODY_UNREADABLE_NOTICE }, ctx);
 	}
 
 	/** Reads a fetch Response body as text, defensively (a stubbed/absent body resolves to "" rather than throwing). */
@@ -4229,14 +4292,19 @@
 				const dt = document.createElement("dt");
 				dt.textContent = (f?.label != null) ? String(f.label) : "";
 				const dd = document.createElement("dd");
-				dd.textContent = (f?.value != null) ? String(f.value) : "";
+				// The ONE line that changed here: the value now goes through the shared kind-aware painter, so a
+				// `code` field is monospaced and copyable in the confirmation dialog and in the receipt alike.
+				buildModalFieldValueNode(dd, f);
 				dl.appendChild(dt);
 				dl.appendChild(dd);
 			});
 			dialog.appendChild(dl);
 		}
 
-		appendDialogForm(dialog, modal?.form, table, tr, ctx, seq);
+		// The dialog-scoped child-action catalog rides the form paint as a trailing optional parameter, so a
+		// `type=action` input inside this dialog's form can resolve a STACKED step that is not a row action.
+		const childCatalog = modal?.childActions ?? null;
+		appendDialogForm(dialog, modal?.form, table, tr, ctx, seq, childCatalog);
 
 		const actions = document.createElement("div");
 		actions.className = "juneau-view-dialog-actions";
@@ -4311,7 +4379,7 @@
 	// A `type=action` button: its name/id wiring is entirely its own (no shared tail post-processing), and
 	// a missing/non-dialog action id paints DISABLED rather than throwing (SF-1 / H-P4-S1).  A resolvable action
 	// gated-and-failing for this row paints disabled too, with both enabledWhen reason channels.
-	function buildActionFormControl(f, table, tr, ctx, row) {
+	function buildActionFormControl(f, table, tr, ctx, row, childCatalog) {
 		const control = document.createElement("button");
 		control.type = "button";
 		control.className = "juneau-view-dialog-form-action";
@@ -4320,16 +4388,23 @@
 		control.dataset.juneauFormField = String(f.name);
 		row.appendChild(control);   // attach BEFORE gating: attachRowActionDescNode needs control.parentNode to insert the reason sibling
 		const actionId = f.actionId;
-		if (! dialogActionIsOpenable(ctx, actionId)) {
-			control.disabled = true;
-			control.setAttribute("aria-disabled", "true");
-			control.dataset.juneauActionMissing = "1";
-		} else {
+		// Resolution order, and the order is the whole security property.  (1) Today's row-action check runs FIRST
+		// and unchanged, enabledWhen included, so a served payload can never shadow - and thereby bypass the gating
+		// of - a declared row action.  (2) A collision with a NON-dialog row action is a fail-closed miss and the
+		// child catalog is NOT consulted, because rescuing it there would be the same shadowing hole through a
+		// narrower door (a payload shadowing, say, a gated direct-submit Delete).  (3) Only an id that resolves to
+		// nothing today reaches the dialog-scoped catalog - which is exactly the case that fails closed with no
+		// signal to its author today.  (4) A miss in both stays today's fail-closed disabled+marked paint.
+		if (dialogActionIsOpenable(ctx, actionId)) {
 			const target = findRowActionById(ctx?.viewDef, actionId);
 			const failing = firstFailingRowActionRule(target, rowDataForTr(ctx, tr));
 			if (failing) disableRowActionControl(control, failing.reason);
+		} else if (rowActionIdExists(ctx, actionId) || ! childActionById(childCatalog, actionId)) {
+			control.disabled = true;
+			control.setAttribute("aria-disabled", "true");
+			control.dataset.juneauActionMissing = "1";
 		}
-		control.addEventListener("click", function () { openFormActionDialog(actionId, table, tr, ctx); });
+		control.addEventListener("click", function () { openFormActionDialog(actionId, table, tr, ctx, childCatalog); });
 		return control;
 	}
 
@@ -4342,12 +4417,12 @@
 		if (f.pattern != null) control.dataset.juneauPattern = String(f.pattern);
 	}
 
-	function paintFormControl(row, f, id, table, tr, ctx) {
+	function paintFormControl(row, f, id, table, tr, ctx, childCatalog) {
 		const type = (f.type == null || f.type === "") ? "text" : String(f.type);
 		if (! isTypedFormInputType(type)) return null;
 		// `action` has no shared id/name/value tail below - it wires its own name/dataset and returns early.
 		if (type === "action") {
-			return buildActionFormControl(f, table, tr, ctx, row);
+			return buildActionFormControl(f, table, tr, ctx, row, childCatalog);
 		}
 		let control;
 		if (type === "textarea") control = document.createElement("textarea");
@@ -4422,8 +4497,21 @@
 	 * the SAME kind of visible refusal (fresh, fail-closed re-check - never a silent no-op, never a throw): the
 	 * button's own disabled state (buildActionFormControl) is defense in depth, not the only gate.
 	 */
-	function openFormActionDialog(actionId, table, tr, ctx) {
-		if (! dialogActionIsOpenable(ctx, actionId)) { renderDialogActionRefusal(actionId); return; }
+	function openFormActionDialog(actionId, table, tr, ctx, childCatalog) {
+		// A dialog whose write is in flight or already settled into a receipt has no live form to branch FROM, so a
+		// stale `type=action` click (a child button the hold could not reach, an enter-key replay) must not stack a
+		// child onto a committed layer.  Cheapest correct answer is the no-op: the busy marker is the same one the
+		// hold paints, so there is no second piece of state to keep in sync.
+		if (topDialogEl()?.dataset?.juneauDialogBusy === "1") return;
+		if (! dialogActionIsOpenable(ctx, actionId)) {
+			// Fail-closed ordering mirrors buildActionFormControl exactly: a collision with a NON-dialog row action
+			// refuses OUTRIGHT rather than falling through to the dialog-scoped catalog, so a served child-action
+			// payload can never shadow a declared row action.  See that function's comment for why.
+			const child = rowActionIdExists(ctx, actionId) ? null : childActionById(childCatalog, actionId);
+			if (! child) { renderDialogActionRefusal(actionId); return; }
+			openChildActionDialog(child, table, tr, ctx);
+			return;
+		}
 		let target = null;
 		const catalog = (ctx?.viewDef?.rowActions) || [];
 		for (const c of catalog) if (c?.id === actionId) { target = c; break; }
@@ -4432,6 +4520,39 @@
 		openActionDialog(target, table, tr, ctx);
 	}
 
+	/**
+	 * Opens a STACKED step declared in the enclosing dialog's own `childActions` catalog (WORK-J0513 Scope B).
+	 *
+	 * <p>Deliberately NOT a RowAction: a child action is reachable only from the form of the dialog that declares it,
+	 * so it never surfaces in buildRowActionMenu, never surfaces in the ribbon resolver, and needs no exclusion
+	 * filter in either - the same disjoint-catalog argument that keeps J0512's ribbon dialogs out of the row menus.
+	 * The ChildAction shape is then adapted to the RowAction shape openActionDialog already takes, so the child rides
+	 * the SAME reviewed dialog machinery (confirmation GET, typed fields, optional form, fail-closed CSRF submit,
+	 * typed-result settling) rather than a parallel implementation of it.
+	 *
+	 * <p>`carryDrafts` encodes the PARENT dialog's current form values onto the child's form URL, so a wizard step
+	 * can prefill from what the user has typed but not yet submitted.  It is capped, and an over-cap carry is a
+	 * visible refusal rather than a silently truncated - and therefore silently wrong - prefill.
+	 */
+	function openChildActionDialog(child, table, tr, ctx) {
+		let resolved = child;
+		// The server rejects carryDrafts on a child with no form, so the second half of this guard is defense in
+		// depth against a hand-rolled payload - not a shape the validator lets through.
+		if (child.carryDrafts && child.form != null) {
+			resolved = withDraftQuery(child, collectDialogFormFields(topDialogEl()));
+			if (! resolved) { renderDialogDraftRefusal(child.id); return; }
+		}
+		openActionDialog({
+			id: String(resolved.id),
+			label: resolved.label == null ? String(resolved.id) : String(resolved.label),
+			type: "dialog",
+			form: resolved.form == null ? null : String(resolved.form),
+			endpoint: resolved.endpoint == null ? null : String(resolved.endpoint),
+			method: resolved.method == null ? null : String(resolved.method),
+			onSuccess: resolved.onSuccess == null ? null : String(resolved.onSuccess)
+		}, table, tr, ctx);
+	}
+
 	/**
 	 * Paints a visible fail-closed refusal into the current top dialog: a missing/non-dialog action id (no
 	 * `reason`), or a resolvable action gated-and-failing for this row (`reason` is the failing enabledWhen rule's
@@ -4472,16 +4593,16 @@
 	 * per-control error sibling.  Field ids use a dialog-only sequence (`juneau-dialog-field-<seq>-<name>`) so stacked
 	 * dialogs with the same field name do not collide and popover layers do not shift ids (N-3).
 	 */
-	function appendDialogForm(dialog, form, table, tr, ctx, seq) {
+	function appendDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {
 		if (!dialog || !form) return;
 		// A sectioned form and a flat one are mutually exclusive on the server (FormDef.validate rejects both), so
 		// sections win here without needing to reconcile the two.
-		if (form.sections?.length) { appendSectionedDialogForm(dialog, form, table, tr, ctx, seq); return; }
+		if (form.sections?.length) { appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog); return; }
 		if (!form.fields || !form.fields.length) return;
 		const wrap = document.createElement("div");
 		wrap.className = "juneau-view-dialog-form";
 		wrap.dataset.testid = "dialog-form";
-		form.fields.forEach(function (f) { appendDialogFormRow(wrap, dialog, f, table, tr, ctx, seq); });
+		form.fields.forEach(function (f) { appendDialogFormRow(wrap, dialog, f, table, tr, ctx, seq, childCatalog); });
 		if (wrap.childNodes.length)
 			dialog.appendChild(wrap);
 	}
@@ -4516,7 +4637,7 @@
 		bindControlValidation(dialog, control);
 	}
 
-	function appendDialogFormRow(host, dialog, f, table, tr, ctx, seq) {
+	function appendDialogFormRow(host, dialog, f, table, tr, ctx, seq, childCatalog) {
 		if (!f || f.name == null || String(f.name) === "") return;
 		const type = (f.type == null || f.type === "") ? "text" : String(f.type);
 		if (! isTypedFormInputType(type)) return;
@@ -4529,7 +4650,7 @@
 			label.textContent = f.label != null ? String(f.label) : String(f.name);
 			row.appendChild(label);
 		}
-		const control = paintFormControl(row, f, id, table, tr, ctx);
+		const control = paintFormControl(row, f, id, table, tr, ctx, childCatalog);
 		if (! control) return;   // unknown type skipped
 		if (type !== "action") appendDialogFormHelpAndError(row, dialog, control, f, id);
 		host.appendChild(row);
@@ -4548,7 +4669,7 @@
 	 * <p>The strip opens NO layer: a sectioned dialog is ONE dialog and consumes exactly one MAX_DIALOG_DEPTH slot,
 	 * so a `type=action` control inside a pane still stacks a real nested dialog and still refuses at the cap.
 	 */
-	function appendSectionedDialogForm(dialog, form, table, tr, ctx, seq) {
+	function appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {
 		const wrap = document.createElement("div");
 		wrap.className = "juneau-view-dialog-form";
 		wrap.dataset.testid = "dialog-form";
@@ -4561,7 +4682,7 @@
 			pane.dataset.juneauFormSection = String(s.id);
 			const fields = s.fields || [];
 			for (const field of fields)
-				appendDialogFormRow(pane, dialog, field, table, tr, ctx, seq);
+				appendDialogFormRow(pane, dialog, field, table, tr, ctx, seq, childCatalog);
 			if (! pane.childNodes.length) continue;   // every field skipped -> no empty tab
 			items.push({ id: String(s.id), label: s.label == null ? String(s.id) : String(s.label), pane: pane });
 		}
@@ -4736,7 +4857,15 @@
 		ui.confirmBtn.addEventListener("click", function () {
 			if (! validateDialogForm(ui.dialog, true)) return;   // fail-loud client validation before the submit
 			const fields = collectDialogFormFields(ui.dialog);
-			close();
+			// A modal that opted in to hosting its own result KEEPS ITS LAYER and registers a hold instead of
+			// closing; the settle then decides that layer's fate from the outcome.  Registration happens BEFORE the
+			// submit because the submit's client-refusal terminal can settle synchronously.  The hold is passed
+			// ctx-scoped rather than as a submit parameter on purpose: submitActionDialog stays byte-identical, so
+			// J0512's targetId precedence and its tripwire keep covering the only submit path there is.
+			// No ctx means no hold registry AND no dialog stack to check liveness against, so it closes as today -
+			// the fail-safe direction: today's behavior, not a dialog nothing can ever settle.
+			if (modal?.keepOpenOnSubmit && ctx) beginDialogResultHold(ui, action, modal, ctx);
+			else close();
 			submitActionDialog(modal, action, table, tr, ctx, fields);
 		});
 		if (ctx) {
@@ -4795,6 +4924,459 @@
 		submitRowAction(action, table, tr, ctx, extra);
 	}
 
+	// ==================================================================================================================
+	// THE NON-SUBMITTABLE IN-DIALOG RESULT HOST (WORK-J0513 Scope A): one mechanism, terminal by construction
+	// ==================================================================================================================
+	//
+	// A dialog that opted in with ModalDef.keepOpenOnSubmit is NOT closed at confirm-click time; instead its Confirm
+	// is disabled, the dialog is marked busy, and a HOLD is registered on ctx.  Every terminal of the submit graph
+	// then settles that hold to exactly one of five named dispositions, so a held dialog can always be exited: it
+	// either re-enables Confirm, or offers a Close-only surface, or is removed.  There is no fourth state.
+	//
+	// The hold is registered on `ctx` rather than passed as a callback parameter ON PURPOSE: submitActionDialog is a
+	// shared submit seam with its own pinned target-precedence rule, and threading a completion callback through it
+	// would couple this feature's diff to that one.  Coupling through ctx._resultHold leaves it byte-identical.
+	//
+	// WHY THE RESULT HOST IS NOT THE PREVIOUSLY-REJECTED "open a follow-up dialog from the response" shape: the
+	// distinguishing property is POSITION RELATIVE TO THE COMMIT, and it is enforced by three independently-testable
+	// absences rather than by documentation.  (1) The host never opens a new dialog - it swaps the body of the dialog
+	// that is already open, at the same layer depth, so the layer count is unchanged across the swap.  (2) It is
+	// reached ONLY after outcome === "success", i.e. only after a committed write, so no pre-commit path can produce
+	// it and there is nothing for a dry-run response mode to serve.  (3) The host it paints CANNOT submit -
+	// paintDialogReceipt creates no confirm control, references no submit function, and refuses outright to paint a
+	// payload that carries a `form`.  A pre-write follow-up step is a different thing entirely: it is a stacked child
+	// dialog opened BEFORE submit (Scope B, below), and the two are structurally disjoint.
+
+	/** The in-dialog held-outcome notice: ONE node per dialog, overwritten - never a growing pile of siblings. */
+	const DIALOG_RESULT_NOTICE_CLASS = "juneau-view-dialog-result-notice";
+	const DIALOG_RESULT_NOTICE_TESTID = "dialog-result-notice";
+
+	/** T2's wording: the request WAS sent, so the outcome is ambiguous rather than a plain "try again". */
+	const REQUEST_UNCONFIRMED_NOTICE =
+		"The request was sent but no response came back.  It may or may not have been applied - re-check before retrying.";
+
+	/** T9's wording: headers arrived, so the server answered; only the body was unreadable. */
+	const BODY_UNREADABLE_NOTICE =
+		"The server answered but the response body could not be read.  The action may or may not have been applied.";
+
+	/** F12: the write COMMITTED and only the follow-up read failed - nothing here casts doubt on the write. */
+	const RESULT_FORM_UNAVAILABLE_NOTICE =
+		"The action completed, but its result could not be loaded.";
+
+	/** F1c: a well-formed payload of the FORBIDDEN shape - a consumer authoring bug, diagnosed as one. */
+	const RESULT_FORM_REFUSED_NOTICE =
+		"The action completed, but its result was not shown: a result form must not carry inputs.";
+
+	/** Paints (or OVERWRITES) the one in-dialog held-outcome notice, through the shared top-dialog notice painter. */
+	function renderHeldResultNotice(text) {
+		renderTopDialogNotice(DIALOG_RESULT_NOTICE_CLASS, DIALOG_RESULT_NOTICE_TESTID, text);
+	}
+
+	/** The `.juneau-view-dialog` element of the CURRENT top layer, or null when no layer is open. */
+	function topDialogEl() {
+		const el = topLayer()?.el;
+		return (el?.querySelector?.(".juneau-view-dialog")) || el || null;
+	}
+
+	/** Removes a dialog's actions row outright - which detaches Confirm AND its listener, not merely disables it. */
+	function removeDialogActionsRow(dialog) {
+		if (! dialog?.querySelectorAll) return;
+		for (const el of dialog.querySelectorAll(".juneau-view-dialog-actions")) removeEl(el);
+	}
+
+	/**
+	 * Tears down a COMMITTED write's body before a terminal post-commit surface is painted over it.
+	 *
+	 * <p>Removing the actions row is not sufficient teardown: the submitted inputs live in a SEPARATE
+	 * `.juneau-view-dialog-form` wrapper (and, for a sectioned form, a live section ribbon), which nothing else
+	 * here touches and the receipt paint does not repaint.  Without this a receipt would render the server's
+	 * result UNDERNEATH the still-populated, still-editable form the operator just submitted - a terminal surface
+	 * carrying live inputs for a write that already committed, i.e. the forbidden shape leaking back in through
+	 * sloppy teardown rather than through a submit control.  The old field list goes too, since the receipt
+	 * repaints one from its own payload and a surviving `dl` would double it.
+	 */
+	function teardownCommittedWriteBody(dialog) {
+		if (! dialog?.querySelectorAll) return;
+		for (const el of dialog.querySelectorAll(".juneau-view-dialog-form")) removeEl(el);
+		for (const el of dialog.querySelectorAll(".juneau-view-dialog-fields")) removeEl(el);
+	}
+
+	/**
+	 * The ONE Close-only action row every non-receipt `terminal` surface uses (T2, T3, T7, T9, T8-failure,
+	 * T8-unknown, plus the two post-commit cells F12 and F1c).  Its only listener is the WHOLE-STACK teardown, so
+	 * the operator's exit from a terminal held dialog is identical everywhere and lives in exactly one place.
+	 *
+	 * <p>Factoring it here is also what makes the terminal dispositions buildable at all: the automatic-teardown
+	 * paths are forbidden from calling closeActionDialog themselves, so they delegate their Close button to this
+	 * helper.  A Close button the operator has to press is that operator's exit, not an automatic teardown.
+	 */
+	function appendDialogCloseOnlyRow(dialog, ctx) {
+		if (! dialog?.appendChild) return null;
+		const actions = document.createElement("div");
+		actions.className = "juneau-view-dialog-actions";
+		const close = document.createElement("button");
+		close.type = "button";
+		close.className = "juneau-view-dialog-close";
+		close.dataset.testid = "dialog-result-close";
+		close.textContent = "Close";
+		close.addEventListener("click", function () { closeActionDialog(ctx); });
+		actions.appendChild(close);
+		dialog.appendChild(actions);
+		return close;
+	}
+
+	/**
+	 * Registers the click-time hold: disable Confirm, mark the dialog busy, and disable every child-action button
+	 * in it - RECORDING which were already disabled, so the re-enable path restores the PRIOR state and can never
+	 * enable a child that was gated before the submit started.
+	 *
+	 * <p>Cancel deliberately stays enabled.  Escape pops the top layer through the shared stack regardless, so the
+	 * design has to tolerate "dialog gone before settle" anyway; given that, disabling Cancel would only remove the
+	 * operator's obvious exit.  The layer-gone case is handled at settle time.
+	 *
+	 * <p>Child-action buttons are disabled because the row in-flight marker cannot help here (it disables a trigger
+	 * inside a `tr`, and a ribbon-hosted dialog has no row), and because a live child button during the in-flight
+	 * parent write lets an operator push a child layer on top of a dialog that is about to be swapped into a
+	 * receipt - the parent settles, the body is torn down under a live child, and the operator is typing into a
+	 * step whose parent already committed.
+	 */
+	function beginDialogResultHold(ui, action, modal, ctx) {
+		ui.confirmBtn.disabled = true;
+		ui.dialog.dataset.juneauDialogBusy = "1";
+		const disabledChildren = [];
+		if (ui.dialog.querySelectorAll)
+			for (const c of ui.dialog.querySelectorAll(".juneau-view-dialog-form-action")) {
+				if (c.disabled) { disabledChildren.push(c); continue; }
+				c.disabled = true;
+				c.setAttribute("aria-disabled", "true");
+			}
+		ctx._resultHold = {
+			backdrop: ui.backdrop, dialog: ui.dialog, confirmBtn: ui.confirmBtn,
+			action: action, modal: modal, disabledChildren: disabledChildren
+		};
+	}
+
+	/** Whether the held dialog's layer is still on this ctx's dialog stack (it is not, after Cancel or Escape). */
+	function heldLayerIsLive(ctx, hold) {
+		const stack = ctx?._dialogStack;
+		return !! (stack && stack.indexOf(hold.backdrop) >= 0);
+	}
+
+	/** Drops the busy marker a hold stamped on its dialog. */
+	function clearDialogBusyMarker(hold) {
+		if (hold?.dialog?.dataset) delete hold.dialog.dataset.juneauDialogBusy;
+	}
+
+	/** Restores child-action buttons to their PRE-HOLD state - never enabling one that was already gated. */
+	function restoreHeldChildActions(hold) {
+		const dialog = hold?.dialog;
+		if (! dialog?.querySelectorAll) return;
+		const prior = hold.disabledChildren || [];
+		for (const c of dialog.querySelectorAll(".juneau-view-dialog-form-action")) {
+			if (prior.indexOf(c) >= 0) continue;
+			c.disabled = false;
+			c.removeAttribute("aria-disabled");
+		}
+	}
+
+	/**
+	 * Settles the registered hold to one named disposition, and reports whether the calling terminal's OWN row /
+	 * ribbon painter was superseded.
+	 *
+	 * <p>The hold is cleared FIRST, which is what guarantees exactly one hand-off per submit even though the
+	 * submit graph has nine terminals: they are mutually exclusive paths through one submitRowAction call, and a
+	 * second settle on the same hold finds nothing.
+	 *
+	 * <p>Returns <code>false</code>, having done nothing, when there is no hold at all or when the registered layer
+	 * is no longer on the stack (the operator pressed Cancel or Escape mid-flight).  The caller then paints into
+	 * the row / ribbon host exactly as it does today, which is what makes leaving Cancel enabled safe.
+	 *
+	 * <p>Returns <code>true</code> for `retryable` / `terminal` / `receipt` - the three dispositions that leave the
+	 * dialog ON SCREEN, so the notice belongs in the dialog where the operator can read it and painting the row
+	 * banner too would render one outcome twice, once behind a backdrop.  Returns <code>false</code> for
+	 * `closeCommitted`, because the dialog is GONE and the row / ribbon banner is then the only surviving signal -
+	 * suppressing it there would leave a committed write with no visible confirmation anywhere.
+	 *
+	 * <p>applySuccessBehavior is never skipped under any disposition; only the BANNER moves.  That is safe for an
+	 * open receipt because an onSuccess=redraw is a data reload, not a DOM teardown, and a portalled dialog is a
+	 * body-level layer a row repaint cannot remove.
+	 */
+	function settleDialogResultHold(ctx, disposition) {
+		const hold = ctx?._resultHold ?? null;
+		if (ctx) ctx._resultHold = null;   // cleared FIRST: no terminal may settle the same hold twice
+		if (! hold || ! heldLayerIsLive(ctx, hold)) return false;
+		clearDialogBusyMarker(hold);
+		if (disposition === "closeCommitted") {
+			// The ONE automatic teardown in the whole graph, and it takes the WHOLE stack: a terminal surface for a
+			// committed write must not drop the operator back onto a now-stale parent dialog underneath it.  At
+			// depth 1 this is observably identical to popping the single layer.
+			closeActionDialog(ctx);
+			return false;
+		}
+		if (disposition === "retryable" || disposition === "release") {
+			// `release` is RESERVED and currently passed by nothing - a defined name for "let the caller's UI run,
+			// dialog untouched".  It is deliberately NOT the no-hold escape above, which returns false.
+			if (hold.confirmBtn) hold.confirmBtn.disabled = false;
+			restoreHeldChildActions(hold);
+			return true;
+		}
+		if (disposition === "terminal") {
+			// Actions row only.  The form body is left standing on purpose: T2/T3/T7/T9 are not confirmed-commit
+			// surfaces, so the operator keeps their typed values on screen to copy out before Close.  The two
+			// post-commit `terminal` cells (F12, F1c) run the body teardown themselves, in fetchResultForm.
+			removeDialogActionsRow(hold.dialog);
+			appendDialogCloseOnlyRow(hold.dialog, ctx);
+		}
+		return true;
+	}
+
+	/**
+	 * F4 - a result carried a resultForm but its dialog never opted in with keepOpenOnSubmit.
+	 *
+	 * <p>The write SUCCEEDED, so the success banner is left byte-identical and this adds exactly one non-alarming
+	 * `role=status` diagnostic into the SAME outcome host the success already used.  Never a new layer (that is the
+	 * forbidden shape), and never a refusal (which would make a successful write look failed for what is purely a
+	 * consumer-authoring mistake).  One node, overwritten.
+	 */
+	function renderResultFormIgnored(tr, ctx) {
+		const host = tr
+			? (tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr)
+			: ribbonBannerHost(ctx);
+		if (! host?.appendChild) return;
+		let el = host.querySelector ? host.querySelector(".juneau-view-result-form-ignored") : null;
+		if (! el) {
+			el = document.createElement("div");
+			el.className = "juneau-view-result-form-ignored";
+			el.dataset.testid = "result-form-ignored";
+			el.setAttribute("role", "status");
+			host.appendChild(el);
+		}
+		el.textContent = "This action returned a result to show, but its dialog did not opt in to staying open"
+			+ " (ModalDef.keepOpenOnSubmit), so the result was not shown.";
+	}
+
+	/** Whether `kind` is a legal ModalDef.Field display kind (the frozen 2-token allowlist: text, code). */
+	function isModalFieldKind(kind) {
+		return kind === "text" || kind === "code";
+	}
+
+	/**
+	 * A guarded clipboard copy button for a `code`-kind field.  Feature-detected, `.catch()`ed, and wrapped so it
+	 * can never throw out of a click handler; the `<pre>` beside it stays selectable, so manual copy always works
+	 * even where the clipboard API is absent or permission is denied.
+	 */
+	function buildFieldCopyButton(value) {
+		const btn = document.createElement("button");
+		btn.type = "button";
+		btn.className = "juneau-view-dialog-field-copy";
+		btn.dataset.testid = "dialog-field-copy";
+		btn.textContent = "Copy";
+		btn.addEventListener("click", function () {
+			const clip = (typeof navigator === "undefined") ? null : navigator?.clipboard;
+			if (typeof clip?.writeText !== "function") return;
+			try {
+				Promise.resolve(clip.writeText(value)).catch(function () { /* denied - the <pre> stays selectable */ });
+			} catch (e) { /* a copy affordance never throws */ }
+		});
+		return btn;
+	}
+
+	/**
+	 * Paints ONE confirmation field's value into its `<dd>`: plain text by default, and for `kind="code"` a
+	 * monospaced `<pre>` plus a copy button.  createElement / textContent ONLY - never innerHTML, for exactly the
+	 * reason the whole dialog payload is painted that way (live data is attacker-influenceable and this origin
+	 * holds the CSRF token).  An UNRECOGNIZED kind token falls back to `text` rather than being trusted.
+	 */
+	function buildModalFieldValueNode(dd, f) {
+		const value = (f?.value != null) ? String(f.value) : "";
+		const kind = (f?.kind == null || f.kind === "") ? "text" : String(f.kind);
+		if (! isModalFieldKind(kind) || kind === "text") { dd.textContent = value; return dd; }
+		const pre = document.createElement("pre");
+		pre.className = "juneau-view-dialog-field-code";
+		pre.textContent = value;
+		dd.appendChild(pre);
+		dd.appendChild(buildFieldCopyButton(value));
+		return dd;
+	}
+
+	/**
+	 * Paints a `<dl>` of typed confirmation fields for the RECEIPT.
+	 *
+	 * <p>The confirmation overlay keeps its own `dl` shell rather than calling this: its shell is pinned where it
+	 * stands, and what actually needed sharing is the per-value painter above (the `kind` dispatch), not the three
+	 * lines of `dl` around it.
+	 */
+	function buildModalFieldList(fields) {
+		const dl = document.createElement("dl");
+		dl.className = "juneau-view-dialog-fields";
+		dl.dataset.testid = "dialog-fields";
+		fields.forEach(function (f) {
+			const dt = document.createElement("dt");
+			dt.textContent = (f?.label != null) ? String(f.label) : "";
+			const dd = document.createElement("dd");
+			buildModalFieldValueNode(dd, f);
+			dl.appendChild(dt);
+			dl.appendChild(dd);
+		});
+		return dl;
+	}
+
+	/**
+	 * Paints the RECEIPT into the dialog that is already open - the enforcement, and it enforces by ABSENCE.
+	 *
+	 * <p>This function creates no confirm control, names no submit function, and pushes no layer.  A structural
+	 * marker would enforce by veto (a later change can delete the check, and a DOM attribute a fixture can forge);
+	 * an absence is what a test can pin cheaply and what a careless edit cannot silently undo.  The
+	 * `data-juneau-receipt` stamp is for tests and CSS - it is NOT the enforcement.
+	 *
+	 * <p>A payload carrying a `form` is refused outright rather than painted read-only or silently stripped: inputs
+	 * on a terminal post-commit surface are the forbidden shape arriving through the receipt door, and refusing
+	 * loudly makes it a visible authoring bug instead of a silently-degraded surface.  That refusal is the caller's
+	 * (fetchResultForm), which never reaches this function for such a payload.
+	 */
+	function paintDialogReceipt(dialog, modal, ctx) {
+		if (! dialog?.appendChild) return false;
+		removeDialogActionsRow(dialog);
+		teardownCommittedWriteBody(dialog);
+		const title = dialog.querySelector ? dialog.querySelector(".juneau-view-dialog-title") : null;
+		if (title) title.textContent = isBlankToken(modal?.title) ? "Done" : String(modal.title);
+		if (modal?.fields?.length)
+			dialog.appendChild(buildModalFieldList(modal.fields));
+		const actions = document.createElement("div");
+		actions.className = "juneau-view-dialog-actions";
+		const close = document.createElement("button");
+		close.type = "button";
+		close.className = "juneau-view-dialog-close";
+		close.dataset.testid = "dialog-receipt-close";
+		close.textContent = "Close";
+		// The WHOLE stack, not this one layer: a terminal receipt for a committed write must not drop the operator
+		// back onto the now-stale parent dialog underneath, showing pre-write values for a write that committed.
+		close.addEventListener("click", function () { closeActionDialog(ctx); });
+		actions.appendChild(close);
+		dialog.appendChild(actions);
+		dialog.className = dialog.className + " juneau-view-dialog-receipt";
+		dialog.dataset.testid = "dialog-receipt";
+		dialog.dataset.juneauReceipt = "1";
+		return true;
+	}
+
+	/**
+	 * Issues the follow-up READ-ONLY receipt GET and routes its four outcomes.
+	 *
+	 * <p>Three of them are the same F12 terminal - "the write committed, we just could not show you the result":
+	 * a non-2xx, an unparseable payload, and a REJECTED promise.  The third arm matters as much as the other two:
+	 * without it a single dropped packet on the follow-up read leaves the promise rejected, the terminal unreached,
+	 * and Confirm stuck disabled + busy on a write that had ALREADY COMMITTED.
+	 *
+	 * <p>The fourth is distinct and must stay distinct: a 2xx that parses fine but carries a `form` is a
+	 * SUCCESSFUL read of a payload of the forbidden shape.  It shares F12's disposition, teardown and Close-only
+	 * row, but it carries its own wording, because folding a consumer authoring bug into a transport-failure
+	 * message hides it.
+	 *
+	 * <p>No automatic teardown here: every arm ends with the dialog OPEN, and the Close row's listener is the
+	 * operator's exit rather than a terminal closing the stack behind their back.
+	 */
+	function fetchResultForm(url, action, table, tr, ctx) {
+		const dialog = ctx?._resultHold?.dialog ?? null;
+		function resultFormTerminal(text) {
+			teardownCommittedWriteBody(dialog);   // post-commit surface: the submitted inputs come out
+			if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(text);
+		}
+		fetch(url, { method: "GET", credentials: "same-origin", headers: { "Accept": "application/json" } })
+			.then(function (resp) {
+				if (! resp || ! resp.ok) { resultFormTerminal(RESULT_FORM_UNAVAILABLE_NOTICE); return null; }
+				return readBodyText(resp).then(function (text) {
+					const payload = parseJsonSafe(text);
+					if (! payload) { resultFormTerminal(RESULT_FORM_UNAVAILABLE_NOTICE); return; }
+					if (payload.form) { resultFormTerminal(RESULT_FORM_REFUSED_NOTICE); return; }
+					if (! settleDialogResultHold(ctx, "receipt")) return;   // the layer went away mid-GET
+					paintDialogReceipt(dialog, payload, ctx);
+				});
+			})
+			.catch(function () {
+				// F12's third arm, written out here rather than delegated: the pin on this function slices from its
+				// last `.catch(` to the end and requires the SETTLE inside it, because a `.catch` that merely logs
+				// would satisfy a bare "has a catch" check while leaving the hold uncleared on a committed write.
+				teardownCommittedWriteBody(dialog);
+				if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(RESULT_FORM_UNAVAILABLE_NOTICE);
+			});
+	}
+
+	// ==================================================================================================================
+	// THE DIALOG-SCOPED CHILD-ACTION CATALOG (WORK-J0513 Scope B): reaching a stacked step from inside a dialog
+	// ==================================================================================================================
+	//
+	// A `type=action` input resolves its actionId against ctx.viewDef.rowActions and nothing else, so a stacked
+	// Review step inside an already-open dialog has nothing legal to resolve against.  ModalDef.childActions is that
+	// missing catalog, and it is DIALOG-SCOPED: it arrives on the per-open payload, so buildRowActionMenu and the
+	// ribbon resolver never see it and there is no exclusion filter to maintain (or to forget).
+	//
+	// It reaches the click site as a TRAILING OPTIONAL parameter threaded through both row-painting paths.  Not
+	// stamped on the dialog element (framework state on a DOM node, forgeable from the document), and not looked up
+	// from topLayer() at click time (that assumes the button's dialog is topmost - true today, but an assumption a
+	// popover push could break).  Threaded and captured in the click closure, it is unforgeable; and because the
+	// parameter is trailing and optional, every existing positional call site keeps resolving exactly as today.
+
+	/** The client-side cap on the draft query, measured on the ENCODED value.  See withDraftQuery. */
+	const MAX_DRAFT_QUERY_BYTES = 2048;
+
+	/** Resolves `actionId` against a dialog-scoped child catalog, or null. */
+	function childActionById(childCatalog, actionId) {
+		for (const c of (childCatalog || []))
+			if (c?.id === actionId) return c;
+		return null;
+	}
+
+	/** Whether `actionId` names ANY entry in this view's row-action catalog (dialog-presenting or not). */
+	function rowActionIdExists(ctx, actionId) {
+		for (const c of ((ctx?.viewDef?.rowActions) || []))
+			if (c?.id === actionId) return true;
+		return false;
+	}
+
+	/**
+	 * The byte length of an encoded draft query value.
+	 *
+	 * <p>The cap is measured on the ENCODED value - the bytes that actually land in the URL, the access log and
+	 * the browser's history entry - and not on the raw JSON.  Percent-encoding is up to 3x expansion per byte for
+	 * non-ASCII and for the braces / quotes / colons JSON is made of, so a cap applied to the raw string would let
+	 * a ~2KB draft become a ~6KB query and blow a server or proxy request-line limit AFTER the client had already
+	 * told the operator it was fine: a refusal arriving as an opaque transport error instead of a visible,
+	 * actionable one.
+	 */
+	function draftQueryByteLength(encoded) {
+		if (typeof TextEncoder === "function") return new TextEncoder().encode(encoded).length;
+		return encoded.length;   // the encoded form is ASCII-only by construction, so the two agree
+	}
+
+	/**
+	 * A SHALLOW COPY of `childAction` whose form URL already carries the parent's drafts as one `juneauDrafts`
+	 * query parameter; null when the encoded value exceeds the cap (the caller then refuses visibly and opens
+	 * nothing).
+	 *
+	 * <p>A copy, rather than a parameter on the dialog opener: the opener is a shared surface with its own pinned
+	 * shape, and a descriptor whose `form` is already final needs nothing from it.
+	 *
+	 * <p>Without this channel a Review step opens EMPTY and loses everything the operator typed.  Drafts land in
+	 * access logs and browser history, so no secret may ride here.
+	 */
+	function withDraftQuery(childAction, drafts) {
+		const encoded = encodeURIComponent(JSON.stringify(drafts || {}));
+		if (draftQueryByteLength(encoded) > MAX_DRAFT_QUERY_BYTES) return null;
+		const copy = {};
+		for (const k of Object.keys(childAction)) copy[k] = childAction[k];
+		const url = String(childAction.form);
+		copy.form = url + (url.indexOf("?") >= 0 ? "&" : "?") + "juneauDrafts=" + encoded;
+		return copy;
+	}
+
+	/** The visible over-cap refusal: the child does not open, and the operator is told why. */
+	function renderDialogDraftRefusal(actionId) {
+		renderTopDialogNotice("juneau-view-dialog-action-refusal", "dialog-action-refusal",
+			"Action '" + String(actionId) + "' was not opened: its form draft exceeds "
+			+ MAX_DRAFT_QUERY_BYTES + " bytes.");
+	}
+
 	/** Renders a VISIBLE row-action refusal (anti-silent-degradation) into the row's actions cell. */
 	function renderRowActionRefusal(tr, action, reason) {
 		const cell = tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr;
@@ -5941,6 +6523,22 @@
 		renderActionOutcomeFor: renderActionOutcomeFor,
 		renderDialogActionRefusal: renderDialogActionRefusal,
 		renderDialogDepthRefusal: renderDialogDepthRefusal,
+		// In-dialog result receipt + dialog-scoped child-action catalog (WORK-J0513) - exposed for the harnesses.
+		MAX_DRAFT_QUERY_BYTES: MAX_DRAFT_QUERY_BYTES,
+		isModalFieldKind: isModalFieldKind,
+		buildModalFieldValueNode: buildModalFieldValueNode,
+		buildModalFieldList: buildModalFieldList,
+		beginDialogResultHold: beginDialogResultHold,
+		settleDialogResultHold: settleDialogResultHold,
+		settleBodyReadFailure: settleBodyReadFailure,
+		appendDialogCloseOnlyRow: appendDialogCloseOnlyRow,
+		paintDialogReceipt: paintDialogReceipt,
+		fetchResultForm: fetchResultForm,
+		renderResultFormIgnored: renderResultFormIgnored,
+		childActionById: childActionById,
+		rowActionIdExists: rowActionIdExists,
+		withDraftQuery: withDraftQuery,
+		openChildActionDialog: openChildActionDialog,
 		pushLayer: pushLayer,
 		popLayer: popLayer,
 		topLayer: topLayer,
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
index ffc2bdbf36..6cfc1fa4c9 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
@@ -64,12 +64,46 @@ class ActionResult_Contract_Test extends TestBase {
 	}
 
 	@Test void a04_topLevelKeyOrder_fullResult_matchesBeanTypeOrder() {
-		// contractVersion,outcome,replay,refusalCode,message,row (the @BeanType-pinned order).
+		// contractVersion,outcome,replay,refusalCode,message,row,resultForm (the @BeanType-pinned order).
 		var r = ActionResult.success(Map.of("id", "x")).replay(true).message("done");
 		Map<?,?> actual = Json.to(Json.of(r), Map.class);
 		assertEquals(List.of("contractVersion", "outcome", "replay", "message", "row"), new ArrayList<>(actual.keySet()));
 	}
 
+	//------------------------------------------------------------------------------------------------------------------
+	// resultForm - the additive receipt pointer (WORK-J0513 Scope A)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a05_resultForm_isAppendedLast_neverInterleaved() {
+		// APPENDED, not interleaved: a reader positioned on the frozen prefix keeps reading the same keys in the
+		// same order, which is what makes this additive rather than a contract change.
+		var r = ActionResult.success(Map.of("id", "x")).replay(true).message("done").resultForm("/x/receipt");
+		Map<?,?> actual = Json.to(Json.of(r), Map.class);
+		assertEquals(List.of("contractVersion", "outcome", "replay", "message", "row", "resultForm"),
+			new ArrayList<>(actual.keySet()));
+	}
+
+	@Test void a06_resultForm_omittedWhenUnset() {
+		for (var r : List.of(ActionResult.success(null), ActionResult.failure(), ActionResult.unknown())) {
+			var json = Json.of(r);
+			assertFalse(json.contains("resultForm"), () -> "unset resultForm leaked:\n" + json);
+		}
+		assertFalse(Json.of(ActionResult.success(null).resultForm(null)).contains("resultForm"));
+	}
+
+	@Test void a07_resultForm_doesNotBumpTheContractVersion() {
+		// The no-bump is the whole additive claim: an older runtime that has never heard of resultForm keeps
+		// accepting these payloads and simply ignores the key.
+		assertEquals("1", ActionResult.CONTRACT_VERSION);
+		var json = Json.of(ActionResult.success(null).resultForm("/x/receipt"));
+		assertTrue(json.contains("\"contractVersion\":\"1\""), json);
+		assertTrue(json.contains("\"resultForm\":\"/x/receipt\""), json);
+	}
+
+	@Test void a08_resultForm_isFluentAndReadable() {
+		assertEquals("/x/receipt", ActionResult.success(null).resultForm("/x/receipt").resultForm);
+	}
+
 	//------------------------------------------------------------------------------------------------------------------
 	// Outcome discriminator + reserved async terminal states (MED-8)
 	//------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogChildCatalog_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogChildCatalog_Test.java
new file mode 100644
index 0000000000..70fb4dc992
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogChildCatalog_Test.java
@@ -0,0 +1,378 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * The dialog-scoped child-action catalog (WORK-J0513 Scope B): {@code ModalDef.childActions} lets a
+ * {@code type="action"} input inside one dialog's form open a STACKED step that is not a row action, and therefore
+ * appears in no row's action menu and in no ribbon.
+ *
+ * <p>Two layers:
+ * <ul class='spaced-list'>
+ * 	<li><b>Default-gate source pins</b> (a-group): the catalog's scoping is structural rather than a filter some
+ * 		menu builder has to remember, the catalog reaches the click site as a threaded parameter rather than as
+ * 		framework state on a DOM node, and the resolution ORDER puts the row-action check first so a served
+ * 		payload can never shadow - and thereby bypass the gating of - a declared row action.
+ * 	<li><b>A behavioral harness</b> ({@code dialog-child-catalog.cjs}) driving real clicks through both
+ * 		row-painting paths.  Runs when {@code node} is on {@code PATH}.
+ * </ul>
+ */
+class ViewsJs_DialogChildCatalog_Test extends TestBase {
+
+	private static String resource(String name) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(name)) {
+			assertNotNull(in, () -> "missing classpath resource: " + name);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	private static String viewsJs() throws IOException { return resource(ViewsMixin.VIEWS_JS_RESOURCE); }
+	private static String rendersJs() throws IOException { return resource(ViewsMixin.RENDERS_JS_RESOURCE); }
+
+	private static String functionBody(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> "'" + signature + "' not found in juneau-views.js");
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) Default-gate source pins
+	//------------------------------------------------------------------------------------------------------------------
+
+	/**
+	 * The catalog is DIALOG-SCOPED structurally: it is read from the per-open payload, so the row-action menu and
+	 * the ribbon resolver never see it.
+	 *
+	 * <p>This is the same disjoint-catalog argument WORK-J0512 made for ribbon dialogs, and it matters for the same
+	 * reason: an exclusion filter in {@code buildRowActionMenu} would be a thing to maintain, and a thing to
+	 * forget.  Nothing to filter is stronger than a filter that works.
+	 */
+	@Test void a01_theCatalogIsReadFromThePerOpenPayloadOnly() throws Exception {
+		var views = viewsJs();
+		var overlay = functionBody(views, "\tfunction buildDialogOverlay(");
+		assertTrue(overlay.contains("const childCatalog = modal?.childActions ?? null;"), overlay);
+		// The three catalogs stay disjoint: neither existing resolver was widened to look at childActions.
+		for (var fn : List.of("\tfunction dialogActionIsOpenable(", "\tfunction findRibbonDialogAction(",
+				"\tfunction buildRowActionMenu(", "\tfunction activatePillAction(")) {
+			var body = functionBody(views, fn);
+			assertFalse(body.contains("childActions"),
+				() -> fn + " must not see the dialog-scoped catalog:\n" + body);
+			assertFalse(body.contains("childActionById"),
+				() -> fn + " must not resolve against the dialog-scoped catalog:\n" + body);
+		}
+	}
+
+	/**
+	 * The catalog reaches the click site as a THREADED PARAMETER.
+	 *
+	 * <p>Deliberately not stamped on the dialog element - framework state on a DOM node is forgeable from the
+	 * document - and deliberately not looked up from {@code topLayer()} at click time, which would assume the
+	 * button's own dialog is topmost.  That is true today and is exactly the kind of assumption a later stacked
+	 * surface breaks silently.
+	 */
+	@Test void a02_theCatalogIsThreadedNotStampedOnTheDom() throws Exception {
+		var views = viewsJs();
+		// One trailing optional parameter on each of the six functions in the chain, so every existing positional
+		// call site keeps working unchanged.
+		assertTrue(views.contains("function appendDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {"), views);
+		assertTrue(views.contains("function appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {"), views);
+		assertTrue(views.contains("function appendDialogFormRow(host, dialog, f, table, tr, ctx, seq, childCatalog) {"), views);
+		assertTrue(views.contains("function paintFormControl(row, f, id, table, tr, ctx, childCatalog) {"), views);
+		assertTrue(views.contains("function buildActionFormControl(f, table, tr, ctx, row, childCatalog) {"), views);
+		assertTrue(views.contains("function openFormActionDialog(actionId, table, tr, ctx, childCatalog) {"), views);
+		// Both row-painting paths thread it (a sectioned form is the second one, and is easy to miss).
+		assertTrue(functionBody(views, "\tfunction appendDialogForm(")
+			.contains("appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog)"), views);
+		assertTrue(functionBody(views, "\tfunction appendSectionedDialogForm(")
+			.contains("appendDialogFormRow(pane, dialog, field, table, tr, ctx, seq, childCatalog)"), views);
+		// It is never stamped onto an element.
+		var resolver = functionBody(views, "\tfunction childActionById(");
+		assertFalse(resolver.contains("dataset"), resolver);
+		assertFalse(resolver.contains("topLayer("), resolver);
+	}
+
+	/**
+	 * The resolution ORDER, which is the whole security property.
+	 *
+	 * <p>The existing row-action check runs FIRST and unchanged, {@code enabledWhen} included, so a served payload
+	 * can never shadow a declared row action.  A collision with a NON-dialog row action is a fail-closed miss and
+	 * the child catalog is not consulted at all - rescuing it there would be the same shadowing hole through a
+	 * narrower door.  Only an id that resolves to nothing today reaches the catalog.
+	 */
+	@Test void a03_theRowActionCheckRunsFirstAndTheCatalogIsTheFallback() throws Exception {
+		var views = viewsJs();
+		for (var fn : List.of("\tfunction buildActionFormControl(", "\tfunction openFormActionDialog(")) {
+			var body = functionBody(views, fn);
+			var rowCheck = body.indexOf("dialogActionIsOpenable(ctx, actionId)");
+			var shadow = body.indexOf("rowActionIdExists(ctx, actionId)");
+			var child = body.indexOf("childActionById(childCatalog, actionId)");
+			assertTrue(rowCheck >= 0, () -> fn + " must still run the row-action check:\n" + body);
+			assertTrue(shadow >= 0, () -> fn + " must refuse a non-dialog row-action collision:\n" + body);
+			assertTrue(child >= 0, () -> fn + " must fall back to the dialog catalog:\n" + body);
+			assertTrue(rowCheck < shadow && shadow < child,
+				() -> fn + " must check the row catalog, then the shadow case, then the child catalog:\n" + body);
+			// The gate on the row-action branch was NOT relaxed to accommodate the new fallback.
+			assertTrue(body.contains("firstFailingRowActionRule(target, rowDataForTr(ctx, tr))")
+				|| body.contains("firstFailingRowActionRule(target,"), body);
+		}
+		// rowActionIdExists is deliberately BROADER than dialogActionIsOpenable - it is what makes the
+		// non-dialog collision refusable.
+		var exists = functionBody(views, "\tfunction rowActionIdExists(");
+		assertTrue(exists.contains("ctx?.viewDef?.rowActions"), exists);
+		assertFalse(exists.contains("isDialogAction("),
+			() -> "rowActionIdExists must see NON-dialog row actions too, or a gated direct-submit action can be "
+				+ "shadowed:\n" + exists);
+	}
+
+	/** A `type=action` click on a dialog whose write is in flight or already a receipt is a no-op. */
+	@Test void a04_aStaleChildClickOnABusyDialogIsANoOp() throws Exception {
+		var body = functionBody(viewsJs(), "\tfunction openFormActionDialog(");
+		assertTrue(body.contains("topDialogEl()?.dataset?.juneauDialogBusy === \"1\""), body);
+		// The guard is the FIRST thing it does - after any resolution work it would be checking too late.
+		var guard = body.indexOf("juneauDialogBusy");
+		var resolve = body.indexOf("dialogActionIsOpenable(ctx, actionId)");
+		assertTrue(guard >= 0 && resolve >= 0 && guard < resolve, body);
+		// The SAME marker the hold paints, so there is no second piece of state to keep in sync.
+		assertTrue(functionBody(viewsJs(), "\tfunction beginDialogResultHold(")
+			.contains("ui.dialog.dataset.juneauDialogBusy = \"1\""), body);
+	}
+
+	/**
+	 * The draft cap is measured on the ENCODED value.
+	 *
+	 * <p>Percent-encoding is up to 3x expansion per byte, so a cap applied to the raw JSON would let a ~2KB draft
+	 * become a ~6KB query and blow a server or proxy request-line limit AFTER the client had already told the
+	 * operator it was fine - a refusal arriving as an opaque transport error instead of a visible, actionable one.
+	 */
+	@Test void a05_theDraftCapIsMeasuredOnTheEncodedValue() throws Exception {
+		var views = viewsJs();
+		assertTrue(views.contains("const MAX_DRAFT_QUERY_BYTES = 2048;"), views);
+		var fn = functionBody(views, "\tfunction withDraftQuery(");
+		var encode = fn.indexOf("encodeURIComponent(JSON.stringify(drafts");
+		var measure = fn.indexOf("draftQueryByteLength(encoded) > MAX_DRAFT_QUERY_BYTES");
+		assertTrue(encode >= 0 && measure >= 0 && encode < measure,
+			() -> "the cap must be applied to the ENCODED value, not the raw JSON:\n" + fn);
+		// It returns a COPY rather than mutating the descriptor it was handed.
+		assertTrue(fn.contains("const copy = {};"), fn);
+		// Over the cap it returns null so the caller refuses VISIBLY - never a silently-truncated (and therefore
+		// silently-wrong) prefill.
+		assertTrue(fn.contains("return null;"), fn);
+		assertTrue(functionBody(views, "\tfunction renderDialogDraftRefusal(")
+			.contains("MAX_DRAFT_QUERY_BYTES"), views);
+	}
+
+	/** The child is adapted to the shape openActionDialog already takes, rather than reimplementing the machinery. */
+	@Test void a06_aChildRidesTheSameReviewedDialogMachinery() throws Exception {
+		var fn = functionBody(viewsJs(), "\tfunction openChildActionDialog(");
+		assertTrue(fn.contains("openActionDialog({"), fn);
+		assertTrue(fn.contains("type: \"dialog\""), fn);
+		// No parallel fetch / submit / settle path.
+		for (var forbidden : List.of("fetch(", "submitRowAction(", "settleActionResponse(", "pushLayer("))
+			assertFalse(fn.contains(forbidden),
+				() -> "a child must reuse the shared opener, not re-plumb it - it grew '" + forbidden + "':\n" + fn);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Behavioral harness
+	//------------------------------------------------------------------------------------------------------------------
+
+	private static Map<?,?> report;
+
+	@BeforeAll
+	static void probeIfNodeAvailable() throws Exception {
+		if (!nodeAvailable())
+			return;
+		var harness = locateHarness();
+		if (harness == null)
+			return;
+		var rendersFile = Files.createTempFile("juneau-renders-", ".js");
+		var viewsFile = Files.createTempFile("juneau-views-", ".js");
+		try {
+			Files.writeString(rendersFile, rendersJs(), UTF_8);
+			Files.writeString(viewsFile, viewsJs(), UTF_8);
+			report = Json.to(runNode(harness, rendersFile, viewsFile), Map.class);
+		} finally {
+			Files.deleteIfExists(rendersFile);
+			Files.deleteIfExists(viewsFile);
+		}
+	}
+
+	private static boolean nodeAvailable() {
+		try {
+			var p = new ProcessBuilder("node", "--version").redirectErrorStream(true).start();
+			if (!p.waitFor(5, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				return false;
+			}
+			return p.exitValue() == 0;
+		} catch (Exception e) {
+			return false;
+		}
+	}
+
+	private static Path locateHarness() {
+		var basedir = System.getProperty("basedir");
+		if (basedir != null) {
+			var p = Path.of(basedir, "src/test/js/dialog-child-catalog.cjs");
+			if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+		}
+		for (var rel : List.of(
+			"src/test/js/dialog-child-catalog.cjs",
+			"juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs"
+		)) {
+			var p = Path.of(rel);
+			if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+		}
+		return null;
+	}
+
+	private static String runNode(Path harness, Path rendersJs, Path viewsJs) throws Exception {
+		var stdout = Files.createTempFile("dialog-child-catalog-stdout-", ".json");
+		var stderr = Files.createTempFile("dialog-child-catalog-stderr-", ".txt");
+		try {
+			var pb = new ProcessBuilder(List.of("node", harness.toString(), rendersJs.toString(), viewsJs.toString()))
+				.redirectOutput(stdout.toFile())
+				.redirectError(stderr.toFile());
+			var p = pb.start();
+			if (!p.waitFor(30, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				fail("dialog-child-catalog.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+			}
+			if (p.exitValue() != 0)
+				fail("dialog-child-catalog.cjs exited " + p.exitValue() + "; stderr:\n" + quietRead(stderr)
+					+ "\nstdout:\n" + quietRead(stdout));
+			return Files.readString(stdout, UTF_8);
+		} finally {
+			Files.deleteIfExists(stdout);
+			Files.deleteIfExists(stderr);
+		}
+	}
+
+	private static String quietRead(Path p) {
+		try { return Files.readString(p, UTF_8); }
+		catch (IOException e) { return "(unreadable: " + e.getMessage() + ")"; }
+	}
+
+	private static Map<?,?> report() {
+		assumeTrue(report != null, "node not available or dialog-child-catalog.cjs not found — behavioral layer skipped");
+		return report;
+	}
+
+	private static void assertAllTrue(String... keys) {
+		var r = report();
+		for (var k : keys)
+			assertEquals(true, r.get(k), () -> k + " was " + r.get(k) + " in " + r);
+	}
+
+	@Test void b00_theCatalogSeamIsWired() {
+		assertEquals(true, report().get("hasCatalogSeam"), report()::toString);
+	}
+
+	/** The case that fails closed today: an id in neither row catalog, now reachable from its own dialog. */
+	@Test void b01_aChildActionOpensAStackedStep() {
+		assertAllTrue("open_parentOpened", "open_childButtonPainted", "open_childButtonEnabled",
+			"open_childButtonNotMarkedMissing", "open_childStackedALayer", "open_childFormWasFetched",
+			"open_noRefusalPainted");
+	}
+
+	/** Scoping, observed: the catalog rides the per-open payload, so no other resolver can see it. */
+	@Test void b02_aChildActionIsInvisibleToEveryOtherCatalog() {
+		assertAllTrue("scope_rowMenuExcludesChild", "scope_ribbonResolverExcludesChild",
+			"scope_rowResolverExcludesChild");
+		assertEquals("step1", report().get("scope_rowMenuIds"),
+			() -> "the row menu must contain the row action and nothing from the dialog catalog: " + report());
+	}
+
+	/** Precedence: a declared DIALOG row action of the same id wins, and its enabledWhen gate still applies. */
+	@Test void b03_aDeclaredRowActionWinsAnIdCollisionAndStaysGated() {
+		assertAllTrue("precedence_rowActionWinsAndStaysGated", "precedence_gateReasonSurfaced",
+			"precedence_reasonOnTheControlTitle", "precedence_notMarkedMissing");
+	}
+
+	/**
+	 * A collision with a NON-dialog row action is a fail-closed refusal, not a fall-through.
+	 *
+	 * <p>The dangerous case is a gated direct-submit action - a Delete, say.  Rescuing the collision by consulting
+	 * the child catalog would let a served payload shadow it and bypass its gating through a narrower door, which
+	 * is the same hole the precedence rule exists to close.
+	 */
+	@Test void b04_aNonDialogRowActionCollisionIsRefusedNotRescued() {
+		assertAllTrue("shadow_buttonPaintedDisabled", "shadow_buttonMarkedMissing", "shadow_ariaDisabled",
+			"shadow_clickIsAVisibleRefusal", "shadow_refusalNamesTheId", "shadow_noChildOpened");
+	}
+
+	/** An id in neither catalog stays today's paint, and a click is a visible refusal - never a throw. */
+	@Test void b05_anUnresolvableIdStaysFailClosed() {
+		assertAllTrue("unknown_buttonDisabled", "unknown_buttonMarkedMissing", "unknown_clickIsAVisibleRefusal",
+			"unknown_noChildOpened", "unknown_absentCatalogSameAsEmpty");
+	}
+
+	/** A child consumes a real depth slot, so the cap refuses in the same place with the same visible notice. */
+	@Test void b06_aChildIsARealLayerAndTheDepthCapStillHolds() {
+		assertAllTrue("depth_childIsARealLayer", "depth_thirdIsRefused", "depth_refusalPainted");
+	}
+
+	/** carryDrafts: without it a Review step opens EMPTY and loses everything the operator typed. */
+	@Test void b07_carryDraftsCarriesTheParentsUnsubmittedValues() {
+		assertAllTrue("drafts_queryParamPresent", "drafts_carriedTheEditedValue", "drafts_childOpened");
+		assertEquals("/x/review-form?juneauDrafts=%7B%22note%22%3A%22edited-in-place%22%7D",
+			report().get("drafts_childGetUrl"), report()::toString);
+	}
+
+	@Test void b08_theDraftCapIsEnforcedOnTheEncodedValue() {
+		assertEquals(2048, report().get("cap_value"), report()::toString);
+		assertAllTrue("cap_smallDraftPasses", "cap_overCapIsRefused", "cap_returnsACopyAndDoesNotMutate",
+			"cap_appendsToAnExistingQuery");
+		// The case that distinguishes the two measurements: raw JSON under the cap whose encoded form is over it.
+		assertAllTrue("cap_rawJsonWouldHaveFit", "cap_encodedFormIsRefused");
+	}
+
+	/** Over the cap the child does NOT open, and the operator is told why rather than getting a wrong prefill. */
+	@Test void b09_anOverCapDraftIsAVisibleRefusal() {
+		assertAllTrue("capRefusal_noChildOpened", "capRefusal_visible", "capRefusal_namesTheCap");
+	}
+
+	@Test void b10_aChildClickOnABusyDialogIsANoOp() {
+		assertAllTrue("busy_noChildStacked", "busy_noRefusalPainted", "busy_worksAgainOnceCleared");
+	}
+
+	/** The sectioned form is the second row-painting path, and threading it is easy to miss. */
+	@Test void b11_aSectionedParentFormThreadsTheCatalogToo() {
+		assertAllTrue("sectioned_buttonPainted", "sectioned_buttonEnabledFromCatalog",
+			"sectioned_withoutCatalogStaysDisabled");
+	}
+
+	@Test void b12_theResolverHelpersInIsolation() {
+		assertAllTrue("helpers_childActionByIdFindsFirst", "helpers_childActionByIdMissIsNull",
+			"helpers_rowActionIdExistsSeesNonDialog", "helpers_dialogActionIsOpenableDoesNot",
+			"helpers_rowActionIdExistsMiss", "helpers_rowActionIdExistsNullSafe");
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java
index 655a2d060f..d5de90e404 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java
@@ -192,6 +192,18 @@ class ViewsJs_DialogForm_Test extends TestBase {
 		assertEquals(true, r.get("missing_marker"));
 	}
 
+	/**
+	 * The trailing {@code childCatalog} parameter (WORK-J0513) is additive at the CALL SITE, not just in the
+	 * signature: the six-argument calls above are byte-identical to their pre-J0513 form and still paint today's
+	 * fail-closed control, while the same field resolves once a catalog is supplied.
+	 */
+	@Test void b04a_theTrailingChildCatalogParameterIsOptional() {
+		var r = report();
+		assertEquals(true, r.get("catalog_enabled"), r::toString);
+		assertEquals(true, r.get("catalog_notMarkedMissing"), r::toString);
+		assertEquals(true, r.get("catalog_stillSkippedByCollect"), r::toString);
+	}
+
 	@Test void b05_collectReadsTypedValuesAndSkipsActions() {
 		var r = report();
 		assertEquals(true, r.get("collect_notes"));
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogReceipt_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogReceipt_Test.java
new file mode 100644
index 0000000000..6fbdb695a5
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogReceipt_Test.java
@@ -0,0 +1,550 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * The in-dialog result <b>receipt</b> (WORK-J0513 Scope A): a dialog that opted in with
+ * {@code ModalDef.keepOpenOnSubmit} keeps its layer across the submit, and the runtime paints the write's result
+ * into that already-open dialog instead of closing it and painting a row banner.
+ *
+ * <p>Two layers, the same split {@code ViewsJs_RibbonDialog_Test} uses:
+ * <ul class='spaced-list'>
+ * 	<li><b>Default-gate source pins</b> (a-group): the <b>coupling guard</b> that keeps
+ * 		{@code submitActionDialog} byte-identical, the <b>enforcement by absence</b> that makes the receipt
+ * 		non-submittable, the hold's one-settle discipline, and the narrow allowlist of sites permitted to tear the
+ * 		dialog stack down automatically.
+ * 	<li><b>A behavioral harness</b> ({@code dialog-receipt.cjs}) walking all nine submit terminals and checking
+ * 		the disposition each settles to.  The interesting failures here - a stuck busy modal, a Confirm left
+ * 		disabled on a retryable refusal, a receipt painted over a still-populated form, one outcome rendered twice
+ * 		- are DOM-shaped and invisible to a source pin.  Runs when {@code node} is on {@code PATH}.
+ * </ul>
+ */
+class ViewsJs_DialogReceipt_Test extends TestBase {
+
+	private static String resource(String name) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(name)) {
+			assertNotNull(in, () -> "missing classpath resource: " + name);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	private static String viewsJs() throws IOException { return resource(ViewsMixin.VIEWS_JS_RESOURCE); }
+	private static String rendersJs() throws IOException { return resource(ViewsMixin.RENDERS_JS_RESOURCE); }
+
+	private static String functionBody(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> "'" + signature + "' not found in juneau-views.js");
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	private static int countOf(String body, String needle) {
+		var n = 0;
+		for (var i = body.indexOf(needle); i >= 0; i = body.indexOf(needle, i + needle.length()))
+			n++;
+		return n;
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) Default-gate source pins
+	//------------------------------------------------------------------------------------------------------------------
+
+	/**
+	 * The COUPLING GUARD, and the single most load-bearing pin in this file.
+	 *
+	 * <p>The receipt needs to know, at settle time, which dialog to paint into - and the obvious way to arrange
+	 * that is a callback parameter on the submit.  {@code submitActionDialog} is the one function EVERY
+	 * {@code present=dialog} submit goes through, and WORK-J0512 put its {@code targetId} precedence rule and that
+	 * rule's tripwire there precisely because it is the only path.  Threading a receipt parameter through it would
+	 * re-open a reviewed function to add a feature that has nothing to do with what it decides.  The coupling
+	 * therefore runs through a ctx-scoped hold registered at CLICK time instead, which leaves this function
+	 * byte-identical.
+	 */
+	@Test void a01_submitActionDialogIsUntouched() throws Exception {
+		var fn = functionBody(viewsJs(), "\tfunction submitActionDialog(");
+		// The J0512 precedence rule and its opt-in gate, quoted verbatim.
+		assertTrue(fn.contains("const targetId = (modal?.selfTargeted && modal?.idempotencyKey != null)"), fn);
+		assertTrue(fn.contains(": (tr?.dataset?.juneauRowId ?? null);"), fn);
+		// Nothing about the receipt reached it: no hold, no callback, no result-form parameter.
+		for (var forbidden : List.of("_resultHold", "beginDialogResultHold", "settleDialogResultHold",
+				"resultForm", "keepOpenOnSubmit", "onResult", "callback")) {
+			assertFalse(fn.contains(forbidden),
+				() -> "submitActionDialog must stay byte-identical - it grew '" + forbidden + "':\n" + fn);
+		}
+		// Its signature is unchanged too: no trailing parameter was appended.
+		assertTrue(viewsJs().contains("function submitActionDialog(modal, action, table, tr, ctx, fields) {"), fn);
+	}
+
+	/** {@code startJobStream} is likewise untouched - T5 closes the dialog BEFORE handing off to it. */
+	@Test void a02_startJobStreamIsUntouched() throws Exception {
+		var fn = functionBody(viewsJs(), "\tfunction startJobStream(");
+		for (var forbidden : List.of("_resultHold", "settleDialogResultHold", "resultForm", "keepOpenOnSubmit"))
+			assertFalse(fn.contains(forbidden), () -> "startJobStream grew '" + forbidden + "':\n" + fn);
+		var settle = functionBody(viewsJs(), "\tfunction settleActionResponse(");
+		// The ORDER is load-bearing: everything startJobStream paints is row-anchored, so a surviving backdrop
+		// would hide the whole progress affordance behind a modal that can never settle.
+		var close = settle.indexOf("settleDialogResultHold(ctx, \"closeCommitted\")");
+		var stream = settle.indexOf("startJobStream(started");
+		assertTrue(close >= 0 && stream >= 0, settle);
+		assertTrue(close < stream, () -> "T5 must close the held dialog BEFORE streaming:\n" + settle);
+	}
+
+	/**
+	 * The receipt painter enforces its non-submittability by ABSENCE.
+	 *
+	 * <p>A structural marker would enforce by veto - a later change can delete the check, and a DOM attribute a
+	 * fixture can forge.  An absence is what a test can pin cheaply and what a careless edit cannot silently
+	 * undo: this painter creates no confirm control and names no submit function, so there is nothing to disable
+	 * and nothing to re-enable.
+	 */
+	@Test void a03_theReceiptPainterCreatesNoSubmitControl() throws Exception {
+		var fn = functionBody(viewsJs(), "\tfunction paintDialogReceipt(");
+		for (var forbidden : List.of("juneau-view-dialog-confirm", "submitActionDialog", "submitRowAction",
+				"appendDialogForm", "validateDialogForm", "collectDialogFormFields", "innerHTML"))
+			assertFalse(fn.contains(forbidden),
+				() -> "the receipt host must be non-submittable BY CONSTRUCTION - it grew '" + forbidden + "':\n" + fn);
+		// It tears the submitted write's body down rather than painting over it: a terminal post-commit surface
+		// must not carry the live, editable inputs of a write that already landed.
+		assertTrue(fn.contains("removeDialogActionsRow(dialog)"), fn);
+		assertTrue(fn.contains("teardownCommittedWriteBody(dialog)"), fn);
+		// And it opens no layer - the forbidden shape this whole mechanism exists to avoid.
+		for (var forbidden : List.of("pushLayer(", "showActionDialog(", "buildDialogOverlay(", "openActionDialog("))
+			assertFalse(fn.contains(forbidden), () -> "a receipt is a SWAP, not a new layer - it grew '"
+				+ forbidden + "':\n" + fn);
+	}
+
+	/** The body teardown must take the FORM wrapper, not merely the actions row. */
+	@Test void a04_theCommittedWriteBodyTeardownTakesTheInputsToo() throws Exception {
+		var fn = functionBody(viewsJs(), "\tfunction teardownCommittedWriteBody(");
+		// Removing the actions row alone is not sufficient: the submitted inputs live in a SEPARATE wrapper, and
+		// leaving it standing renders the server's result underneath a still-editable form for a committed write.
+		assertTrue(fn.contains("juneau-view-dialog-form"), fn);
+		assertTrue(fn.contains("juneau-view-dialog-fields"), fn);
+	}
+
+	/**
+	 * One hand-off per submit, guaranteed by clearing the hold FIRST.
+	 *
+	 * <p>The submit graph has nine terminals; they are mutually-exclusive paths through one {@code submitRowAction}
+	 * call, so clearing before dispatching means a second settle on the same hold finds nothing. That ordering is
+	 * the whole mechanism, so it is pinned as an ordering and not just as a presence.
+	 */
+	@Test void a05_theHoldIsClearedBeforeItIsDispatched() throws Exception {
+		var fn = functionBody(viewsJs(), "\tfunction settleDialogResultHold(");
+		var read = fn.indexOf("const hold = ctx?._resultHold");
+		var clear = fn.indexOf("ctx._resultHold = null");
+		var live = fn.indexOf("heldLayerIsLive(ctx, hold)");
+		assertTrue(read >= 0 && clear >= 0 && live >= 0, fn);
+		assertTrue(read < clear && clear < live,
+			() -> "the hold must be READ, then CLEARED, then dispatched - or a terminal can settle twice:\n" + fn);
+	}
+
+	/**
+	 * Every terminal in the submit graph hands off exactly once, and the F19 exemption is per-disposition.
+	 *
+	 * <p>{@code closeCommitted} returns {@code false} on purpose: the dialog is gone, so the row / ribbon banner is
+	 * the only surviving signal and suppressing it would leave a committed write with no visible confirmation
+	 * anywhere.  Every other disposition returns {@code true}, because the dialog is still on screen and painting
+	 * the row banner too would render one outcome twice, once behind a backdrop.
+	 */
+	@Test void a06_allNineTerminalsHandOffExactlyOnce() throws Exception {
+		var views = viewsJs();
+		var submit = functionBody(views, "\tfunction submitRowAction(");
+		var settle = functionBody(views, "\tfunction settleActionResponse(");
+		// T1 (client refuse) + T2 (network reject).
+		assertEquals(2, countOf(submit, "settleDialogResultHold(ctx, "),
+			() -> "submitRowAction has exactly two terminals:\n" + submit);
+		// T3..T8 - six terminals, six hand-offs, one each.
+		assertEquals(6, countOf(settle, "settleDialogResultHold(ctx, "),
+			() -> "settleActionResponse has exactly six terminals:\n" + settle);
+		// T9 is shared by BOTH readBodyText chains, so it is one function reached from two `.catch` arms.
+		assertEquals(2, countOf(settle, "settleBodyReadFailure(action, table, tr, ctx)"),
+			() -> "both body-read chains must route to T9, or a held dialog hangs forever:\n" + settle);
+		assertEquals(1, countOf(functionBody(views, "\tfunction settleBodyReadFailure("),
+			"settleDialogResultHold(ctx, "), views);
+		// The dispositions actually used, and no others.
+		for (var d : List.of("\"retryable\"", "\"terminal\"", "\"closeCommitted\"", "\"receipt\""))
+			assertTrue(views.contains("settleDialogResultHold(ctx, " + d + ")")
+				|| views.contains("settleDialogResultHold(ctx, disposition)"), d);
+		var hold = functionBody(views, "\tfunction settleDialogResultHold(");
+		assertTrue(hold.contains("if (disposition === \"closeCommitted\")"), hold);
+		assertTrue(hold.contains("closeActionDialog(ctx);\n\t\t\treturn false;"),
+			() -> "closeCommitted must return FALSE so the caller's row painter still runs:\n" + hold);
+	}
+
+	/**
+	 * The receipt terminal FETCHES before it settles.
+	 *
+	 * <p>Settling first would clear {@code ctx._resultHold} before the GET resolved, so every arm inside
+	 * {@code fetchResultForm} would take the layer-gone escape and a committed write with a perfectly good receipt
+	 * payload would paint no receipt at all - silently.
+	 */
+	@Test void a07_theReceiptTerminalFetchesBeforeItSettles() throws Exception {
+		var settle = functionBody(viewsJs(), "\tfunction settleActionResponse(");
+		var fetch = settle.indexOf("fetchResultForm(result.resultForm");
+		assertTrue(fetch >= 0, settle);
+		// The receipt branch returns immediately after the fetch: it must not also fall through to a settle.
+		var tail = settle.substring(fetch);
+		var nextSettle = tail.indexOf("settleDialogResultHold(");
+		var ret = tail.indexOf("return;");
+		assertTrue(ret >= 0 && (nextSettle < 0 || ret < nextSettle),
+			() -> "the receipt branch must fetch and RETURN, never fetch and then settle:\n" + tail);
+		// And the fetch is gated on a HELD success carrying a resultForm - never on a failure, refusal or unknown,
+		// so no result host can appear for a write that did not commit.
+		assertTrue(settle.contains("if (held && outcome === \"success\" && ! isBlankToken(result.resultForm))"),
+			settle);
+	}
+
+	/**
+	 * The automatic-teardown allowlist.
+	 *
+	 * <p>Exactly three NEW sites may call {@code closeActionDialog(} - the {@code closeCommitted} branch, the
+	 * receipt's Close-button wiring, and the shared Close-only row helper - plus the pre-existing grandfathered
+	 * {@code teardownTable} caller.  Every OTHER terminal path must end with the dialog OPEN and delegate its exit
+	 * to a Close button the operator presses, because a terminal that closes the stack behind the operator's back
+	 * destroys the message it was about to show them.
+	 */
+	@Test void a08_noTerminalPathTearsTheStackDownAutomatically() throws Exception {
+		var views = viewsJs();
+		// The BAN, stated as absences on the automatic paths: none of these may close the stack, because a
+		// terminal that closes it behind the operator's back destroys the message it was about to show them.
+		for (var fn : List.of("\tfunction settleBodyReadFailure(", "\tfunction fetchResultForm(",
+				"\tfunction renderHeldResultNotice(", "\tfunction renderResultFormIgnored(",
+				"\tfunction beginDialogResultHold(", "\tfunction removeDialogActionsRow(",
+				"\tfunction teardownCommittedWriteBody(", "\tfunction restoreHeldChildActions(")) {
+			var body = functionBody(views, fn);
+			assertFalse(body.contains("closeActionDialog("),
+				() -> fn + " must not tear the stack down automatically:\n" + body);
+		}
+		// The three permitted NEW sites.  Two of them are CLICK LISTENERS - a Close button the operator presses is
+		// that operator's exit, not an automatic teardown - and the third is the one disposition whose write is
+		// known-committed and whose dialog is therefore meant to go.
+		assertTrue(functionBody(views, "\tfunction appendDialogCloseOnlyRow(")
+			.contains("close.addEventListener(\"click\", function () { closeActionDialog(ctx); });"), views);
+		var receipt = functionBody(views, "\tfunction paintDialogReceipt(");
+		assertTrue(receipt.contains("close.addEventListener(\"click\", function () { closeActionDialog(ctx); });"),
+			receipt);
+		assertEquals(1, countOf(receipt, "closeActionDialog("),
+			() -> "the receipt's ONLY teardown is its Close button's listener:\n" + receipt);
+		var settle = functionBody(views, "\tfunction settleDialogResultHold(");
+		assertEquals(1, countOf(settle, "closeActionDialog("),
+			() -> "exactly one disposition may close the stack:\n" + settle);
+		var branch = settle.indexOf("if (disposition === \"closeCommitted\")");
+		assertTrue(branch >= 0 && branch < settle.indexOf("closeActionDialog("),
+			() -> "the one teardown must sit inside the closeCommitted branch:\n" + settle);
+	}
+
+	/** Every terminal Close-only row comes from the ONE shared helper, so the operator's exit is identical. */
+	@Test void a09_terminalSurfacesShareOneCloseOnlyRow() throws Exception {
+		var views = viewsJs();
+		var helper = functionBody(views, "\tfunction appendDialogCloseOnlyRow(");
+		assertTrue(helper.contains("juneau-view-dialog-actions"), helper);
+		assertTrue(helper.contains("dialog-result-close"), helper);
+		assertFalse(helper.contains("juneau-view-dialog-confirm"),
+			() -> "a Close-only row is Close ONLY:\n" + helper);
+		// The `terminal` disposition removes the live actions row FIRST, so Confirm is detached (listener and all)
+		// rather than merely disabled, and then appends the shared row.
+		var settle = functionBody(views, "\tfunction settleDialogResultHold(");
+		var remove = settle.indexOf("removeDialogActionsRow(hold.dialog)");
+		var append = settle.indexOf("appendDialogCloseOnlyRow(hold.dialog, ctx)");
+		assertTrue(remove >= 0 && append >= 0 && remove < append, settle);
+		assertTrue(functionBody(views, "\tfunction removeDialogActionsRow(").contains("removeEl(el)"), views);
+	}
+
+	/** Painters use createElement + textContent only - live result data is attacker-influenceable. */
+	@Test void a10_noInnerHtmlInAnyNewPainter() throws Exception {
+		var views = viewsJs();
+		for (var fn : List.of("\tfunction paintDialogReceipt(", "\tfunction buildModalFieldValueNode(",
+				"\tfunction buildModalFieldList(", "\tfunction buildFieldCopyButton(",
+				"\tfunction appendDialogCloseOnlyRow(", "\tfunction renderResultFormIgnored(")) {
+			var body = functionBody(views, fn);
+			assertFalse(body.contains("innerHTML"), () -> fn + " must not assign innerHTML:\n" + body);
+		}
+		assertTrue(functionBody(views, "\tfunction buildModalFieldValueNode(").contains("pre.textContent = value"),
+			views);
+	}
+
+	/** The copy affordance is feature-detected, caught, and wrapped - it can never throw out of a click handler. */
+	@Test void a11_theCopyButtonCannotThrow() throws Exception {
+		var fn = functionBody(viewsJs(), "\tfunction buildFieldCopyButton(");
+		assertTrue(fn.contains("typeof clip?.writeText !== \"function\""), fn);
+		assertTrue(fn.contains(".catch(function ()"), fn);
+		assertTrue(fn.contains("try {") && fn.contains("catch (e)"), fn);
+	}
+
+	/** The three constants that carry the terminal wording, so a diagnosis is never an empty banner. */
+	@Test void a12_theTerminalNoticesAreNamedConstants() throws Exception {
+		var views = viewsJs();
+		for (var c : List.of("REQUEST_UNCONFIRMED_NOTICE", "BODY_UNREADABLE_NOTICE",
+				"RESULT_FORM_UNAVAILABLE_NOTICE", "RESULT_FORM_REFUSED_NOTICE"))
+			assertTrue(views.contains("const " + c + " ="), () -> "missing named notice constant: " + c);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Behavioral harness
+	//------------------------------------------------------------------------------------------------------------------
+
+	private static Map<?,?> report;
+
+	@BeforeAll
+	static void probeIfNodeAvailable() throws Exception {
+		if (!nodeAvailable())
+			return;
+		var harness = locateHarness();
+		if (harness == null)
+			return;
+		var rendersFile = Files.createTempFile("juneau-renders-", ".js");
+		var viewsFile = Files.createTempFile("juneau-views-", ".js");
+		try {
+			Files.writeString(rendersFile, rendersJs(), UTF_8);
+			Files.writeString(viewsFile, viewsJs(), UTF_8);
+			report = Json.to(runNode(harness, rendersFile, viewsFile), Map.class);
+		} finally {
+			Files.deleteIfExists(rendersFile);
+			Files.deleteIfExists(viewsFile);
+		}
+	}
+
+	private static boolean nodeAvailable() {
+		try {
+			var p = new ProcessBuilder("node", "--version").redirectErrorStream(true).start();
+			if (!p.waitFor(5, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				return false;
+			}
+			return p.exitValue() == 0;
+		} catch (Exception e) {
+			return false;
+		}
+	}
+
+	private static Path locateHarness() {
+		var basedir = System.getProperty("basedir");
+		if (basedir != null) {
+			var p = Path.of(basedir, "src/test/js/dialog-receipt.cjs");
+			if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+		}
+		for (var rel : List.of(
+			"src/test/js/dialog-receipt.cjs",
+			"juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs"
+		)) {
+			var p = Path.of(rel);
+			if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+		}
+		return null;
+	}
+
+	private static String runNode(Path harness, Path rendersJs, Path viewsJs) throws Exception {
+		var stdout = Files.createTempFile("dialog-receipt-stdout-", ".json");
+		var stderr = Files.createTempFile("dialog-receipt-stderr-", ".txt");
+		try {
+			var pb = new ProcessBuilder(List.of("node", harness.toString(), rendersJs.toString(), viewsJs.toString()))
+				.redirectOutput(stdout.toFile())
+				.redirectError(stderr.toFile());
+			var p = pb.start();
+			if (!p.waitFor(30, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				fail("dialog-receipt.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+			}
+			if (p.exitValue() != 0)
+				fail("dialog-receipt.cjs exited " + p.exitValue() + "; stderr:\n" + quietRead(stderr)
+					+ "\nstdout:\n" + quietRead(stdout));
+			return Files.readString(stdout, UTF_8);
+		} finally {
+			Files.deleteIfExists(stdout);
+			Files.deleteIfExists(stderr);
+		}
+	}
+
+	private static String quietRead(Path p) {
+		try { return Files.readString(p, UTF_8); }
+		catch (IOException e) { return "(unreadable: " + e.getMessage() + ")"; }
+	}
+
+	private static Map<?,?> report() {
+		assumeTrue(report != null, "node not available or dialog-receipt.cjs not found — behavioral layer skipped");
+		return report;
+	}
+
+	private static void assertAllTrue(String... keys) {
+		var r = report();
+		for (var k : keys)
+			assertEquals(true, r.get(k), () -> k + " was " + r.get(k) + " in " + r);
+	}
+
+	@Test void b00_theReceiptSeamIsWired() {
+		assertEquals(true, report().get("hasReceiptSeam"), report()::toString);
+	}
+
+	/** T8-receipt: the swap.  Same layer, no submit controls, the submitted form gone. */
+	@Test void b01_aHeldSuccessWithAResultFormSwapsInPlace() {
+		assertAllTrue("receipt_dialogOpened", "receipt_stillOpenAtSameDepth", "receipt_marked",
+			"receipt_titleFromPayload", "receipt_fieldsRepainted", "receipt_exactlyOneFieldList",
+			"receipt_closeButtonPresent", "receipt_closeTearsDownTheStack");
+	}
+
+	/** The enforcement, observed rather than asserted about the source: there is nothing to submit with. */
+	@Test void b02_theReceiptHostCarriesNoSubmitControls() {
+		assertAllTrue("receipt_noConfirmButton", "receipt_noInputsAtAll", "receipt_submittedFormTornDown");
+	}
+
+	@Test void b03_aReceiptSuppressesTheRowBannerAndClearsBusy() {
+		// The dialog is on screen, so the outcome belongs in it - rendering the row banner too would show one
+		// outcome twice, once behind a backdrop.
+		assertAllTrue("receipt_noRowBanner", "receipt_busyMarkerCleared");
+	}
+
+	@Test void b04_codeKindFieldsPaintAsSelectablePreWithCopy() {
+		assertAllTrue("receipt_codeFieldIsPre", "receipt_codeFieldHasCopyButton",
+			"kinds_ddCount", "kinds_plainIsText", "kinds_codeIsPre", "kinds_codeHasCopy",
+			"kinds_explicitTextIsText", "kinds_allowlistIsExactlyTwo");
+		// An UNRECOGNIZED token falls back to text rather than being trusted - the runtime half of the
+		// allowlist that ModalDef.validate enforces server-side.
+		assertAllTrue("kinds_unknownFallsBackToText");
+	}
+
+	/** T8-commit / T6: with the dialog gone, the row banner is the only surviving signal and must still render. */
+	@Test void b05_aHeldSuccessWithoutAResultFormClosesAndStillPaintsTheRow() {
+		assertAllTrue("commit_dialogClosed", "commit_noDialogInDom", "commit_rowOutcomeRendered", "commit_noReceipt");
+		assertEquals("success", report().get("commit_rowOutcomeState"), report()::toString);
+	}
+
+	/**
+	 * F4: a resultForm on a dialog that never opted in.
+	 *
+	 * <p>The write SUCCEEDED, so the success banner stays byte-identical and this adds exactly one non-alarming
+	 * {@code role=status} diagnostic.  Never a new layer (the forbidden shape), and never a refusal - which would
+	 * make a successful write look failed for what is purely a consumer-authoring mistake.
+	 */
+	@Test void b06_anUnheldResultFormIsDiagnosedNotOpened() {
+		assertAllTrue("f4_dialogClosedAsToday", "f4_noFollowUpGet", "f4_successBannerUnchanged",
+			"f4_diagnosticPresent", "f4_diagnosticIsStatusNotAlert", "f4_noNewLayer");
+	}
+
+	/** F1c: a receipt payload carrying a form is the forbidden shape arriving through the receipt door. */
+	@Test void b07_aReceiptPayloadCarryingAFormIsRefusedWithItsOwnWording() {
+		assertAllTrue("f1c_stillOpen", "f1c_notPaintedAsReceipt", "f1c_noInputsPainted", "f1c_closeOnlyRow",
+			"f1c_noConfirm", "f1c_busyCleared");
+		var r = report();
+		// Its own wording, not folded into the transport-failure message: a consumer authoring bug that reads as
+		// a network blip is a bug nobody fixes.
+		assertEquals("The action completed, but its result was not shown: a result form must not carry inputs.",
+			r.get("f1c_noticeText"), r::toString);
+	}
+
+	/**
+	 * F12: the follow-up read failed three different ways, all of them post-commit.
+	 *
+	 * <p>The rejected-promise arm matters as much as the other two: without it a single dropped packet on the
+	 * follow-up read leaves the promise rejected, the terminal unreached, and Confirm stuck disabled + busy on a
+	 * write that had ALREADY COMMITTED.
+	 */
+	@Test void b08_aFailedReceiptReadIsTerminalAndNeverStuck() {
+		assertAllTrue("f12_stillOpen", "f12_closeOnlyRow", "f12_busyCleared", "f12_submittedFormTornDown",
+			"f12reject_stillOpen", "f12reject_closeOnlyRow", "f12reject_busyCleared",
+			"f12reject_noConfirmStuckDisabled", "f12parse_closeOnlyRow", "f12parse_notPaintedAsReceipt");
+		var r = report();
+		var expected = "The action completed, but its result could not be loaded.";
+		for (var k : List.of("f12_noticeText", "f12reject_noticeText", "f12parse_noticeText"))
+			assertEquals(expected, r.get(k), () -> k + " in " + r);
+	}
+
+	/** T1: nothing was even sent, so the operator can fix the declaration and press Confirm again. */
+	@Test void b09_aPreflightClientRefusalIsRetryable() {
+		assertAllTrue("t1_nothingSent", "t1_stillOpen", "t1_noticePresent", "t1_confirmReEnabled",
+			"t1_busyCleared", "t1_noCloseOnlyRow", "t1_noRowBanner", "t1_formStillStanding");
+	}
+
+	/**
+	 * T2: a network reject is AMBIGUOUS, not retryable.
+	 *
+	 * <p>{@code fetch} rejects on a connection dropped AFTER the request was fully sent and processed, so this
+	 * does not prove the write didn't land - re-enabling Confirm would hand the operator a one-click duplicate of
+	 * a non-idempotent write.
+	 */
+	@Test void b10_aNetworkRejectIsTerminalNotRetryable() {
+		assertAllTrue("t2_stillOpen", "t2_noConfirmButton", "t2_closeOnlyRow", "t2_busyCleared",
+			"t2_formStillStanding", "t2_noRowBanner");
+		assertEquals("The request was sent but no response came back.  It may or may not have been applied - "
+			+ "re-check before retrying.", report().get("t2_noticeText"), report()::toString);
+	}
+
+	/** T4 / T8-refusal: a gate said no and nothing was written, so retry is safe and is the point (F11). */
+	@Test void b11_refusalsReEnableConfirm() {
+		assertAllTrue("t4_stillOpen", "t4_noticePresent", "t4_confirmReEnabled", "t4_noCloseOnlyRow",
+			"t4_busyCleared", "t8refuse_confirmReEnabled", "t8refuse_noCloseOnlyRow", "t8refuse_noticePresent");
+	}
+
+	@Test void b12_aTypedFailureIsTerminal() {
+		assertAllTrue("t8fail_stillOpen", "t8fail_terminalCloseOnly", "t8fail_noConfirm", "t8fail_noticePresent");
+	}
+
+	/** A resultForm on a NON-success outcome is ignored outright: no receipt for a write that did not happen. */
+	@Test void b13_aResultFormOnANonSuccessOutcomeIsIgnored() {
+		assertAllTrue("failResultForm_noGetIssued", "failResultForm_noReceiptPainted",
+			"failResultForm_terminalCloseOnly", "failResultForm_noDiagnostic");
+	}
+
+	/** T5: the close happens first, so the row-anchored job affordance is not painted behind a live backdrop. */
+	@Test void b14_aJobPointerClosesTheDialogBeforeStreaming() {
+		assertAllTrue("t5_dialogClosed", "t5_noDialogInDom", "t5_rowAnchoredSignalVisible",
+			"t5_rowSignalIsNotInADialog");
+	}
+
+	/**
+	 * Cancel stays enabled during the hold, and that is safe.
+	 *
+	 * <p>Escape pops the top layer through the shared stack regardless, so the design has to tolerate "dialog gone
+	 * before settle" anyway; given that, disabling Cancel would only remove the operator's obvious exit.  The
+	 * layer-gone case is handled at settle time by falling back to the row banner.
+	 */
+	@Test void b15_cancellingMidFlightFallsBackToTheRowBanner() {
+		assertAllTrue("cancel_layerGone", "cancel_outcomeFellBackToRow", "cancel_noThrowAndNoDialog");
+	}
+
+	@Test void b16_aHoldSettlesExactlyOnce() {
+		assertAllTrue("once_holdRegistered", "once_confirmDisabledAtClickTime", "once_busyMarked",
+			"once_firstSettleHandsOff", "once_holdCleared", "once_secondSettleIsANoOp",
+			"once_closeCommittedDoesNotSuppressRowPaint", "once_noHoldIsFalse", "once_noCtxIsFalse");
+	}
+
+	/**
+	 * Child-action buttons are disabled for the duration of the hold, and restored to their PRIOR state.
+	 *
+	 * <p>Recording which were already disabled is what makes the restore safe: it can never enable a child that
+	 * was gated before the submit started.  They are disabled at all because a live child button during the
+	 * in-flight parent write lets an operator push a child layer onto a dialog that is about to be swapped into a
+	 * receipt.
+	 */
+	@Test void b17_childActionButtonsAreHeldAndRestoredToTheirPriorState() {
+		assertAllTrue("children_liveOneDisabled", "children_gatedOneStaysDisabled", "children_liveOneRestored",
+			"children_gatedOneStillDisabled");
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
index 59e62cc638..6615d9fa64 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
@@ -306,7 +306,8 @@ class ViewsJs_ModalResult_Test extends TestBase {
 	@Test void h05_buildDialogOverlayPaintsForm() throws Exception {
 		var body = viewsJs();
 		var build = fn(body, "function buildDialogOverlay(");
-		// The form paint is threaded the row context (table/tr/ctx) and the dialog-only field-id sequence.
-		assertTrue(build.contains("appendDialogForm(dialog, modal?.form, table, tr, ctx, seq)"), build);
+		// The form paint is threaded the row context (table/tr/ctx), the dialog-only field-id sequence, and - since
+		// WORK-J0513 - this dialog's own child-action catalog, read from the per-open payload.
+		assertTrue(build.contains("appendDialogForm(dialog, modal?.form, table, tr, ctx, seq, childCatalog)"), build);
 	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java
index c03e77b39c..c2634dec78 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java
@@ -151,7 +151,9 @@ class ViewsJs_RibbonStrip_Test extends TestBase {
 	@Test void a06_sectionedFormIsMutuallyExclusiveWithFlatFields() throws Exception {
 		var painter = bodyOf(viewsJs(), "appendDialogForm");
 		assertTrue(painter.contains("if (form.sections?.length)"), painter);
-		assertTrue(painter.contains("appendSectionedDialogForm(dialog, form, table, tr, ctx, seq); return;"), painter);
+		// The trailing `childCatalog` is threaded straight through to the sectioned painter (WORK-J0513): a
+		// `type=action` control inside a pane resolves against the same dialog-scoped catalog a flat form's does.
+		assertTrue(painter.contains("appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog); return;"), painter);
 	}
 
 	private static int count(String haystack, String needle) {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs
new file mode 100644
index 0000000000..91d935807b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs
@@ -0,0 +1,375 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * dialog-child-catalog.cjs - always-on Node harness for the DIALOG-SCOPED child-action catalog (WORK-J0513 Scope
+ * B): `ModalDef.childActions` lets a `type="action"` input inside one dialog's form open a STACKED step that is
+ * not a row action, and therefore appears in no row's action menu and in no ribbon.
+ *
+ * The properties worth a DOM harness rather than a source pin, in the order the resolver decides them:
+ *
+ *   1. Scope. A child action opens from the dialog that declares it, and is invisible to buildRowActionMenu.
+ *   2. Precedence. A declared ROW action wins an id collision, gating included - a served payload must never be
+ *      able to shadow (and thereby bypass the gating of) a row action.  A collision with a NON-dialog row action
+ *      is a fail-closed refusal, not a fall-through to the catalog, because rescuing it there would be the same
+ *      shadowing hole through a narrower door.
+ *   3. Fail-closed. An id in neither catalog stays today's disabled+marked paint, and clicking it is a visible
+ *      refusal - never a throw, never a silent no-op.
+ *   4. Stacking. Opening a child pushes a real layer and consumes a real MAX_DIALOG_DEPTH slot, so the cap still
+ *      refuses at the same place.
+ *   5. Drafts. `carryDrafts` carries the PARENT's typed-but-unsubmitted values on the child's form GET; over the
+ *      cap it is a visible refusal and the child does not open, because a silently-truncated prefill is a
+ *      silently-WRONG prefill.
+ *   6. Hold interaction. A `type=action` click on a dialog whose write is in flight or already settled into a
+ *      receipt is a no-op - it must not stack a child onto a committed layer.
+ *
+ * Usage:  node dialog-child-catalog.cjs <juneau-renders.js> <juneau-views.js>
+ *
+ * Prints ONE JSON object to stdout; every assertion lives in the Java test.
+ */
+'use strict';
+
+const path = require('node:path');
+const { makeEnv, loadViews, jsonResponse } = require(path.join(__dirname, 'views-dom-shim.cjs'));
+
+const rendersJsPath = process.argv[2];
+const viewsJsPath = process.argv[3];
+if (!rendersJsPath || !viewsJsPath) {
+	console.error('usage: node dialog-child-catalog.cjs <juneau-renders.js> <juneau-views.js>');
+	process.exit(2);
+}
+
+const PARENT_FORM = '/x/step1-form';
+const CHILD_FORM = '/x/review-form';
+
+function load() {
+	const env = makeEnv();
+	return loadViews(rendersJsPath, viewsJsPath, env);
+}
+
+const out = {};
+{
+	const probe = load();
+	out.hasCatalogSeam = !!(probe.I
+		&& typeof probe.I.childActionById === 'function'
+		&& typeof probe.I.rowActionIdExists === 'function'
+		&& typeof probe.I.withDraftQuery === 'function'
+		&& typeof probe.I.openChildActionDialog === 'function'
+		&& typeof probe.I.MAX_DRAFT_QUERY_BYTES === 'number');
+}
+if (!out.hasCatalogSeam) { process.stdout.write(JSON.stringify(out)); process.exit(0); }
+
+function flush() {
+	return new Promise(function (r) { let n = 0; (function tick() { if (n++ >= 8) { return r(); } setTimeout(tick, 0); })(); });
+}
+
+const q = (env, sel) => env.body.querySelector(sel);
+const dialogRefusalText = (env) => {
+	const el = q(env, '.juneau-view-dialog-action-refusal');
+	return el ? el.textContent : null;
+};
+
+/**
+ * The parent dialog's served payload: a form carrying a `type=action` input plus the dialog-scoped catalog the
+ * input resolves against.
+ */
+function parentPayload(opts) {
+	const o = opts || {};
+	return {
+		contractVersion: '2',
+		title: 'Step 1',
+		form: {
+			contractVersion: '2',
+			fields: [
+				{ name: 'note', type: 'text', label: 'Note', value: 'typed-by-operator' },
+				{ name: 'go', type: 'action', label: 'Review', actionId: o.actionId || 'review' }
+			]
+		},
+		childActions: o.childActions === undefined
+			? [{ id: 'review', label: 'Review', form: CHILD_FORM, endpoint: '/x/review', method: 'POST' }]
+			: o.childActions
+	};
+}
+
+/** A table + row + ctx with a `present=dialog` row action that serves the parent payload. */
+function fixture(env, opts) {
+	const o = opts || {};
+	const table = env.el('table');
+	table.setAttribute('data-juneau-csrf', 'tok-1');
+	env.body.appendChild(table);
+
+	const tr = env.el('tr');
+	tr.setAttribute('data-juneau-row-id', 'INC-1');
+	tr.dataset.juneauRowId = 'INC-1';
+	const cell = env.el('td');
+	cell.className = 'juneau-view-actions-cell';
+	tr.appendChild(cell);
+	table.appendChild(tr);
+
+	const parent = { id: 'step1', label: 'Step 1', present: 'dialog', method: 'POST', endpoint: '/x/step1', form: PARENT_FORM };
+	const rowActions = [parent].concat(o.extraRowActions || []);
+	const ctx = { table: table, viewDef: { id: 'v1', rowActions: rowActions }, dataTable: {}, activeState: {} };
+	return { table: table, tr: tr, parent: parent, ctx: ctx };
+}
+
+/** Opens the parent dialog through the real fetch path and returns the child-action button inside it. */
+async function openParent(opts) {
+	const o = opts || {};
+	const { env, I } = load();
+	const fx = fixture(env, o);
+	const gets = [];
+	env.setFetch(function (url, init) {
+		const method = (init && init.method) || 'GET';
+		if (method === 'GET') gets.push(String(url));
+		if (method === 'GET' && url === PARENT_FORM) return Promise.resolve(jsonResponse(parentPayload(o)));
+		if (method === 'GET' && String(url).indexOf(CHILD_FORM) === 0)
+			return Promise.resolve(jsonResponse({
+				contractVersion: '2', title: 'Review',
+				form: { contractVersion: '2', fields: [{ name: 'note', type: 'text', label: 'Note' }] }
+			}));
+		return Promise.resolve(jsonResponse({ outcome: 'success' }));
+	});
+	I.openActionDialog(fx.parent, fx.table, fx.tr, fx.ctx);
+	await flush();
+	return { env: env, I: I, fx: fx, gets: gets, btn: q(env, '.juneau-view-dialog-form-action') };
+}
+
+(async function main() {
+
+	// --- 1) A child action opens a STACKED step, and never appears in a row menu -----------------------------
+	{
+		const r = await openParent();
+		const env = r.env, I = r.I;
+		out.open_parentOpened = I.dialogLayerCount() === 1;
+		out.open_childButtonPainted = r.btn != null;
+		// The resolution that fails closed today: the id is in NEITHER row catalog, so pre-J0513 this button was
+		// painted disabled+marked with no way for its author to reach it.
+		out.open_childButtonEnabled = r.btn != null && r.btn.disabled === false;
+		out.open_childButtonNotMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === undefined;
+
+		if (r.btn) r.btn.dispatch('click');
+		await flush();
+		out.open_childStackedALayer = I.dialogLayerCount() === 2;
+		out.open_childFormWasFetched = r.gets.some(function (u) { return u.indexOf(CHILD_FORM) === 0; });
+		out.open_noRefusalPainted = dialogRefusalText(env) === null;
+
+		// Scope: the catalog rides the per-open payload, so no row menu can see it.
+		const menu = I.buildRowActionMenu(r.fx.ctx.viewDef, r.fx.table, r.fx.tr, r.fx.ctx);
+		const ids = menu.querySelectorAll('.juneau-view-action-item').map(function (b) { return b.dataset.actionId; });
+		out.scope_rowMenuIds = ids.join(',');
+		out.scope_rowMenuExcludesChild = ids.indexOf('review') < 0;
+		// ...and neither can the ribbon resolver.
+		out.scope_ribbonResolverExcludesChild = I.ribbonDialogActionIsOpenable(r.fx.ctx, 'review') === false;
+		// ...nor the row-action resolver.
+		out.scope_rowResolverExcludesChild = I.dialogActionIsOpenable(r.fx.ctx, 'review') === false;
+	}
+
+	// --- 2) Precedence: a declared DIALOG row action of the same id wins, gating included --------------------
+	{
+		const rowTwin = {
+			id: 'review', label: 'Row Review', present: 'dialog', method: 'POST', endpoint: '/x/row-review',
+			enabledWhen: [{ field: 'status', op: 'eq', value: 'open', reason: 'only open records' }]
+		};
+		const r = await openParent({ extraRowActions: [rowTwin] });
+		const env = r.env;
+		// The row-action check runs FIRST and unchanged, so its enabledWhen gate still applies - which is exactly
+		// what a served payload must not be able to route around.  This fixture's row carries no `status` at all,
+		// which the shared rule evaluator fails CLOSED on.
+		out.precedence_rowActionWinsAndStaysGated = r.btn != null && r.btn.disabled === true;
+		// Gated, but not silently: the failing rule's own reason is what the control announces.
+		out.precedence_gateReasonSurfaced = (function () {
+			const el = q(env, '[data-juneau-row-action-desc]');
+			return el != null && el.textContent === 'only open records';
+		})();
+		out.precedence_reasonOnTheControlTitle = r.btn != null && r.btn.getAttribute('title') === 'only open records';
+		// NOT the fail-closed "missing" paint: the id resolved, it is the GATE that closed it.
+		out.precedence_notMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === undefined;
+	}
+
+	// --- 3) Precedence: a collision with a NON-dialog row action is a fail-closed REFUSAL -------------------
+	{
+		// A gated direct-submit Delete is the dangerous case: falling through to the child catalog here would let
+		// a served payload shadow it and bypass its gating through a narrower door.
+		const directDelete = { id: 'review', label: 'Delete', method: 'DELETE', endpoint: '/x/rows/{id}' };
+		const r = await openParent({ extraRowActions: [directDelete] });
+		const env = r.env, I = r.I;
+		out.shadow_buttonPaintedDisabled = r.btn != null && r.btn.disabled === true;
+		out.shadow_buttonMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === '1';
+		out.shadow_ariaDisabled = r.btn != null && r.btn.getAttribute('aria-disabled') === 'true';
+		// Defense in depth: the click handler refuses too, rather than relying on the disabled attribute alone.
+		I.openFormActionDialog('review', r.fx.table, r.fx.tr, r.fx.ctx,
+			[{ id: 'review', label: 'Review', form: CHILD_FORM }]);
+		await flush();
+		out.shadow_clickIsAVisibleRefusal = dialogRefusalText(env) != null;
+		out.shadow_refusalNamesTheId = (dialogRefusalText(env) || '').indexOf("'review'") >= 0;
+		out.shadow_noChildOpened = I.dialogLayerCount() === 1;
+	}
+
+	// --- 4) Fail-closed: an id in NEITHER catalog stays disabled, and a click refuses visibly ---------------
+	{
+		const r = await openParent({ actionId: 'nope', childActions: [] });
+		const env = r.env, I = r.I;
+		out.unknown_buttonDisabled = r.btn != null && r.btn.disabled === true;
+		out.unknown_buttonMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === '1';
+		I.openFormActionDialog('nope', r.fx.table, r.fx.tr, r.fx.ctx, []);
+		await flush();
+		out.unknown_clickIsAVisibleRefusal = dialogRefusalText(env) != null;
+		out.unknown_noChildOpened = I.dialogLayerCount() === 1;
+		// An absent catalog behaves exactly like an empty one (the parameter is optional by design, so every
+		// existing positional caller keeps working unchanged).
+		out.unknown_absentCatalogSameAsEmpty = I.childActionById(null, 'review') === null
+			&& I.childActionById(undefined, 'review') === null
+			&& I.childActionById([], 'review') === null;
+	}
+
+	// --- 5) Stacking: a child consumes a real depth slot, so the cap still refuses at the same place --------
+	{
+		const r = await openParent();
+		const I = r.I;
+		if (r.btn) r.btn.dispatch('click');
+		await flush();
+		out.depth_childIsARealLayer = I.dialogLayerCount() === 2 && I.MAX_DIALOG_DEPTH === 2;
+		// A third is the visible depth refusal, not a third overlay.
+		I.openChildActionDialog({ id: 'review', label: 'Review', form: CHILD_FORM }, r.fx.table, r.fx.tr, r.fx.ctx);
+		await flush();
+		out.depth_thirdIsRefused = I.dialogLayerCount() === 2;
+		out.depth_refusalPainted = q(r.env, '[data-testid="dialog-depth-refusal"]') != null;
+	}
+
+	// --- 6) carryDrafts: the parent's typed values ride the child's form GET --------------------------------
+	{
+		const r = await openParent({
+			childActions: [{ id: 'review', label: 'Review', form: CHILD_FORM, carryDrafts: true }]
+		});
+		const I = r.I;
+		// Type something the operator has NOT submitted yet - the whole reason this channel exists.
+		const note = q(r.env, '[data-juneau-form-field="note"]');
+		if (note) note.value = 'edited-in-place';
+		if (r.btn) r.btn.dispatch('click');
+		await flush();
+		const childGet = r.gets.find(function (u) { return u.indexOf(CHILD_FORM) === 0; });
+		out.drafts_childGetUrl = childGet || null;
+		out.drafts_queryParamPresent = childGet != null && childGet.indexOf('juneauDrafts=') > 0;
+		out.drafts_carriedTheEditedValue = (function () {
+			if (childGet == null) return false;
+			const raw = decodeURIComponent(childGet.slice(childGet.indexOf('juneauDrafts=') + 'juneauDrafts='.length));
+			const parsed = JSON.parse(raw);
+			return parsed.note === 'edited-in-place';
+		})();
+		out.drafts_childOpened = I.dialogLayerCount() === 2;
+	}
+
+	// --- 7) The draft cap is measured on the ENCODED value, and over it the child does NOT open ------------
+	{
+		const { env, I } = load();
+		out.cap_value = I.MAX_DRAFT_QUERY_BYTES;
+		const child = { id: 'review', label: 'Review', form: CHILD_FORM };
+		out.cap_smallDraftPasses = I.withDraftQuery(child, { note: 'hi' }) != null;
+		// A raw JSON string that fits the cap but whose ENCODED form does not: every one of these characters
+		// expands to three bytes, so measuring the raw string would let a ~2KB draft become a ~6KB query and blow
+		// a request-line limit AFTER the client had already told the operator it was fine.
+		const expands = { note: '\u00e9'.repeat(900) };
+		out.cap_rawJsonWouldHaveFit = JSON.stringify(expands).length < I.MAX_DRAFT_QUERY_BYTES;
+		out.cap_encodedFormIsRefused = I.withDraftQuery(child, expands) === null;
+		out.cap_overCapIsRefused = I.withDraftQuery(child, { note: 'x'.repeat(I.MAX_DRAFT_QUERY_BYTES + 1) }) === null;
+		// It is a COPY: the descriptor handed in is never mutated.
+		const copy = I.withDraftQuery(child, { note: 'hi' });
+		out.cap_returnsACopyAndDoesNotMutate = child.form === CHILD_FORM && copy.form !== CHILD_FORM
+			&& copy.id === 'review' && copy.label === 'Review';
+		// An existing query string is appended to, not clobbered.
+		const withQuery = I.withDraftQuery({ id: 'r', label: 'R', form: '/x/f?a=1' }, { note: 'hi' });
+		out.cap_appendsToAnExistingQuery = withQuery.form.indexOf('/x/f?a=1&juneauDrafts=') === 0;
+
+		// Over the cap the child does NOT open, and the refusal is visible.
+		const fx = fixture(env);
+		const backdrop = env.el('div');
+		const dialog = env.el('div');
+		dialog.className = 'juneau-view-dialog';
+		backdrop.appendChild(dialog);
+		env.body.appendChild(backdrop);
+		I.pushLayer(backdrop, { kind: 'dialog', trapFocus: false, lightDismiss: false });
+		const field = env.el('input');
+		field.dataset.juneauFormField = 'note';
+		field.value = 'x'.repeat(I.MAX_DRAFT_QUERY_BYTES + 1);
+		dialog.appendChild(field);
+		I.openChildActionDialog({ id: 'review', label: 'Review', form: CHILD_FORM, carryDrafts: true },
+			fx.table, fx.tr, fx.ctx);
+		out.capRefusal_noChildOpened = I.dialogLayerCount() === 1;
+		out.capRefusal_visible = dialogRefusalText(env) != null;
+		out.capRefusal_namesTheCap = (dialogRefusalText(env) || '').indexOf(String(I.MAX_DRAFT_QUERY_BYTES)) >= 0;
+	}
+
+	// --- 8) A `type=action` click on a BUSY (in-flight or receipt) dialog is a no-op ----------------------
+	{
+		const r = await openParent();
+		const I = r.I;
+		const dialog = q(r.env, '.juneau-view-dialog');
+		dialog.dataset.juneauDialogBusy = '1';
+		I.openFormActionDialog('review', r.fx.table, r.fx.tr, r.fx.ctx,
+			[{ id: 'review', label: 'Review', form: CHILD_FORM }]);
+		await flush();
+		// Not a refusal either: the operator did not ask for anything new, a stale control fired.
+		out.busy_noChildStacked = I.dialogLayerCount() === 1;
+		out.busy_noRefusalPainted = dialogRefusalText(r.env) === null;
+		delete dialog.dataset.juneauDialogBusy;
+		I.openFormActionDialog('review', r.fx.table, r.fx.tr, r.fx.ctx,
+			[{ id: 'review', label: 'Review', form: CHILD_FORM }]);
+		await flush();
+		out.busy_worksAgainOnceCleared = I.dialogLayerCount() === 2;
+	}
+
+	// --- 9) A SECTIONED parent form threads the catalog too (the second row-painting path) ---------------
+	{
+		const { env, I } = load();
+		const fx = fixture(env);
+		const dialog = env.el('div');
+		const catalog = [{ id: 'review', label: 'Review', form: CHILD_FORM }];
+		I.appendDialogForm(dialog, {
+			sections: [{ id: 's1', label: 'One', fields: [{ name: 'go', type: 'action', label: 'Review', actionId: 'review' }] }]
+		}, fx.table, fx.tr, fx.ctx, 7, catalog);
+		const btn = dialog.querySelector('.juneau-view-dialog-form-action');
+		out.sectioned_buttonPainted = btn != null;
+		out.sectioned_buttonEnabledFromCatalog = btn != null && btn.disabled === false
+			&& btn.dataset.juneauActionMissing === undefined;
+		// Without the catalog the same field stays today's fail-closed paint.
+		const dialog2 = env.el('div');
+		I.appendDialogForm(dialog2, {
+			sections: [{ id: 's1', label: 'One', fields: [{ name: 'go', type: 'action', label: 'Review', actionId: 'review' }] }]
+		}, fx.table, fx.tr, fx.ctx, 8);
+		const btn2 = dialog2.querySelector('.juneau-view-dialog-form-action');
+		out.sectioned_withoutCatalogStaysDisabled = btn2 != null && btn2.disabled === true
+			&& btn2.dataset.juneauActionMissing === '1';
+	}
+
+	// --- 10) The resolver helpers, in isolation ---------------------------------------------------------
+	{
+		const { I } = load();
+		const catalog = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }];
+		out.helpers_childActionByIdFindsFirst = I.childActionById(catalog, 'b').label === 'B';
+		out.helpers_childActionByIdMissIsNull = I.childActionById(catalog, 'zz') === null;
+		const ctx = { viewDef: { rowActions: [{ id: 'del', label: 'Delete', method: 'DELETE' }] } };
+		// rowActionIdExists is deliberately BROADER than dialogActionIsOpenable: it sees non-dialog row actions
+		// too, which is what makes the shadowing refusal in step 3 possible.
+		out.helpers_rowActionIdExistsSeesNonDialog = I.rowActionIdExists(ctx, 'del') === true;
+		out.helpers_dialogActionIsOpenableDoesNot = I.dialogActionIsOpenable(ctx, 'del') === false;
+		out.helpers_rowActionIdExistsMiss = I.rowActionIdExists(ctx, 'nope') === false;
+		out.helpers_rowActionIdExistsNullSafe = I.rowActionIdExists(null, 'x') === false
+			&& I.rowActionIdExists({}, 'x') === false;
+	}
+
+	process.stdout.write(JSON.stringify(out));
+})();
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs
index 299e82b563..afc86e5025 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs
@@ -119,4 +119,17 @@ out.missing_disabled = xbtn.disabled === true;
 out.missing_ariaDisabled = xbtn.getAttribute('aria-disabled') === 'true';
 out.missing_marker = xbtn.dataset.juneauActionMissing === '1';
 
+// --- the trailing childCatalog parameter is OPTIONAL: every existing positional call is unchanged -----------
+// The call above passes six arguments, exactly as it did before WORK-J0513, and still paints today's fail-closed
+// control.  With the catalog supplied, the SAME field resolves instead - so the parameter is additive at the call
+// site as well as in the signature.
+const dialog3 = env.el('div');
+I.appendDialogForm(dialog3, { fields: [{ name: 'x', type: 'action', label: 'X', actionId: 'nope' }] },
+	table, tr, ctx, 3, [{ id: 'nope', label: 'Child', form: '/x/child-form' }]);
+const cbtn = dialog3.querySelector('button');
+out.catalog_enabled = cbtn.disabled === false;
+out.catalog_notMarkedMissing = cbtn.dataset.juneauActionMissing === undefined;
+// ...and it still carries no submit value, so the collection path is untouched by any of this.
+out.catalog_stillSkippedByCollect = !Object.hasOwn(I.collectDialogFormFields(dialog3), 'x');
+
 process.stdout.write(JSON.stringify(out));
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs
new file mode 100644
index 0000000000..658fa20d05
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs
@@ -0,0 +1,509 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * dialog-receipt.cjs - always-on Node harness for the in-dialog result RECEIPT (WORK-J0513 Scope A): a dialog that
+ * opted in with `ModalDef.keepOpenOnSubmit` keeps its layer across the submit, and the runtime paints the write's
+ * result into that already-open dialog rather than closing it and painting a row banner.
+ *
+ * Why a DOM harness and not source pins: the interesting failures here are not "is the right function called", they
+ * are "which surface did the outcome land on, and can the operator still submit".  A stuck busy modal, a Confirm
+ * left disabled on a retryable refusal, a receipt painted over a still-populated form, an outcome rendered twice
+ * (once in the dialog and once behind the backdrop) - all of those are DOM-shaped and invisible to a source pin.
+ *
+ * The submit graph has NINE mutually-exclusive terminals, so this harness walks them one case per terminal and
+ * checks the DISPOSITION each one settles to, plus the two enforcement properties that hold across all of them:
+ *
+ *   1. T8-receipt   - success + resultForm on a held dialog: swap in place, same layer depth, no submit controls.
+ *   2. T8-commit    - success with NO resultForm: whole-stack close, and the row banner still renders.
+ *   3. F4           - success + resultForm on an UN-held dialog: success banner unchanged, plus one role=status
+ *                     diagnostic.  Never a new layer, never a refusal.
+ *   4. F1c          - the receipt payload carries a `form`: refused VISIBLY with its own wording, terminal.
+ *   5. F12          - the receipt GET fails (non-2xx / unparseable / REJECTED promise): terminal, Close-only, and
+ *                     the busy marker cleared - a committed write must never leave a permanently stuck modal.
+ *   6. T1           - a pre-flight client refusal (nothing sent): retryable, Confirm re-enabled.
+ *   7. T2           - a network reject (ambiguous): terminal, Confirm NOT re-enabled, no one-click duplicate.
+ *   8. T4           - a transport refusal (a gate said no, nothing written): retryable.
+ *   9. T5           - a job-started pointer: the dialog closes BEFORE the row-anchored progress affordance paints.
+ *  10. Cancel       - the operator dismissed the dialog mid-flight: the outcome falls back to the row banner.
+ *  11. Field kinds  - `code` paints a <pre> + copy button; an unrecognized kind falls back to text.
+ *
+ * Usage:  node dialog-receipt.cjs <juneau-renders.js> <juneau-views.js>
+ *
+ * Prints ONE JSON object to stdout; every assertion lives in the Java test.
+ */
+'use strict';
+
+const path = require('node:path');
+const { makeEnv, loadViews, jsonResponse } = require(path.join(__dirname, 'views-dom-shim.cjs'));
+
+const rendersJsPath = process.argv[2];
+const viewsJsPath = process.argv[3];
+if (!rendersJsPath || !viewsJsPath) {
+	console.error('usage: node dialog-receipt.cjs <juneau-renders.js> <juneau-views.js>');
+	process.exit(2);
+}
+
+const FORM_URL = '/x/form';
+const RECEIPT_URL = '/x/receipt';
+const ENDPOINT = '/x/act';
+
+function load() {
+	const env = makeEnv();
+	return loadViews(rendersJsPath, viewsJsPath, env);
+}
+
+const out = {};
+{
+	const probe = load();
+	out.hasReceiptSeam = !!(probe.I
+		&& typeof probe.I.beginDialogResultHold === 'function'
+		&& typeof probe.I.settleDialogResultHold === 'function'
+		&& typeof probe.I.paintDialogReceipt === 'function'
+		&& typeof probe.I.fetchResultForm === 'function'
+		&& typeof probe.I.appendDialogCloseOnlyRow === 'function');
+}
+if (!out.hasReceiptSeam) { process.stdout.write(JSON.stringify(out)); process.exit(0); }
+
+function flush() {
+	return new Promise(function (r) { let n = 0; (function tick() { if (n++ >= 8) { return r(); } setTimeout(tick, 0); })(); });
+}
+
+/**
+ * The served confirmation payload: a form-bearing ModalDef, so it must carry the runtime's dialog-form contract
+ * version at BOTH levels or openActionDialog refuses before any of this is reachable.
+ */
+function modalPayload(opts) {
+	const o = opts || {};
+	const m = {
+		contractVersion: '2',
+		title: 'Really do it?',
+		fields: o.fields || [{ label: 'Id', value: 'INC-1' }],
+		form: {
+			contractVersion: '2',
+			fields: [{ name: 'note', type: 'text', label: 'Note', value: 'typed-by-operator' }]
+		}
+	};
+	if (o.keepOpen) m.keepOpenOnSubmit = true;
+	if (o.childActions) m.childActions = o.childActions;
+	return m;
+}
+
+/** A table + row + ctx wired the way constructTable wires a real one, with a `present=dialog` row action. */
+function fixture(env, opts) {
+	const o = opts || {};
+	const table = env.el('table');
+	if (o.csrf !== false) table.setAttribute('data-juneau-csrf', 'tok-1');
+	env.body.appendChild(table);
+
+	const tr = env.el('tr');
+	tr.setAttribute('data-juneau-row-id', 'INC-1');
+	tr.dataset.juneauRowId = 'INC-1';
+	const cell = env.el('td');
+	cell.className = 'juneau-view-actions-cell';
+	tr.appendChild(cell);
+	table.appendChild(tr);
+
+	const action = {
+		id: 'act', label: 'Act', present: 'dialog', method: 'POST', endpoint: ENDPOINT, form: FORM_URL,
+		onSuccess: o.onSuccess || null
+	};
+	let redraws = 0;
+	const ctx = {
+		table: table, viewDef: { id: 'v1', rowActions: [action] }, dataTable: {}, activeState: {},
+		redraw: function () { redraws++; },
+		redrawCount: function () { return redraws; }
+	};
+	return { table: table, tr: tr, action: action, ctx: ctx };
+}
+
+const q = (env, sel) => env.body.querySelector(sel);
+const dialogEl = (env) => q(env, '.juneau-view-dialog');
+const confirmBtn = (env) => q(env, '.juneau-view-dialog-confirm');
+const heldNotice = (env) => q(env, '.juneau-view-dialog-result-notice');
+const rowOutcome = (env) => q(env, '.juneau-view-action-outcome');
+const rowRefusal = (env) => q(env, '.juneau-view-action-refusal');
+const anyRowBanner = (env) => rowOutcome(env) != null || rowRefusal(env) != null;
+
+/**
+ * Opens the dialog and presses Confirm, with `respond` answering the submit POST (and, when given, the follow-up
+ * receipt GET).  Returns the loaded env / interfaces so each case can inspect the settled DOM.
+ */
+async function openAndConfirm(opts) {
+	const o = opts || {};
+	const { env, I } = load();
+	const fx = fixture(env, o);
+	env.setFetch(function (url, init) {
+		const method = (init && init.method) || 'GET';
+		if (method === 'GET' && url === FORM_URL)
+			return Promise.resolve(jsonResponse(modalPayload(o)));
+		if (method === 'GET' && String(url).indexOf(RECEIPT_URL) === 0)
+			return o.receipt();
+		return o.submit();
+	});
+	I.openActionDialog(fx.action, fx.table, fx.tr, fx.ctx);
+	await flush();
+	const opened = I.dialogLayerCount() === 1;
+	const btn = confirmBtn(env);
+	if (o.beforeConfirm) o.beforeConfirm(env, I, fx);
+	if (btn) btn.dispatch('click');
+	if (o.afterConfirm) o.afterConfirm(env, I, fx);
+	await flush();
+	return { env: env, I: I, fx: fx, opened: opened, confirm: btn };
+}
+
+const success = (extra) => () => Promise.resolve(jsonResponse(Object.assign({ outcome: 'success' }, extra || {})));
+
+(async function main() {
+
+	// --- 1) T8-receipt: the swap.  Same layer, no submit controls, the submitted form torn down ---------------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: success({ resultForm: RECEIPT_URL, message: 'done' }),
+			receipt: () => Promise.resolve(jsonResponse({
+				title: 'Deleted',
+				fields: [{ label: 'Id', value: 'INC-1' }, { label: 'Token', value: 'tok-9', kind: 'code' }]
+			}))
+		});
+		const env = r.env, d = dialogEl(env);
+		out.receipt_dialogOpened = r.opened;
+		out.receipt_stillOpenAtSameDepth = r.I.dialogLayerCount() === 1;
+		out.receipt_marked = d != null && d.dataset.juneauReceipt === '1' && d.dataset.testid === 'dialog-receipt';
+		// The ENFORCEMENT, and it is an absence: no confirm control anywhere in the receipt.
+		out.receipt_noConfirmButton = confirmBtn(env) === null;
+		out.receipt_noInputsAtAll = env.body.querySelectorAll('[data-juneau-form-field]').length === 0;
+		out.receipt_submittedFormTornDown = env.body.querySelector('.juneau-view-dialog-form') === null;
+		out.receipt_closeButtonPresent = q(env, '[data-testid="dialog-receipt-close"]') != null;
+		out.receipt_titleFromPayload = (function () {
+			const t = q(env, '.juneau-view-dialog-title');
+			return t != null && t.textContent === 'Deleted';
+		})();
+		out.receipt_fieldsRepainted = (function () {
+			const dl = q(env, '.juneau-view-dialog-fields');
+			return dl != null && dl.querySelectorAll('dd').length === 2 && dl.querySelectorAll('dt').length === 2;
+		})();
+		out.receipt_exactlyOneFieldList = env.body.querySelectorAll('.juneau-view-dialog-fields').length === 1;
+		out.receipt_codeFieldIsPre = q(env, '.juneau-view-dialog-field-code') != null
+			&& q(env, '.juneau-view-dialog-field-code').textContent === 'tok-9';
+		out.receipt_codeFieldHasCopyButton = q(env, '[data-testid="dialog-field-copy"]') != null;
+		// The row banner is SUPPRESSED: the dialog is on screen, so rendering it too would show one outcome twice.
+		out.receipt_noRowBanner = !anyRowBanner(env);
+		out.receipt_busyMarkerCleared = d != null && d.dataset.juneauDialogBusy === undefined;
+		// Pressing Close takes the whole stack down.
+		const closeBtn = q(env, '[data-testid="dialog-receipt-close"]');
+		if (closeBtn) closeBtn.dispatch('click');
+		out.receipt_closeTearsDownTheStack = r.I.dialogLayerCount() === 0;
+	}
+
+	// --- 2) T8-commit: a held success with NO resultForm closes, and the row banner STILL renders -------------
+	{
+		const r = await openAndConfirm({ keepOpen: true, submit: success({ message: 'done' }) });
+		const env = r.env;
+		out.commit_dialogClosed = r.I.dialogLayerCount() === 0;
+		out.commit_noDialogInDom = dialogEl(env) === null;
+		// closeCommitted returns false on purpose: with the dialog gone the row banner is the ONLY signal left.
+		out.commit_rowOutcomeRendered = rowOutcome(env) != null;
+		out.commit_rowOutcomeState = rowOutcome(env) ? rowOutcome(env).dataset.state : null;
+		out.commit_noReceipt = env.body.querySelector('[data-juneau-receipt]') === null;
+	}
+
+	// --- 3) F4: an UN-held success carrying a resultForm - diagnose, never open a layer ----------------------
+	{
+		let receiptGets = 0;
+		const r = await openAndConfirm({
+			submit: success({ resultForm: RECEIPT_URL, message: 'done' }),
+			receipt: () => { receiptGets++; return Promise.resolve(jsonResponse({ title: 'Deleted', fields: [] })); }
+		});
+		const env = r.env;
+		out.f4_dialogClosedAsToday = r.I.dialogLayerCount() === 0;
+		out.f4_noFollowUpGet = receiptGets === 0;
+		out.f4_successBannerUnchanged = rowOutcome(env) != null && rowOutcome(env).dataset.state === 'success';
+		const diag = q(env, '[data-testid="result-form-ignored"]');
+		out.f4_diagnosticPresent = diag != null;
+		// role=status, not alert: the write SUCCEEDED, and a consumer authoring bug must not look like a failure.
+		out.f4_diagnosticIsStatusNotAlert = diag != null && diag.getAttribute('role') === 'status';
+		out.f4_noNewLayer = r.I.dialogLayerCount() === 0 && env.body.querySelector('[data-juneau-receipt]') === null;
+	}
+
+	// --- 4) F1c: the receipt payload carries a form - the FORBIDDEN shape, refused visibly -------------------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: success({ resultForm: RECEIPT_URL }),
+			receipt: () => Promise.resolve(jsonResponse({
+				title: 'Nope', form: { contractVersion: '2', fields: [{ name: 'again', type: 'text', label: 'Again' }] }
+			}))
+		});
+		const env = r.env;
+		out.f1c_stillOpen = r.I.dialogLayerCount() === 1;
+		out.f1c_notPaintedAsReceipt = env.body.querySelector('[data-juneau-receipt]') === null;
+		out.f1c_noInputsPainted = env.body.querySelectorAll('[data-juneau-form-field]').length === 0;
+		out.f1c_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+		out.f1c_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+		out.f1c_noConfirm = confirmBtn(env) === null;
+		out.f1c_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+	}
+
+	// --- 5a) F12: the receipt GET returns a non-2xx --------------------------------------------------------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: success({ resultForm: RECEIPT_URL }),
+			receipt: () => Promise.resolve(jsonResponse({ reason: 'nope' }, { status: 500 }))
+		});
+		const env = r.env;
+		out.f12_stillOpen = r.I.dialogLayerCount() === 1;
+		out.f12_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+		out.f12_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+		out.f12_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+		out.f12_submittedFormTornDown = env.body.querySelector('.juneau-view-dialog-form') === null;
+	}
+
+	// --- 5b) F12: the receipt GET PROMISE REJECTS - the arm without which a committed write hangs forever ---
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: success({ resultForm: RECEIPT_URL }),
+			receipt: () => Promise.reject(new Error('socket died'))
+		});
+		const env = r.env;
+		out.f12reject_stillOpen = r.I.dialogLayerCount() === 1;
+		out.f12reject_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+		out.f12reject_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+		out.f12reject_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+		out.f12reject_noConfirmStuckDisabled = confirmBtn(env) === null;
+	}
+
+	// --- 5c) F12: the receipt GET returns 2xx that does not parse ------------------------------------------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: success({ resultForm: RECEIPT_URL }),
+			receipt: () => Promise.resolve(jsonResponse('not json at all'))
+		});
+		const env = r.env;
+		out.f12parse_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+		out.f12parse_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+		out.f12parse_notPaintedAsReceipt = env.body.querySelector('[data-juneau-receipt]') === null;
+	}
+
+	// --- 6) T1: a pre-flight client refusal (no CSRF token) is RETRYABLE ----------------------------------
+	{
+		let posts = 0;
+		const r = await openAndConfirm({
+			keepOpen: true, csrf: false,
+			submit: () => { posts++; return Promise.resolve(jsonResponse({ outcome: 'success' })); }
+		});
+		const env = r.env;
+		out.t1_nothingSent = posts === 0;
+		out.t1_stillOpen = r.I.dialogLayerCount() === 1;
+		out.t1_noticePresent = heldNotice(env) != null;
+		// Retryable: nothing was sent, so the operator can fix the declaration and press Confirm again.
+		out.t1_confirmReEnabled = confirmBtn(env) != null && confirmBtn(env).disabled === false;
+		out.t1_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+		out.t1_noCloseOnlyRow = q(env, '[data-testid="dialog-result-close"]') === null;
+		out.t1_noRowBanner = !anyRowBanner(env);
+		out.t1_formStillStanding = env.body.querySelector('.juneau-view-dialog-form') != null;
+	}
+
+	// --- 7) T2: a network reject is AMBIGUOUS - terminal, and Confirm must NOT come back ------------------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: () => Promise.reject(new Error('connection reset'))
+		});
+		const env = r.env;
+		out.t2_stillOpen = r.I.dialogLayerCount() === 1;
+		out.t2_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+		// The whole point: re-enabling Confirm here would hand the operator a one-click duplicate of a write that
+		// may well have landed.
+		out.t2_noConfirmButton = confirmBtn(env) === null;
+		out.t2_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+		out.t2_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+		// Terminal keeps the typed values on screen so the operator can copy them out before Close.
+		out.t2_formStillStanding = env.body.querySelector('.juneau-view-dialog-form') != null;
+		out.t2_noRowBanner = !anyRowBanner(env);
+	}
+
+	// --- 8) T4: a transport refusal is a gate saying no - nothing written, so RETRYABLE -------------------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: () => Promise.resolve(jsonResponse({ reason: 'not-armed' }, { status: 403 }))
+		});
+		const env = r.env;
+		out.t4_stillOpen = r.I.dialogLayerCount() === 1;
+		out.t4_noticePresent = heldNotice(env) != null;
+		out.t4_confirmReEnabled = confirmBtn(env) != null && confirmBtn(env).disabled === false;
+		out.t4_noCloseOnlyRow = q(env, '[data-testid="dialog-result-close"]') === null;
+		out.t4_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+	}
+
+	// --- 9) T8-failure / T8-refusal: the two remaining dispositions of the typed-outcome terminal --------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: () => Promise.resolve(jsonResponse({ outcome: 'failure', message: 'boom' }))
+		});
+		const env = r.env;
+		out.t8fail_stillOpen = r.I.dialogLayerCount() === 1;
+		out.t8fail_terminalCloseOnly = q(env, '[data-testid="dialog-result-close"]') != null;
+		out.t8fail_noConfirm = confirmBtn(env) === null;
+		out.t8fail_noticePresent = heldNotice(env) != null;
+	}
+	{
+		// F11: a named refusal did not write, so Confirm comes back.
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: () => Promise.resolve(jsonResponse({ outcome: 'refusal', refusalCode: 'write-guard:not-armed' }))
+		});
+		const env = r.env;
+		out.t8refuse_confirmReEnabled = confirmBtn(env) != null && confirmBtn(env).disabled === false;
+		out.t8refuse_noCloseOnlyRow = q(env, '[data-testid="dialog-result-close"]') === null;
+		out.t8refuse_noticePresent = heldNotice(env) != null;
+	}
+
+	// --- 10) resultForm is IGNORED on a non-success outcome: no receipt for a write that did not happen ---
+	{
+		let receiptGets = 0;
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: () => Promise.resolve(jsonResponse({ outcome: 'failure', resultForm: RECEIPT_URL })),
+			receipt: () => { receiptGets++; return Promise.resolve(jsonResponse({ title: 'Deleted' })); }
+		});
+		const env = r.env;
+		out.failResultForm_noGetIssued = receiptGets === 0;
+		out.failResultForm_noReceiptPainted = env.body.querySelector('[data-juneau-receipt]') === null;
+		out.failResultForm_terminalCloseOnly = q(env, '[data-testid="dialog-result-close"]') != null;
+		out.failResultForm_noDiagnostic = q(env, '[data-testid="result-form-ignored"]') === null;
+	}
+
+	// --- 11) T5: a job pointer closes the dialog BEFORE the row-anchored progress affordance paints -------
+	{
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: () => Promise.resolve(jsonResponse({ streamUrl: '/x/stream' }))
+		});
+		const env = r.env;
+		out.t5_dialogClosed = r.I.dialogLayerCount() === 0;
+		out.t5_noDialogInDom = dialogEl(env) === null;
+		// Everything startJobStream paints is ROW-anchored, so a surviving backdrop would hide the whole
+		// affordance behind a modal that can never settle (a job pointer is not a terminal result and can never
+		// carry a resultForm).  This shim has no EventSource, so the path taken is that function's own
+		// no-SSE-transport fallback - which is itself a ROW paint, and therefore proves the same ordering: the
+		// close already happened, and the row was reachable when the job path ran.
+		out.t5_rowAnchoredSignalVisible = rowOutcome(env) != null;
+		out.t5_rowSignalIsNotInADialog = rowOutcome(env) != null && rowOutcome(env).closest('.juneau-view-dialog') === null;
+	}
+
+	// --- 12) Cancel mid-flight: the layer is gone, so the outcome falls back to the row banner -----------
+	{
+		let resolveSubmit = null;
+		const r = await openAndConfirm({
+			keepOpen: true,
+			submit: () => new Promise(function (res) { resolveSubmit = res; }),
+			afterConfirm: function (env, I) {
+				// Cancel stays enabled on purpose (Escape pops the layer regardless, so the design has to tolerate
+				// "dialog gone before settle" anyway).
+				const cancel = env.body.querySelector('.juneau-view-dialog-cancel');
+				if (cancel) cancel.dispatch('click');
+			}
+		});
+		out.cancel_layerGone = r.I.dialogLayerCount() === 0;
+		if (resolveSubmit) resolveSubmit(jsonResponse({ outcome: 'refusal', refusalCode: 'app:x' }));
+		await flush();
+		const env = r.env;
+		out.cancel_outcomeFellBackToRow = rowOutcome(env) != null || rowRefusal(env) != null;
+		out.cancel_noThrowAndNoDialog = dialogEl(env) === null;
+	}
+
+	// --- 13) Field kinds on the confirmation payload: `code` -> <pre>, unknown -> plain text -------------
+	{
+		const { env, I } = load();
+		const dl = I.buildModalFieldList([
+			{ label: 'Plain', value: 'p' },
+			{ label: 'Coded', value: 'c' , kind: 'code' },
+			{ label: 'Bogus', value: 'b', kind: 'html' },
+			{ label: 'Explicit', value: 'e', kind: 'text' }
+		]);
+		env.body.appendChild(dl);
+		const dds = dl.querySelectorAll('dd');
+		out.kinds_ddCount = dds.length === 4;
+		out.kinds_plainIsText = dds[0].querySelector('pre') === null && dds[0].textContent === 'p';
+		out.kinds_codeIsPre = dds[1].querySelector('pre') !== null;
+		out.kinds_codeHasCopy = dds[1].querySelector('.juneau-view-dialog-field-copy') !== null;
+		// An unrecognized token falls BACK to text rather than being trusted.
+		out.kinds_unknownFallsBackToText = dds[2].querySelector('pre') === null && dds[2].textContent === 'b';
+		out.kinds_explicitTextIsText = dds[3].querySelector('pre') === null && dds[3].textContent === 'e';
+		out.kinds_allowlistIsExactlyTwo = I.isModalFieldKind('text') === true && I.isModalFieldKind('code') === true
+			&& I.isModalFieldKind('html') === false && I.isModalFieldKind('') === false;
+	}
+
+	// --- 14) A hold settles exactly ONCE: the second hand-off on the same submit finds nothing -----------
+	{
+		const { env, I } = load();
+		const fx = fixture(env);
+		const dialog = env.el('div');
+		dialog.className = 'juneau-view-dialog';
+		const btn = env.el('button');
+		const backdrop = env.el('div');
+		env.body.appendChild(backdrop);
+		fx.ctx._dialogStack = [backdrop];
+		I.beginDialogResultHold({ backdrop: backdrop, dialog: dialog, confirmBtn: btn }, fx.action, {}, fx.ctx);
+		out.once_holdRegistered = fx.ctx._resultHold != null;
+		out.once_confirmDisabledAtClickTime = btn.disabled === true;
+		out.once_busyMarked = dialog.dataset.juneauDialogBusy === '1';
+		out.once_firstSettleHandsOff = I.settleDialogResultHold(fx.ctx, 'terminal') === true;
+		out.once_holdCleared = fx.ctx._resultHold == null;
+		out.once_secondSettleIsANoOp = I.settleDialogResultHold(fx.ctx, 'terminal') === false;
+		// closeCommitted is the ONE disposition that returns false, so the caller's row painter still runs.
+		fx.ctx._dialogStack = [backdrop];
+		I.beginDialogResultHold({ backdrop: backdrop, dialog: dialog, confirmBtn: btn }, fx.action, {}, fx.ctx);
+		out.once_closeCommittedDoesNotSuppressRowPaint = I.settleDialogResultHold(fx.ctx, 'closeCommitted') === false;
+		// A settle with no hold at all is also false - the un-held path renders exactly as it does today.
+		out.once_noHoldIsFalse = I.settleDialogResultHold(fx.ctx, 'terminal') === false;
+		out.once_noCtxIsFalse = I.settleDialogResultHold(null, 'terminal') === false;
+	}
+
+	// --- 15) A child-action button is disabled for the duration of the hold, and RESTORED to its prior state
+	{
+		const { env, I } = load();
+		const fx = fixture(env);
+		const dialog = env.el('div');
+		dialog.className = 'juneau-view-dialog';
+		const live = env.el('button');
+		live.className = 'juneau-view-dialog-form-action';
+		const alreadyGated = env.el('button');
+		alreadyGated.className = 'juneau-view-dialog-form-action';
+		alreadyGated.disabled = true;
+		dialog.appendChild(live);
+		dialog.appendChild(alreadyGated);
+		const backdrop = env.el('div');
+		env.body.appendChild(backdrop);
+		fx.ctx._dialogStack = [backdrop];
+		I.beginDialogResultHold({ backdrop: backdrop, dialog: dialog, confirmBtn: env.el('button') }, fx.action, {}, fx.ctx);
+		out.children_liveOneDisabled = live.disabled === true && live.getAttribute('aria-disabled') === 'true';
+		out.children_gatedOneStaysDisabled = alreadyGated.disabled === true;
+		I.settleDialogResultHold(fx.ctx, 'retryable');
+		out.children_liveOneRestored = live.disabled === false && live.getAttribute('aria-disabled') === null;
+		// The restore must never ENABLE a child that was gated before the submit started.
+		out.children_gatedOneStillDisabled = alreadyGated.disabled === true;
+	}
+
+	process.stdout.write(JSON.stringify(out));
+})();
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java
index bbea4826d1..f86dcbfee5 100644
--- a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java
@@ -66,10 +66,43 @@ import org.apache.juneau.commons.bean.*;
  * Set it <b>only</b> on a modal whose key came from the self-targeted mint helper.  Setting it on a modal carrying
  * an artifact-bound key silently discards the real target and makes the server's binding check vacuous.
  *
+ * <h5 class='section'>{@link #keepOpenOnSubmit} &mdash; the opt-in for an in-dialog result receipt</h5>
+ * <p>
+ * By default the runtime closes a dialog at confirm-click time and then submits, so every dialog is gone before its
+ * result exists.  {@link #keepOpenOnSubmit} suppresses that close, and the runtime instead paints the write's
+ * <b>result</b> into the dialog that is already open &mdash; a <i>receipt</i>.
+ * <p>
+ * A receipt host is <b>non-submittable by construction</b>, not by convention: the runtime paints it with a distinct
+ * function that creates no confirm control at all, and it refuses to paint one whose payload carries a
+ * {@link #form}.  It is reached <b>only</b> after a committed write (an
+ * {@code ActionResult} whose outcome is {@code success}), so a result host can never appear for a write that did not
+ * happen.  A follow-up read-only form source is named by that result's {@code resultForm}.
+ * <p>
+ * The opt-in has to live here rather than on the action bean, because the confirm handler must decide whether to
+ * close <i>before</i> any response exists.  A dialog synthesized from a {@code confirm} string alone (no form-source
+ * URL) therefore cannot opt in &mdash; a receipt-bearing action must serve a real {@link ModalDef}.
+ * <p>
+ * Additive, and named degradation, accepted: an older runtime ignoring this flag closes the dialog and drops the
+ * receipt.  The write still happens; the receipt is simply not shown.
+ *
+ * <h5 class='section'>{@link #childActions} &mdash; the dialog-scoped child-action catalog</h5>
+ * <p>
+ * A {@code type="action"} form input names an action id, and the runtime resolves it against the enclosing view's
+ * row-action catalog.  A <b>stacked</b> step reached from inside an already-open dialog (a Review step in a wizard)
+ * has nothing legal to resolve against there: it is not a row action, and publishing it as one would surface it in
+ * every row's action menu.  {@link #childActions} is that missing catalog, scoped to <b>one open dialog</b>.
+ * <p>
+ * Scoping is <b>structural</b>, not a flag some menu builder has to remember to filter on: this catalog rides on a
+ * per-open payload, so the row-action menu and the ribbon resolver never see it and there is no exclusion logic to
+ * rot.  Resolution precedence is likewise fail-safe: the existing row-action check runs <b>first</b> and unchanged,
+ * so a served payload can never shadow &mdash; and thereby bypass the gating of &mdash; a declared row action.
+ *
  * <h5 class='section'>Not a new row-action wire field</h5>
  * <p>
  * This is the response a row action's form-source URL returns, <b>not</b> a new field on the frozen row-action wire
  * schema: the modal's declaration lives in this separately-served payload so the row-action contract stays frozen.
+ * The same reasoning is why {@link #keepOpenOnSubmit} and {@link #childActions} land here rather than on
+ * {@code RowAction} / {@code RibbonAction}, which stay frozen and untouched.
  *
  * <h5 class='section'>Example:</h5>
  * <p class='bjava'>
@@ -104,7 +137,7 @@ import org.apache.juneau.commons.bean.*;
  *
  * @since 10.0.0
  */
-@BeanType(properties="contractVersion,title,fields,form,idempotencyKey,selfTargeted,barSlot")
+@BeanType(properties="contractVersion,title,fields,form,idempotencyKey,selfTargeted,barSlot,keepOpenOnSubmit,childActions")
 @SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
 public class ModalDef implements Widget {
 
@@ -118,13 +151,19 @@ public class ModalDef implements Widget {
 	 */
 	public static final String CONTRACT_VERSION = "2";
 
+	/** The {@link Field#kind} token for an ordinary text value &mdash; the default when the field declares none. */
+	public static final String FIELD_KIND_TEXT = "text";
+
+	/** The {@link Field#kind} token for a monospaced, copyable value (an id, a URL, a generated token). */
+	public static final String FIELD_KIND_CODE = "code";
+
 	/**
 	 * A single typed confirmation field: a label and a value, painted client-side with {@code textContent} (never
 	 * {@code innerHTML}).
 	 *
 	 * @since 10.0.0
 	 */
-	@BeanType(properties="label,value")
+	@BeanType(properties="label,value,kind")
 	public static class Field {
 
 		/** The field label shown to the user. */
@@ -133,6 +172,22 @@ public class ModalDef implements Widget {
 		/** The field value, read back from the current authoritative record. */
 		public String value;
 
+		/**
+		 * How this field's value is <i>displayed</i>; omitted from the wire when unset (which reads as
+		 * {@link ModalDef#FIELD_KIND_TEXT text}).
+		 *
+		 * <p>
+		 * The allowlist is exactly {@link ModalDef#FIELD_KIND_TEXT text} and {@link ModalDef#FIELD_KIND_CODE code};
+		 * {@link ModalDef#validate()} rejects anything else server-side, and the runtime falls back to
+		 * {@code text} for an unrecognized token rather than trusting it.
+		 *
+		 * <p>
+		 * This is a <b>display</b> kind on a confirmation field, deliberately <b>not</b> a new
+		 * {@code FormDef.Input} type: a {@code code} field carries no submit value, so expressing it as an input
+		 * would mean teaching the collection, validation, prefill and submit paths about a control that is not one.
+		 */
+		public String kind;
+
 		/**
 		 * Creates a typed confirmation field.
 		 *
@@ -149,6 +204,177 @@ public class ModalDef implements Widget {
 			f.value = value;
 			return f;
 		}
+
+		/**
+		 * Creates a typed confirmation field displayed as monospaced, copyable {@link ModalDef#FIELD_KIND_CODE code}.
+		 *
+		 * <p>
+		 * The runtime paints the value into a {@code <pre>} with {@code textContent} (never {@code innerHTML}) and
+		 * adds a copy button that is feature-detected and never throws; the {@code <pre>} stays selectable, so
+		 * manual copy always works.
+		 *
+		 * @param label The field label.  Must not be <jk>null</jk> or blank.
+		 * @param value The field value.  Can be <jk>null</jk> (rendered as an empty value).
+		 * @return A new {@link Field}.
+		 * @throws IllegalArgumentException If {@code label} is <jk>null</jk> or blank.
+		 */
+		public static Field code(String label, String value) {
+			var f = of(label, value);
+			f.kind = FIELD_KIND_CODE;
+			return f;
+		}
+	}
+
+	/**
+	 * One entry in a dialog's {@link ModalDef#childActions} catalog &mdash; a stacked step a {@code type="action"}
+	 * input inside <i>this</i> dialog's form may open.
+	 *
+	 * <h5 class='section'>Dialog-presenting by construction</h5>
+	 * <p>
+	 * A child action carries no {@code present} field: opening a stacked dialog is the only thing it does.  A child
+	 * with no {@link #form} is a legal confirm-only child dialog.
+	 *
+	 * <h5 class='section'>No gating vocabulary, on purpose</h5>
+	 * <p>
+	 * There is deliberately no {@code enabledWhen} here, for three independent reasons.  The rule type lives in the
+	 * rich-view module, which this module must not reference.  A dialog opened where there is no row has no row data
+	 * to evaluate against, so a field-keyed rule would fail closed forever with no signal to its author.  And the
+	 * subject of a child step is the <i>draft in the open dialog</i>, not the parent row &mdash; so evaluating the
+	 * parent row's data would answer a question nobody asked.  Gating a child step is the <b>server's</b> job at
+	 * child-form-GET time; it already owns that GET and can refuse it.
+	 *
+	 * @since 10.0.0
+	 */
+	@BeanType(properties="id,label,form,endpoint,method,onSuccess,carryDrafts")
+	@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+	public static class ChildAction {
+
+		/** The action id a {@code type="action"} input in this dialog's form names.  Required, non-blank. */
+		public String id;
+
+		/** The label painted on the button that opens this child.  Required, non-blank. */
+		public String label;
+
+		/**
+		 * The child dialog's read-only form-source URL; omitted from the wire when unset (a confirm-only child).
+		 *
+		 * <p>
+		 * Required if this child declares {@link #carryDrafts} &mdash; with no URL there is nothing for the draft
+		 * query to ride on, so the drafts would vanish silently.
+		 */
+		public String form;
+
+		/** The child's submit endpoint; omitted from the wire when unset.  See {@link #endpoint(String)}. */
+		public String endpoint;
+
+		/** The child's submit method; omitted from the wire when unset.  See {@link #method(String)}. */
+		public String method;
+
+		/** The child's on-success behaviour token; omitted from the wire when unset. */
+		public String onSuccess;
+
+		/**
+		 * Whether opening this child carries the <b>parent dialog's current draft values</b> to its form-source GET
+		 * as one query parameter; omitted from the wire when unset or <jk>false</jk>.
+		 *
+		 * <p>
+		 * Without it a Review step opens <b>empty</b> and loses everything the operator typed.  With it, the
+		 * runtime serializes the parent's collected form values, URL-encodes them, and appends them as a single
+		 * {@code juneauDrafts} parameter, refusing visibly (and not opening the child) above a client-side byte cap
+		 * measured on the <i>encoded</i> value.
+		 *
+		 * <p>
+		 * <b>Drafts land in access logs and browser history.</b>  No secret may ride this channel.
+		 */
+		public Boolean carryDrafts;
+
+		/**
+		 * Creates a child action.
+		 *
+		 * @param id The action id a {@code type="action"} input names.  Must not be <jk>null</jk> or blank.
+		 * @param label The button label.  Must not be <jk>null</jk> or blank.
+		 * @return A new {@link ChildAction}.
+		 * @throws IllegalArgumentException If {@code id} or {@code label} is <jk>null</jk> or blank.
+		 */
+		public static ChildAction of(String id, String label) {
+			if (id == null || id.isBlank())
+				throw iaex("ModalDef.ChildAction id must not be null or blank.");
+			if (label == null || label.isBlank())
+				throw iaex("ModalDef.ChildAction label must not be null or blank.");
+			var c = new ChildAction();
+			c.id = id;
+			c.label = label;
+			return c;
+		}
+
+		/**
+		 * Sets the child dialog's read-only form-source URL.
+		 *
+		 * @param value The URL.  Can be <jk>null</jk> to unset (a confirm-only child).
+		 * @return This object.
+		 */
+		public ChildAction form(String value) {
+			form = value;
+			return this;
+		}
+
+		/**
+		 * Sets the child's submit endpoint.
+		 *
+		 * <p>
+		 * Optional individually; a child action whose dialog is meant to be <b>submittable</b> must declare both a
+		 * non-safe {@link #method} and an {@code endpoint}, or its Confirm visibly refuses with {@code safe-method}
+		 * and sends nothing.  A display-only child that never submits needs neither, which is why this is not a
+		 * {@link ModalDef#validate()} rule &mdash; this bean cannot know which of its children are meant to submit.
+		 *
+		 * @param value The endpoint.  Can be <jk>null</jk> to unset.
+		 * @return This object.
+		 */
+		public ChildAction endpoint(String value) {
+			endpoint = value;
+			return this;
+		}
+
+		/**
+		 * Sets the child's submit method.
+		 *
+		 * <p>
+		 * Optional individually; see {@link #endpoint(String)} &mdash; a submittable child needs both, and a
+		 * missing or safe method is a visible client-side refusal before any request is sent.
+		 *
+		 * @param value The method.  Can be <jk>null</jk> to unset.
+		 * @return This object.
+		 */
+		public ChildAction method(String value) {
+			method = value;
+			return this;
+		}
+
+		/**
+		 * Sets the child's on-success behaviour token.
+		 *
+		 * @param value The token.  Can be <jk>null</jk> to unset.
+		 * @return This object.
+		 */
+		public ChildAction onSuccess(String value) {
+			onSuccess = value;
+			return this;
+		}
+
+		/**
+		 * Declares that opening this child carries the parent dialog's drafts (see {@link #carryDrafts}).
+		 *
+		 * <p>
+		 * Passing <jk>false</jk> <b>clears</b> the flag rather than serializing an explicit {@code false}, so an
+		 * un-opted-in child's payload is byte-identical to one that never mentioned it.
+		 *
+		 * @param value <jk>true</jk> to carry the parent's drafts; <jk>false</jk> (or unset) to open empty.
+		 * @return This object.
+		 */
+		public ChildAction carryDrafts(boolean value) {
+			carryDrafts = value ? Boolean.TRUE : null;
+			return this;
+		}
 	}
 
 	/**
@@ -193,6 +419,22 @@ public class ModalDef implements Widget {
 	 */
 	public BarSlot barSlot;
 
+	/**
+	 * Whether the runtime must <b>not</b> close this dialog at confirm-click time, so the write's result can be
+	 * painted into it as a receipt (see the class Javadoc); omitted from the wire when unset or <jk>false</jk>.
+	 *
+	 * <p>
+	 * Additive-only: like {@link #barSlot} and {@link #selfTargeted}, its presence does not bump
+	 * {@link #CONTRACT_VERSION}.
+	 */
+	public Boolean keepOpenOnSubmit;
+
+	/**
+	 * The dialog-scoped catalog of stacked child actions a {@code type="action"} input in {@link #form} may open
+	 * (see the class Javadoc); omitted from the wire when unset or empty.
+	 */
+	public List<ChildAction> childActions;
+
 	/**
 	 * Starts a new {@link ModalDef} with the specified title / confirmation prompt.
 	 *
@@ -222,6 +464,52 @@ public class ModalDef implements Widget {
 		return this;
 	}
 
+	/**
+	 * Adds one typed confirmation field displayed as monospaced, copyable {@link #FIELD_KIND_CODE code}.
+	 *
+	 * @param label The field label.  Must not be <jk>null</jk> or blank.
+	 * @param value The field value.  Can be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ModalDef codeField(String label, String value) {
+		if (fields == null)
+			fields = l();
+		fields.add(Field.code(label, value));
+		return this;
+	}
+
+	/**
+	 * Adds one entry to this dialog's child-action catalog.
+	 *
+	 * @param value The child action.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+	 */
+	public ModalDef childAction(ChildAction value) {
+		if (value == null)
+			throw iaex("ModalDef.ChildAction must not be null.");
+		if (childActions == null)
+			childActions = l();
+		childActions.add(value);
+		return this;
+	}
+
+	/**
+	 * Declares that the runtime must keep this dialog open across its submit so the result can be painted into it
+	 * as a receipt (see the class Javadoc).
+	 *
+	 * <p>
+	 * Passing <jk>false</jk> <b>clears</b> the flag rather than serializing an explicit {@code false}: the wire
+	 * carries this opt-in only when it is on, so an un-opted-in dialog's payload is byte-identical to today's.
+	 *
+	 * @param value <jk>true</jk> to opt in; <jk>false</jk> (or unset) keeps today's close-then-submit behaviour.
+	 * @return This object.
+	 */
+	public ModalDef keepOpenOnSubmit(boolean value) {
+		keepOpenOnSubmit = value ? Boolean.TRUE : null;
+		return this;
+	}
+
 	/**
 	 * Sets the optional input form.
 	 *
@@ -288,6 +576,14 @@ public class ModalDef implements Widget {
 	 * (a raw-built form-bearing modal validated directly must not false-refuse on a null version &mdash;
 	 * {@link #checked()} stamps the version first, then validates).
 	 *
+	 * <p>
+	 * Also rejects, fail-closed at serve time rather than silently on the wire: a {@link Field#kind} outside the
+	 * {@link #FIELD_KIND_TEXT text}/{@link #FIELD_KIND_CODE code} allowlist; a blank, duplicate or unlabelled
+	 * {@link ChildAction} id; {@link ChildAction#carryDrafts} on a modal that declares no {@link #form} (nothing to
+	 * collect <i>from</i>); and {@link ChildAction#carryDrafts} on a child that declares no
+	 * {@link ChildAction#form} (nowhere to put them &mdash; the runtime would take its confirm-only branch, issue
+	 * no GET, and the drafts would vanish silently).
+	 *
 	 * @throws IllegalArgumentException If this modal is not well-formed.
 	 */
 	@Override
@@ -300,13 +596,49 @@ public class ModalDef implements Widget {
 					throw iaex("ModalDef field must not be null.");
 				if (f.label == null || f.label.isBlank())
 					throw iaex("ModalDef.Field label must not be null or blank.");
+				if (f.kind != null && ! (FIELD_KIND_TEXT.equals(f.kind) || FIELD_KIND_CODE.equals(f.kind)))
+					throw iaex("ModalDef.Field kind must be '" + FIELD_KIND_TEXT + "' or '" + FIELD_KIND_CODE
+						+ "', not '" + f.kind + "'.");
 			}
+		validateChildActions();
 		if (form != null)
 			form.validate();
 		if (barSlot != null)
 			barSlot.validate();
 	}
 
+	/**
+	 * The {@link #childActions} half of {@link #validate()}.
+	 *
+	 * <p>
+	 * {@link ChildAction#of(String,String)} already argument-checks {@code id} and {@code label}; they are
+	 * re-checked here as the wire-level backstop for a bean-deserialized instance that never went through the
+	 * factory.
+	 */
+	private void validateChildActions() {
+		if (childActions == null)
+			return;
+		var seen = new HashSet<String>();
+		for (var c : childActions) {
+			if (c == null)
+				throw iaex("ModalDef.ChildAction must not be null.");
+			if (c.id == null || c.id.isBlank())
+				throw iaex("ModalDef.ChildAction id must not be null or blank.");
+			if (c.label == null || c.label.isBlank())
+				throw iaex("ModalDef.ChildAction label must not be null or blank.");
+			if (! seen.add(c.id))
+				throw iaex("ModalDef.ChildAction id '" + c.id + "' is declared more than once on this modal.");
+			if (Boolean.TRUE.equals(c.carryDrafts)) {
+				if (form == null)
+					throw iaex("ModalDef.ChildAction '" + c.id + "' declares carryDrafts, but this modal declares "
+						+ "no form - there is nothing to collect drafts from.");
+				if (c.form == null || c.form.isBlank())
+					throw iaex("ModalDef.ChildAction '" + c.id + "' declares carryDrafts, but declares no form URL "
+						+ "- a confirm-only child issues no form GET, so the drafts would be dropped silently.");
+			}
+		}
+	}
+
 	/**
 	 * The serving-path hook every app {@code @RestGet} that returns a {@link ModalDef} must invoke.
 	 *
diff --git a/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_ResultHost_Test.java b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_ResultHost_Test.java
new file mode 100644
index 0000000000..8464d9feac
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_ResultHost_Test.java
@@ -0,0 +1,282 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.widgets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Bean contract for the in-dialog <b>result host</b> opt-in ({@link ModalDef#keepOpenOnSubmit}), the dialog-scoped
+ * <b>child-action catalog</b> ({@link ModalDef#childActions} / {@link ModalDef.ChildAction}), and the confirmation
+ * field <b>display kind</b> ({@link ModalDef.Field#kind} / {@link ModalDef.Field#code(String,String)}).
+ *
+ * <p>
+ * All three are additive to an already-served payload, so the three properties this pins hardest are the ones that
+ * make that claim true rather than merely intended:
+ * <ul class='spaced-list'>
+ * 	<li><b>Appended, never interleaved.</b> The new keys land after the frozen prefix, in {@code @BeanType} order.
+ * 	<li><b>Omitted when unset.</b> An un-opted-in modal's payload is byte-identical to one served before any of
+ * 		this existed &mdash; which is what lets an older runtime keep reading it, and lets the golden fixtures in
+ * 		{@code ModalDef_FormDef_Test} / {@code ModalDef_BarSlot_Test} stay untouched.
+ * 	<li><b>No {@link ModalDef#CONTRACT_VERSION} bump.</b> Additive means the version does not move.
+ * </ul>
+ *
+ * <p>
+ * The remaining group is the fail-closed serve-time refusals: a payload that would produce a silently-wrong or
+ * silently-empty client surface is rejected by {@link ModalDef#validate()} at serve time, where the author sees it,
+ * rather than degrading quietly in a browser.
+ */
+class ModalDef_ResultHost_Test extends TestBase {
+
+	private static FormDef form() {
+		return FormDef.create().field(FormDef.Input.of("note", "Note", "text"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) keepOpenOnSubmit - the receipt opt-in
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_fluentSetter_storesTheFlag() {
+		assertEquals(Boolean.TRUE, ModalDef.create("Delete?").keepOpenOnSubmit(true).keepOpenOnSubmit);
+	}
+
+	@Test void a02_falseClearsRatherThanSerializingFalse() {
+		// Clearing, not storing FALSE: an un-opted-in payload must be byte-identical to one that never mentioned
+		// the flag, or every existing golden fixture would have to grow a "keepOpenOnSubmit":false.
+		assertNull(ModalDef.create("Delete?").keepOpenOnSubmit(true).keepOpenOnSubmit(false).keepOpenOnSubmit);
+		assertFalse(Json.of(ModalDef.create("Delete?").keepOpenOnSubmit(false)).contains("keepOpenOnSubmit"));
+	}
+
+	@Test void a03_omittedWhenUnset() {
+		var json = Json.of(ModalDef.create("Delete?").checked());
+		assertFalse(json.contains("keepOpenOnSubmit"), () -> "unset flag leaked:\n" + json);
+		assertFalse(json.contains("childActions"), () -> "unset catalog leaked:\n" + json);
+	}
+
+	@Test void a04_emittedWhenSet_andAppendedLast() {
+		var m = ModalDef.create("Delete?").field("Id", "INC-1").form(form()).keepOpenOnSubmit(true).checked();
+		Map<?,?> actual = Json.to(Json.of(m), Map.class);
+		assertEquals(List.of("contractVersion", "title", "fields", "form", "keepOpenOnSubmit"),
+			new ArrayList<>(actual.keySet()));
+	}
+
+	@Test void a05_noContractVersionBump() {
+		// A form-bearing modal is the one that carries a stamped version at all (checked() clears it otherwise),
+		// so it is the one that can show the version did not move.
+		assertEquals("2", ModalDef.CONTRACT_VERSION);
+		var json = Json.of(ModalDef.create("Delete?").form(form()).keepOpenOnSubmit(true).checked());
+		assertTrue(json.contains("\"contractVersion\":\"2\""), json);
+		assertTrue(json.contains("\"keepOpenOnSubmit\":true"), json);
+	}
+
+	@Test void a06_keepOpenOnSubmitAloneIsValid() {
+		// The opt-in does not require a form: a confirm-only action can still host its own receipt.
+		assertDoesNotThrow(() -> ModalDef.create("Delete?").keepOpenOnSubmit(true).validate());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Field.kind - the display kind on a confirmation field
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_kindOmittedWhenUnset() {
+		var json = Json.of(ModalDef.create("Delete?").field("Id", "INC-1").checked());
+		assertFalse(json.contains("kind"), () -> "unset kind leaked:\n" + json);
+	}
+
+	@Test void b02_codeFactorySetsTheKind() {
+		var f = ModalDef.Field.code("Token", "abc-123");
+		assertEquals("Token", f.label);
+		assertEquals("abc-123", f.value);
+		assertEquals(ModalDef.FIELD_KIND_CODE, f.kind);
+	}
+
+	@Test void b03_codeFieldOnTheModal_appendsKindLast() {
+		var m = ModalDef.create("Done").codeField("Token", "abc-123").checked();
+		var fields = (List<?>) Json.to(Json.of(m), Map.class).get("fields");
+		assertEquals(List.of("label", "value", "kind"), new ArrayList<>(((Map<?,?>) fields.get(0)).keySet()));
+	}
+
+	@Test void b04_codeFactoryArgumentChecksTheLabel() {
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.code(null, "v"));
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.code("  ", "v"));
+		// A null VALUE is legal (an absent value renders empty, it is not an authoring error).
+		assertDoesNotThrow(() -> ModalDef.Field.code("Token", null));
+	}
+
+	@Test void b05_bothAllowlistTokensValidate() {
+		for (var k : List.of(ModalDef.FIELD_KIND_TEXT, ModalDef.FIELD_KIND_CODE)) {
+			var m = ModalDef.create("Done");
+			m.field("L", "v");
+			m.fields.get(0).kind = k;
+			assertDoesNotThrow(m::validate, k);
+		}
+	}
+
+	@Test void b06_offAllowlistKindIsRefusedAtServeTime() {
+		// Fail closed where the author can see it.  The runtime ALSO falls back to `text` for an unknown token
+		// rather than trusting it, but a served payload should never get that far.
+		var m = ModalDef.create("Done");
+		m.field("L", "v");
+		m.fields.get(0).kind = "html";
+		var e = assertThrows(IllegalArgumentException.class, m::validate);
+		assertTrue(e.getMessage().contains("kind"), e::getMessage);
+		assertTrue(e.getMessage().contains("html"), e::getMessage);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) childActions - the dialog-scoped catalog
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_factoryStoresIdAndLabel() {
+		var c = ModalDef.ChildAction.of("review", "Review");
+		assertEquals("review", c.id);
+		assertEquals("Review", c.label);
+		assertNull(c.form);
+		assertNull(c.endpoint);
+		assertNull(c.method);
+		assertNull(c.onSuccess);
+		assertNull(c.carryDrafts);
+	}
+
+	@Test void c02_factoryArgumentChecks() {
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of(null, "L"));
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of("  ", "L"));
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of("id", null));
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of("id", "  "));
+	}
+
+	@Test void c03_fluentSettersAndWireShape() {
+		var m = ModalDef.create("Step 1")
+			.form(form())
+			.childAction(ModalDef.ChildAction.of("review", "Review")
+				.form("/x/review-form").endpoint("/x/review").method("POST").onSuccess("redraw").carryDrafts(true))
+			.checked();
+		var child = (Map<?,?>) ((List<?>) Json.to(Json.of(m), Map.class).get("childActions")).get(0);
+		assertEquals(List.of("id", "label", "form", "endpoint", "method", "onSuccess", "carryDrafts"),
+			new ArrayList<>(child.keySet()));
+		assertEquals("review", child.get("id"));
+		assertEquals(Boolean.TRUE, child.get("carryDrafts"));
+	}
+
+	@Test void c04_childActionsAppendedAfterKeepOpenOnSubmit() {
+		var m = ModalDef.create("Step 1")
+			.form(form())
+			.keepOpenOnSubmit(true)
+			.childAction(ModalDef.ChildAction.of("review", "Review"))
+			.checked();
+		Map<?,?> actual = Json.to(Json.of(m), Map.class);
+		assertEquals(List.of("contractVersion", "title", "form", "keepOpenOnSubmit", "childActions"),
+			new ArrayList<>(actual.keySet()));
+	}
+
+	@Test void c05_unsetOptionalsOmittedFromAChild() {
+		var m = ModalDef.create("Step 1").childAction(ModalDef.ChildAction.of("review", "Review")).checked();
+		var child = (Map<?,?>) ((List<?>) Json.to(Json.of(m), Map.class).get("childActions")).get(0);
+		assertEquals(List.of("id", "label"), new ArrayList<>(child.keySet()));
+	}
+
+	@Test void c06_carryDraftsFalseClearsRatherThanSerializingFalse() {
+		var c = ModalDef.ChildAction.of("review", "Review").carryDrafts(true).carryDrafts(false);
+		assertNull(c.carryDrafts);
+	}
+
+	@Test void c07_nullChildIsRefusedByTheAdder() {
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.create("Step 1").childAction(null));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) The fail-closed validate rules on the catalog
+	//------------------------------------------------------------------------------------------------------------------
+
+	// A raw list, bypassing the factory - the shape a bean-deserialized payload can actually arrive in.
+	private static ModalDef withRawChildren(ModalDef.ChildAction... children) {
+		var m = ModalDef.create("Step 1");
+		m.childActions = new ArrayList<>(Arrays.asList(children));
+		return m;
+	}
+
+	private static ModalDef.ChildAction raw(String id, String label) {
+		var c = new ModalDef.ChildAction();
+		c.id = id;
+		c.label = label;
+		return c;
+	}
+
+	@Test void d01_blankIdIsRefused() {
+		var e = assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw("  ", "Review")).validate());
+		assertTrue(e.getMessage().contains("id"), e::getMessage);
+		assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw(null, "Review")).validate());
+	}
+
+	@Test void d02_blankLabelIsRefused() {
+		// A child with no label paints a button with nothing on it - unclickable in practice, and invisible in a
+		// screen reader.  Refused where the author can see it.
+		var e = assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw("review", "  ")).validate());
+		assertTrue(e.getMessage().contains("label"), e::getMessage);
+		assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw("review", null)).validate());
+	}
+
+	@Test void d03_duplicateIdIsRefused() {
+		// The resolver returns the FIRST match, so a duplicate id means one of the two declared children is
+		// unreachable with no signal anywhere.
+		var e = assertThrows(IllegalArgumentException.class,
+			() -> withRawChildren(raw("review", "Review"), raw("review", "Review again")).validate());
+		assertTrue(e.getMessage().contains("more than once"), e::getMessage);
+	}
+
+	@Test void d04_nullChildInARawListIsRefused() {
+		assertThrows(IllegalArgumentException.class, () -> withRawChildren((ModalDef.ChildAction) null).validate());
+	}
+
+	@Test void d05_carryDraftsOnAFormlessModalIsRefused() {
+		// Nothing to collect FROM: the parent has no inputs, so the carry would always be an empty object.
+		var m = ModalDef.create("Step 1")
+			.childAction(ModalDef.ChildAction.of("review", "Review").form("/x/review-form").carryDrafts(true));
+		var e = assertThrows(IllegalArgumentException.class, m::validate);
+		assertTrue(e.getMessage().contains("carryDrafts"), e::getMessage);
+		assertTrue(e.getMessage().contains("no form"), e::getMessage);
+	}
+
+	@Test void d06_carryDraftsOnAFormlessChildIsRefused() {
+		// Nowhere to PUT them: a confirm-only child issues no form GET, so the query parameter has no request to
+		// ride on and the drafts would vanish with no error.
+		var m = ModalDef.create("Step 1")
+			.form(form())
+			.childAction(ModalDef.ChildAction.of("review", "Review").carryDrafts(true));
+		var e = assertThrows(IllegalArgumentException.class, m::validate);
+		assertTrue(e.getMessage().contains("carryDrafts"), e::getMessage);
+		assertTrue(e.getMessage().contains("form URL"), e::getMessage);
+	}
+
+	@Test void d07_theLegalCarryDraftsShapePasses() {
+		assertDoesNotThrow(() -> ModalDef.create("Step 1")
+			.form(form())
+			.childAction(ModalDef.ChildAction.of("review", "Review").form("/x/review-form").carryDrafts(true))
+			.validate());
+	}
+
+	@Test void d08_aChildWithoutCarryDraftsNeedsNoForm() {
+		assertDoesNotThrow(() -> ModalDef.create("Step 1")
+			.childAction(ModalDef.ChildAction.of("review", "Review"))
+			.validate());
+	}
+}