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/08/19 20:48:26 UTC

(juneau) 01/02: Views write-path arc: row actions, selections, bulk mutations, modal/form defs, async jobs, and idempotency keys

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

commit 211a919add460e2801046b654e2dfb8d0c06b62c
Author: James Bognar <ja...@apache.org>
AuthorDate: Wed Aug 19 16:47:51 2026 -0400

    Views write-path arc: row actions, selections, bulk mutations, modal/form defs, async jobs, and idempotency keys
    
    Adds the interactive write-path to juneau-rest-server-views: RowAction,
    SelectionDef, BulkMutateDef, and WritePermit for row-level and bulk
    operations; ActionResult, ModalDef, and FormDef for action responses;
    AsyncJob/AsyncJobRef/AsyncJobRegistry/AsyncJobsMixin and IdempotencyKey
    for asynchronous job handling and dedup; and a RawContentSinkScanner
    security-test harness. Wires the behavior into juneau-views.js/.css and
    juneau-pages.js with accompanying Java, JS, and browser tests. Also
    extends the EscapeForScriptSink security scan (juneau-commons) to cover
    the second raw-content sink.
---
 .../EscapeForScriptSink_SecurityScan_Test.java     |   12 +-
 .../juneau/rest/server/views/ActionResult.java     |  270 +++++
 .../apache/juneau/rest/server/views/AsyncJob.java  |  345 ++++++
 .../juneau/rest/server/views/AsyncJobRef.java      |   97 ++
 .../juneau/rest/server/views/AsyncJobRegistry.java |  246 +++++
 .../juneau/rest/server/views/AsyncJobsMixin.java   |  188 ++++
 .../juneau/rest/server/views/BulkMutateDef.java    |  169 +++
 .../apache/juneau/rest/server/views/FormDef.java   |   69 ++
 .../juneau/rest/server/views/IdempotencyKey.java   |  186 ++++
 .../apache/juneau/rest/server/views/ModalDef.java  |  177 +++
 .../apache/juneau/rest/server/views/PageDef.java   |   21 +-
 .../apache/juneau/rest/server/views/PageTable.java |   47 +-
 .../apache/juneau/rest/server/views/RowAction.java |  320 ++++++
 .../juneau/rest/server/views/SelectionDef.java     |  130 +++
 .../apache/juneau/rest/server/views/Subtab.java    |   56 +-
 .../org/apache/juneau/rest/server/views/Tab.java   |   95 +-
 .../apache/juneau/rest/server/views/ViewDef.java   |   39 +-
 .../apache/juneau/rest/server/views/ViewTable.java |  266 ++++-
 .../juneau/rest/server/views/WritePermit.java      |  104 ++
 .../org/apache/juneau/views/juneau-pages.js        |    2 +-
 .../org/apache/juneau/views/juneau-views.css       |  125 +++
 .../org/apache/juneau/views/juneau-views.js        | 1131 +++++++++++++++++++-
 .../server/views/ActionResult_Contract_Test.java   |  156 +++
 .../rest/server/views/AsyncJobRegistry_Test.java   |  193 ++++
 .../rest/server/views/AsyncJob_BrowserTest.java    |  223 ++++
 .../juneau/rest/server/views/AsyncJob_Test.java    |  205 ++++
 .../server/views/AsyncJobsMixin_Serving_Test.java  |  175 +++
 .../rest/server/views/IdempotencyKey_Test.java     |  116 ++
 .../rest/server/views/ModalDef_FormDef_Test.java   |  116 ++
 .../rest/server/views/ModalResult_BrowserTest.java |  231 ++++
 .../rest/server/views/PageDef_Contract_Test.java   |    2 +-
 .../rest/server/views/PageTableContent_Test.java   |  284 +++++
 .../rest/server/views/RawContentSinkScanner.java   |  304 ++++++
 .../views/RawContentSink_SecurityScan_Test.java    |  243 +++++
 .../server/views/RowActionCsrf_BrowserTest.java    |  188 ++++
 .../server/views/RowSelectionBulk_BrowserTest.java |  263 +++++
 .../views/TabSubtabContent_Validation_Test.java    |  214 ++++
 .../server/views/TablePolling_Wiring_Test.java     |   15 +-
 .../rest/server/views/ViewMeta_Contract_Test.java  |   86 +-
 .../server/views/ViewTable_SelectionBulk_Test.java |  174 +++
 .../rest/server/views/ViewsJs_AsyncJobs_Test.java  |  200 ++++
 .../server/views/ViewsJs_ModalResult_Test.java     |  234 ++++
 .../rest/server/views/ViewsJs_RowActions_Test.java |  123 +++
 .../rest/server/views/ViewsJs_Selection_Test.java  |  206 ++++
 .../rest/server/views/ViewsMixin_Serving_Test.java |    2 +-
 ...ritePermit_SelectionDef_BulkMutateDef_Test.java |  175 +++
 .../src/test/js/async-job.cjs                      |  248 +++++
 .../src/test/js/modal-result.cjs                   |  227 ++++
 .../src/test/js/row-actions.cjs                    |  153 +++
 .../src/test/js/row-selection-bulk.cjs             |  272 +++++
 50 files changed, 9066 insertions(+), 57 deletions(-)

diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
index 359492236d..a64508366c 100644
--- a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
@@ -166,18 +166,24 @@ class EscapeForScriptSink_SecurityScan_Test extends TestBase {
 	/**
 	 * Mutation check: the real {@code ViewTable} passes as-is, but removing the escaper turns it into a violation.
 	 * This proves the guard is genuinely exercising that sink rather than passing it by accident.
+	 *
+	 * <p>
+	 * {@code TODO-428} gave {@code ViewTable} a SECOND {@code <script>}-JSON sink &mdash; the independently
+	 * versioned {@code BulkMutateDef} sidecar, alongside the pre-existing VIEW_META one &mdash; so this now
+	 * expects TWO escaped sinks, and the blanket {@code escapeForScript}&rarr;{@code noEscape} mutation (which
+	 * hits every occurrence in the file) now flags both.
 	 */
 	@Test void a12_realViewTable_passesButFailsWhenEscaperRemoved() throws Exception {
 		var root = requireRepoRoot();
 		var source = Files.readString(root.resolve(VIEW_TABLE));
 
 		var clean = ScriptJsonSinkScanner.scan(VIEW_TABLE, source);
-		assertEquals(1, clean.sinks().size(), () -> "sinks: " + clean.sinks());
+		assertEquals(2, clean.sinks().size(), () -> "sinks: " + clean.sinks());
 		assertEquals(java.util.List.of(), clean.violations());
 
 		var mutated = ScriptJsonSinkScanner.scan(VIEW_TABLE, source.replace("escapeForScript", "noEscape"));
-		assertEquals(1, mutated.sinks().size(), () -> "sinks: " + mutated.sinks());
-		assertEquals(1, mutated.violations().size(),
+		assertEquals(2, mutated.sinks().size(), () -> "sinks: " + mutated.sinks());
+		assertEquals(2, mutated.violations().size(),
 			() -> "removing escapeForScript from ViewTable should be flagged: " + mutated.violations());
 	}
 
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
new file mode 100644
index 0000000000..08f8d20691
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
@@ -0,0 +1,270 @@
+/*
+ * 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 org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The typed result a row action's submit returns &mdash; the load-bearing half of the write-path arc (design doc
+ * §6.1/§6.2; the write-path half of {@code TODO-416}).
+ *
+ * <p>
+ * A raw HTML fragment response cannot carry the distinction the consumer's non-optimistic UI rule requires: a row
+ * must not show "acknowledged" because a request was <i>sent</i>, only because the remote system was read back and
+ * said so.  This typed result carries exactly that distinction &mdash; an {@link Outcome outcome discriminator}, the
+ * authoritative {@link #row} data to re-render from, a {@link #replay} marker, and a {@link #refusalCode} &mdash; so
+ * the {@code juneau-views.js} runtime can paint what actually happened (success, failure, a <i>named</i> refusal, or
+ * an honest unknown) rather than what the request asked for.
+ *
+ * <h5 class='section'>Its own, independent contract version</h5>
+ * <p>
+ * The action-result is a <b>third</b> independently-versioned wire contract, alongside {@code VIEW_META}
+ * ({@link ViewDef#CONTRACT_VERSION}) and {@code PAGE_META} ({@link PageDef#CONTRACT_VERSION}).  Its
+ * {@link #CONTRACT_VERSION} is deliberately its <b>own</b> constant and is <b>not</b> aliased to
+ * {@link ViewDef#CONTRACT_VERSION}: aliasing a per-view contract's version onto a separate contract couples two
+ * unrelated client/server locksteps (the exact mechanism behind the {@code PageDef}&rarr;{@code ViewDef} alias risk),
+ * so this contract versions itself.
+ *
+ * <h5 class='section'>Refusals are an opaque, namespaced code &mdash; not a framework-closed enum</h5>
+ * <p>
+ * A refusal's {@link #refusalCode} is an <b>opaque namespaced string</b>, not a framework-closed enum and not a
+ * free-text message.  Refusals span two independent sources, and the framework must be able to render a refusal it
+ * has never heard of:
+ * <ul class='spaced-list'>
+ * 	<li><b>Framework transport codes</b> come from
+ * 		{@link org.apache.juneau.rest.server.filter.LoopbackBoundary.Reason#name() LoopbackBoundary.Reason.name()}
+ * 		(e.g. {@code CSRF_TOKEN_MISSING}), already on the wire as the {@code X-Loopback-Boundary} header and the
+ * 		rejection envelope's {@code reason}.
+ * 	<li><b>Consumer codes</b> are namespaced {@code app:} / {@code write-guard:} strings for concepts Juneau knows
+ * 		nothing about ("not armed", "arming expired", "confirmation does not match this incident", "a previous
+ * 		attempt's outcome is unknown").
+ * </ul>
+ * <p>
+ * The runtime renders ANY code as a visible <i>named</i> refusal without the framework needing to know consumer
+ * concepts exist.
+ *
+ * <h5 class='section'>Reserved async terminal outcomes</h5>
+ * <p>
+ * {@link Outcome#CANCELLED cancelled} and {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} are
+ * <b>reserved from day one</b> (as {@code VIEW_META} reserved {@code rowActions} before it was implemented), even
+ * though a synchronous write never emits them.  The async/streaming variant ({@code TODO-425}) can therefore emit
+ * them without a second result-contract bump and a three-sided lockstep.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	<jc>// A success carrying the authoritative row read back from the remote system.</jc>
+ * 	ActionResult <jv>ok</jv> = ActionResult.<jsm>success</jsm>(<jv>incidentReadBack</jv>);
+ *
+ * 	<jc>// A named refusal from the consumer's write gate.</jc>
+ * 	ActionResult <jv>no</jv> = ActionResult.<jsm>refusal</jsm>(<js>"write-guard:not-armed"</js>);
+ *
+ * 	<jc>// A recorded prior outcome, labelled as a replay rather than re-calling.</jc>
+ * 	ActionResult <jv>again</jv> = ActionResult.<jsm>success</jsm>(<jv>incidentReadBack</jv>).replay(<jk>true</jk>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link RowAction}
+ * 	<li class='jc'>{@link ModalDef}
+ * 	<li class='jc'>{@link IdempotencyKey}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,outcome,replay,refusalCode,message,row")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class ActionResult {
+
+	/**
+	 * The frozen action-result contract version &mdash; its <b>own</b> discriminator, deliberately NOT aliased to
+	 * {@link ViewDef#CONTRACT_VERSION}.  Bumped only on a breaking wire change to this contract.
+	 */
+	public static final String CONTRACT_VERSION = "1";
+
+	/**
+	 * The outcome discriminator a row-action submit reports.
+	 *
+	 * <p>
+	 * Each constant carries the lowercase wire token emitted for the {@code outcome} field.  The four synchronous
+	 * outcomes ({@link #SUCCESS}, {@link #FAILURE}, {@link #REFUSAL}, {@link #UNKNOWN}) are what a synchronous write
+	 * can emit; {@link #CANCELLED} and {@link #CANCELLED_AFTER_EFFECT} are reserved for the async variant
+	 * ({@code TODO-425}) and are frozen here so that contract does not force a second result-contract bump.
+	 */
+	public enum Outcome {
+
+		/** The write completed and {@link ActionResult#row} carries the authoritative row read back. */
+		SUCCESS("success"),
+
+		/** The write was attempted and failed. */
+		FAILURE("failure"),
+
+		/** The write was refused; {@link ActionResult#refusalCode} carries the named reason. */
+		REFUSAL("refusal"),
+
+		/** The write's outcome is genuinely unknown (e.g. a crash mid-write) &mdash; an honest, non-optimistic state. */
+		UNKNOWN("unknown"),
+
+		/** Reserved (async, {@code TODO-425}): the job was cancelled before any effect. */
+		CANCELLED("cancelled"),
+
+		/** Reserved (async, {@code TODO-425}): the job was cancelled after a partial/uncertain effect. */
+		CANCELLED_AFTER_EFFECT("cancelled-after-effect");
+
+		private final String wire;
+
+		Outcome(String wire) {
+			this.wire = wire;
+		}
+
+		/**
+		 * Returns the lowercase wire token for this outcome.
+		 *
+		 * @return The wire token (e.g. {@code "success"}).
+		 */
+		public String wire() {
+			return wire;
+		}
+	}
+
+	/** The frozen contract-version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+	public String contractVersion = CONTRACT_VERSION;
+
+	/** The outcome discriminator wire token (see {@link Outcome#wire()}). */
+	public String outcome;
+
+	/**
+	 * Whether this result is a recorded prior outcome replayed rather than re-executed; omitted from the wire when
+	 * unset (the common, non-replay case).
+	 */
+	public Boolean replay;
+
+	/**
+	 * The opaque, namespaced refusal code for a {@link Outcome#REFUSAL refusal}; omitted from the wire for any other
+	 * outcome.  Framework codes come from
+	 * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary.Reason#name()}; consumer codes are namespaced
+	 * {@code app:} / {@code write-guard:} strings (see the class javadoc).
+	 */
+	public String refusalCode;
+
+	/** Optional human-readable supplementary message for any outcome; omitted from the wire when unset. */
+	public String message;
+
+	/**
+	 * The authoritative row payload the runtime re-renders the row from (the {@code MERGE_ROW} case); omitted from
+	 * the wire when unset.  This is the row as the remote system actually reports it, not what the request asked for.
+	 */
+	public Object row;
+
+	/**
+	 * Starts a {@link Outcome#SUCCESS success} result carrying the authoritative row read back from the remote system.
+	 *
+	 * @param row The authoritative row payload to re-render from.  Can be <jk>null</jk> (e.g. a navigate/redraw
+	 * 	action that carries no row).
+	 * @return A new {@link ActionResult}.
+	 */
+	public static ActionResult success(Object row) {
+		var r = new ActionResult();
+		r.outcome = Outcome.SUCCESS.wire();
+		r.row = row;
+		return r;
+	}
+
+	/**
+	 * Starts a {@link Outcome#FAILURE failure} result.
+	 *
+	 * @return A new {@link ActionResult}.
+	 */
+	public static ActionResult failure() {
+		var r = new ActionResult();
+		r.outcome = Outcome.FAILURE.wire();
+		return r;
+	}
+
+	/**
+	 * Starts a {@link Outcome#REFUSAL refusal} result carrying the named, opaque refusal code.
+	 *
+	 * @param refusalCode The opaque, namespaced refusal code.  Must not be <jk>null</jk> or blank (a refusal with no
+	 * 	code is exactly the free-text-message anti-pattern this contract forbids).
+	 * @return A new {@link ActionResult}.
+	 * @throws IllegalArgumentException If {@code refusalCode} is <jk>null</jk> or blank.
+	 */
+	public static ActionResult refusal(String refusalCode) {
+		if (refusalCode == null || refusalCode.isBlank())
+			throw iaex("ActionResult refusalCode must not be null or blank.");
+		var r = new ActionResult();
+		r.outcome = Outcome.REFUSAL.wire();
+		r.refusalCode = refusalCode;
+		return r;
+	}
+
+	/**
+	 * Starts an {@link Outcome#UNKNOWN unknown} result &mdash; an honest, non-optimistic terminal state.
+	 *
+	 * @return A new {@link ActionResult}.
+	 */
+	public static ActionResult unknown() {
+		var r = new ActionResult();
+		r.outcome = Outcome.UNKNOWN.wire();
+		return r;
+	}
+
+	/**
+	 * Sets the authoritative row payload to re-render from.
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public ActionResult row(Object value) {
+		row = value;
+		return this;
+	}
+
+	/**
+	 * Marks this result as a recorded prior outcome replayed rather than re-executed.
+	 *
+	 * @param value <jk>true</jk> to mark this a replay; <jk>false</jk>/<jk>null</jk> to unset (omitting it from the wire).
+	 * @return This object.
+	 */
+	public ActionResult replay(Boolean value) {
+		replay = (value != null && value) ? Boolean.TRUE : null;
+		return this;
+	}
+
+	/**
+	 * Sets the optional human-readable supplementary message.
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public ActionResult message(String value) {
+		message = value;
+		return this;
+	}
+
+	/**
+	 * Sets the opaque, namespaced refusal code (see {@link #refusalCode}).
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public ActionResult refusalCode(String value) {
+		refusalCode = value;
+		return this;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJob.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJob.java
new file mode 100644
index 0000000000..81f61549b1
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJob.java
@@ -0,0 +1,345 @@
+/*
+ * 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.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.rest.server.views.ActionResult.*;
+
+/**
+ * A single in-memory, one-shot async job &mdash; the streamed-progress + terminal-result unit of the async write
+ * variant (design doc §6.3; {@code TODO-425}).
+ *
+ * <h5 class='section'>The stream URL IS the capability</h5>
+ * <p>
+ * A job is addressed by its {@link #id() id}, an unguessable {@value AsyncJobRegistry#CAPABILITY_BITS}-bit
+ * {@link java.security.SecureRandom} token minted by the {@link AsyncJobRegistry}, matching
+ * {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s bar.  Because a browser {@code EventSource}
+ * constructor cannot set an {@code X-Csrf-Token} header and cookies are unsound on a loopback port, the SSE GET that
+ * streams a job's progress is Host-checked at the boundary and <b>capability-gated by the unguessability of the
+ * id</b> &mdash; there is no session cookie and no CSRF-on-GET (HIGH-4).  Consequently the id is a secret:
+ * {@link #toString()} never renders it.
+ *
+ * <h5 class='section'>Server-side hard limits (MED-6, a Task-11 disclosure bound)</h5>
+ * <p>
+ * A job caps its own streamed output at {@link #maxOutputBytes} bytes and terminates itself when that is exceeded,
+ * and it terminates itself once past its {@link #deadline() hard-timeout deadline}.  Both are enforced <b>here</b>,
+ * server-side &mdash; a client cannot be trusted to cancel &mdash; and both are disclosure bounds, not only heap
+ * bounds: they cap how long, and how much, customer-adjacent triage content is streamed.  The number of concurrent
+ * subscribers is capped at {@link #maxSubscribers} (a browser reload plus one straggler), enforced through
+ * {@link #acquireSubscriber()} / {@link #releaseSubscriber()}.
+ *
+ * <h5 class='section'>Terminal outcomes reuse the frozen {@link ActionResult} contract</h5>
+ * <p>
+ * A job settles exactly once, to an {@link ActionResult}: a producer-supplied {@link #complete(ActionResult)
+ * success/failure/refusal/unknown}, a {@link #cancel() cancellation} (distinguishing {@link Outcome#CANCELLED
+ * cancelled} from {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} via
+ * {@link #markEffectStarted()}), an output-limit termination, or a timeout.  The reserved async outcomes are read
+ * off the frozen {@link Outcome} enum via its public {@code outcome} field &mdash; this contract is reused, never
+ * re-versioned.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * 	<li class='note'>This class is thread-safe: a producer thread appends progress and settles the job while
+ * 		subscriber threads drain it through {@link #awaitUpdate(int, Duration)}.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link AsyncJobRegistry}
+ * 	<li class='jc'>{@link AsyncJobsMixin}
+ * 	<li class='jc'>{@link ActionResult}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class AsyncJob {
+
+	private final String id;
+	private final Instant createdAt;
+	private final Duration timeout;
+	private final long maxOutputBytes;
+	private final int maxSubscribers;
+
+	private final Object lock = new Object();
+	private final List<String> events = new ArrayList<>();
+	private long outputBytes;
+	private boolean effectStarted;
+	private int subscribers;
+	private ActionResult result;   // Non-null iff terminal; the single settled outcome.
+
+	/**
+	 * Constructor.
+	 *
+	 * @param id The unguessable capability id.  Must not be <jk>null</jk> or blank.
+	 * @param createdAt The instant the job was created (for the hard-timeout deadline).  Must not be <jk>null</jk>.
+	 * @param timeout The hard timeout after which the job self-terminates.  Must not be <jk>null</jk>.
+	 * @param maxOutputBytes The maximum number of streamed output bytes before the job self-terminates.
+	 * @param maxSubscribers The maximum number of concurrent subscribers.
+	 */
+	AsyncJob(String id, Instant createdAt, Duration timeout, long maxOutputBytes, int maxSubscribers) {
+		this.id = assertArgNotNull("id", id);
+		this.createdAt = assertArgNotNull("createdAt", createdAt);
+		this.timeout = assertArgNotNull("timeout", timeout);
+		this.maxOutputBytes = maxOutputBytes;
+		this.maxSubscribers = maxSubscribers;
+	}
+
+	/**
+	 * The unguessable capability id &mdash; the whole of the job's access control (see the class javadoc).
+	 *
+	 * @return The id.  Never <jk>null</jk> or blank.
+	 */
+	public String id() { return id; }
+
+	/** @return The instant this job was created. */
+	public Instant createdAt() { return createdAt; }
+
+	/** @return The instant past which this job self-terminates on the next timeout check. */
+	public Instant deadline() { return createdAt.plus(timeout); }
+
+	/** @return The total number of streamed output bytes so far. */
+	public long outputBytes() { synchronized (lock) { return outputBytes; } }
+
+	/** @return The maximum streamed output bytes before self-termination. */
+	public long maxOutputBytes() { return maxOutputBytes; }
+
+	/** @return The maximum number of concurrent subscribers. */
+	public int maxSubscribers() { return maxSubscribers; }
+
+	/**
+	 * Appends a progress event.
+	 *
+	 * <p>
+	 * A no-op once the job is terminal.  Enforces the server-side output cap: if this event would push the total
+	 * over {@link #maxOutputBytes}, the job is instead terminated with a {@link Outcome#FAILURE failure} carrying an
+	 * {@code app:output-limit-exceeded} message and the event is dropped &mdash; the cap is a data-egress bound, so
+	 * the excess content is never streamed.
+	 *
+	 * @param text The progress text.  Must not be <jk>null</jk>.
+	 * @return <jk>true</jk> if the event was appended; <jk>false</jk> if the job was already terminal or the output
+	 * 	cap was hit (which itself terminates the job).
+	 */
+	public boolean progress(String text) {
+		assertArgNotNull("text", text);
+		synchronized (lock) {
+			if (result != null)
+				return false;
+			var add = (long) text.getBytes(UTF_8).length;
+			if (outputBytes + add > maxOutputBytes) {
+				settle(ActionResult.failure().message("app:output-limit-exceeded"));
+				return false;
+			}
+			outputBytes += add;
+			events.add(text);
+			lock.notifyAll();
+			return true;
+		}
+	}
+
+	/**
+	 * Marks that this job's outbound effect has begun, so a subsequent {@link #cancel()} settles to
+	 * {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} rather than {@link Outcome#CANCELLED cancelled}
+	 * (Q4: the two are different outcomes and must not be collapsed).  A no-op once terminal.
+	 */
+	public void markEffectStarted() {
+		synchronized (lock) {
+			if (result == null)
+				effectStarted = true;
+		}
+	}
+
+	/** @return Whether the outbound effect has begun (see {@link #markEffectStarted()}). */
+	public boolean effectStarted() { synchronized (lock) { return effectStarted; } }
+
+	/**
+	 * Settles the job to a producer-supplied terminal result (success/failure/refusal/unknown).
+	 *
+	 * @param value The terminal result.  Must not be <jk>null</jk>.
+	 * @return <jk>true</jk> if this call settled the job; <jk>false</jk> if it was already terminal (first settle
+	 * 	wins).
+	 */
+	public boolean complete(ActionResult value) {
+		assertArgNotNull("value", value);
+		synchronized (lock) {
+			if (result != null)
+				return false;
+			settle(value);
+			return true;
+		}
+	}
+
+	/**
+	 * Cancels the job, settling it to {@link Outcome#CANCELLED cancelled} &mdash; or
+	 * {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} when {@link #markEffectStarted()} was called
+	 * first.  Enforced server-side; a client cannot be trusted to stop the work itself.
+	 *
+	 * @return <jk>true</jk> if this call settled the job; <jk>false</jk> if it was already terminal.
+	 */
+	public boolean cancel() {
+		synchronized (lock) {
+			if (result != null)
+				return false;
+			settle(cancelledResult(effectStarted));
+			return true;
+		}
+	}
+
+	/**
+	 * Terminates the job if it is past its {@link #deadline() hard-timeout deadline}, settling it to a non-optimistic
+	 * {@link Outcome#UNKNOWN unknown} &mdash; the effect may or may not have completed in the time cut off.  This is
+	 * the server-side timeout enforcement (HIGH-4/MED-6): it bounds both heap and, as a Task-11 disclosure bound, how
+	 * long triage keeps streaming customer-adjacent content.
+	 *
+	 * @param now The current instant.  Must not be <jk>null</jk>.
+	 * @return <jk>true</jk> if this call timed the job out; <jk>false</jk> if it was already terminal or not yet past
+	 * 	its deadline.
+	 */
+	public boolean enforceTimeout(Instant now) {
+		assertArgNotNull("now", now);
+		synchronized (lock) {
+			if (result != null || ! now.isAfter(deadline()))
+				return false;
+			settle(ActionResult.unknown().message("app:hard-timeout-exceeded"));
+			return true;
+		}
+	}
+
+	/** @return Whether the job has settled to a terminal result. */
+	public boolean isTerminal() { synchronized (lock) { return result != null; } }
+
+	/** @return The terminal result, or <jk>null</jk> if the job is still running. */
+	public ActionResult result() { synchronized (lock) { return result; } }
+
+	/** @return The number of progress events buffered so far. */
+	public int eventCount() { synchronized (lock) { return events.size(); } }
+
+	/**
+	 * Snapshots the progress events at or after {@code fromIndex} (for a re-attaching subscriber that replays
+	 * buffered progress on page reload, per Q3).
+	 *
+	 * @param fromIndex The zero-based index to start from.
+	 * @return A copy of the events from {@code fromIndex} onward (empty if none/out of range).
+	 */
+	public List<String> eventsFrom(int fromIndex) {
+		synchronized (lock) {
+			if (fromIndex < 0 || fromIndex >= events.size())
+				return List.of();
+			return List.copyOf(events.subList(fromIndex, events.size()));
+		}
+	}
+
+	/**
+	 * Blocks until there is a progress event at or after {@code fromIndex}, or the job is terminal, or {@code timeout}
+	 * elapses &mdash; then returns whichever is available.
+	 *
+	 * <p>
+	 * A terminal job returns immediately with any remaining buffered events plus its terminal result, so a single
+	 * subscriber loop drains a completed (or re-attached) job in one pass.
+	 *
+	 * @param fromIndex The zero-based index the caller has already consumed up to.
+	 * @param timeout The maximum time to wait for a new event.  Must not be <jk>null</jk>.
+	 * @return The new events (possibly empty) and the terminal result (<jk>null</jk> while still running).
+	 */
+	public Update awaitUpdate(int fromIndex, Duration timeout) {
+		assertArgNotNull("timeout", timeout);
+		var deadlineNanos = System.nanoTime() + Math.max(0L, timeout.toNanos());
+		synchronized (lock) {
+			while (true) {
+				if (fromIndex < events.size() || result != null) {
+					var newEvents = fromIndex < events.size()
+						? List.copyOf(events.subList(Math.max(0, fromIndex), events.size()))
+						: List.<String>of();
+					return new Update(newEvents, result);
+				}
+				var remaining = deadlineNanos - System.nanoTime();
+				if (remaining <= 0L)
+					return new Update(List.of(), null);
+				try {
+					lock.wait(remaining / 1_000_000L, (int) (remaining % 1_000_000L));
+				} catch (InterruptedException e) {
+					Thread.currentThread().interrupt();
+					return new Update(List.of(), result);
+				}
+			}
+		}
+	}
+
+	/**
+	 * Attempts to register a subscriber, enforcing the {@link #maxSubscribers} cap.
+	 *
+	 * @return <jk>true</jk> if a subscriber slot was acquired; <jk>false</jk> if the cap is already reached.
+	 */
+	public boolean acquireSubscriber() {
+		synchronized (lock) {
+			if (subscribers >= maxSubscribers)
+				return false;
+			subscribers++;
+			return true;
+		}
+	}
+
+	/** Releases a subscriber slot previously acquired via {@link #acquireSubscriber()}. */
+	public void releaseSubscriber() {
+		synchronized (lock) {
+			if (subscribers > 0)
+				subscribers--;
+		}
+	}
+
+	/** @return The current number of registered subscribers. */
+	public int subscriberCount() { synchronized (lock) { return subscribers; } }
+
+	/** Settles the terminal result and wakes every waiting subscriber.  Caller must hold {@link #lock}. */
+	private void settle(ActionResult value) {
+		result = value;
+		lock.notifyAll();
+	}
+
+	/** Builds a terminal {@link ActionResult} for a cancellation, reusing the frozen contract's reserved outcomes. */
+	private static ActionResult cancelledResult(boolean afterEffect) {
+		var r = new ActionResult();
+		r.outcome = (afterEffect ? Outcome.CANCELLED_AFTER_EFFECT : Outcome.CANCELLED).wire();
+		return r;
+	}
+
+	/**
+	 * Returns a description that does <b>not</b> include the capability id.
+	 *
+	 * <p>
+	 * The id is the stream's whole access control (see the class javadoc); a bean-dumping logger or debug view that
+	 * stringified it would write a live capability somewhere it can be read back and used to attach to the stream.
+	 *
+	 * @return A value-free description.
+	 */
+	@Override /* Object */
+	public String toString() {
+		return "AsyncJob(id=<redacted>,terminal=" + isTerminal() + ")";
+	}
+
+	/**
+	 * The result of {@link AsyncJob#awaitUpdate(int, Duration)}: the newly-available progress events and the terminal
+	 * result (<jk>null</jk> while the job is still running).
+	 *
+	 * @param events The new progress events since the caller's cursor.  Never <jk>null</jk>.
+	 * @param result The terminal result, or <jk>null</jk> if the job is still running.
+	 */
+	public record Update(List<String> events, ActionResult result) {}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRef.java
new file mode 100644
index 0000000000..d0b7b21b8a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRef.java
@@ -0,0 +1,97 @@
+/*
+ * 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 org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The "job accepted" envelope a row action's submit returns <b>instead of</b> a terminal {@link ActionResult} when
+ * the write is asynchronous (design doc §6.3; {@code TODO-425}).
+ *
+ * <h5 class='section'>Why this is not a new {@code RowAction} wire field</h5>
+ * <p>
+ * Whether an action is asynchronous is a property of the <b>response</b>, not of the declared action: the same
+ * {@link RowAction} POST returns either a terminal {@link ActionResult} (synchronous) or this pointer (asynchronous).
+ * So the {@code RowAction} wire schema is untouched &mdash; no new field, no fail-loud contract bump &mdash; and the
+ * {@code juneau-views.js} runtime routes to the streaming path purely by recognizing a {@link #streamUrl} in the 2xx
+ * body.  It carries no data to render, only capability pointers, so it needs no version handshake of its own.
+ *
+ * <h5 class='section'>The URLs are capabilities</h5>
+ * <p>
+ * {@link #streamUrl} points at the SSE progress stream and {@link #cancelUrl} at the cancel endpoint; both embed the
+ * job's unguessable {@value AsyncJobRegistry#CAPABILITY_BITS}-bit id (see {@link AsyncJob}), which is the whole of the
+ * access control (HIGH-4).  The browser {@code EventSource} that consumes {@link #streamUrl} cannot set a CSRF header,
+ * which is precisely why the id must be unguessable rather than CSRF-gated.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link AsyncJobsMixin}
+ * 	<li class='jc'>{@link AsyncJob}
+ * 	<li class='jc'>{@link ActionResult}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="jobId,streamUrl,cancelUrl")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class AsyncJobRef {
+
+	/** The job's unguessable capability id. */
+	public String jobId;
+
+	/** The URL of the SSE progress stream (a browser {@code EventSource} target); embeds {@link #jobId}. */
+	public String streamUrl;
+
+	/** The URL of the cancel endpoint (a non-safe POST); embeds {@link #jobId}. */
+	public String cancelUrl;
+
+	/**
+	 * Builds a reference to the given job using {@code servlet:}-relative URLs against the {@link AsyncJobsMixin}
+	 * mount &mdash; the same {@code servlet:} convention {@code ViewsMixin} uses for its asset URLs, resolved by
+	 * Juneau's serializer against the current request.
+	 *
+	 * @param job The started job.  Must not be <jk>null</jk>.
+	 * @return A new reference carrying the job's id and its {@code servlet:}-relative stream + cancel URLs.
+	 */
+	public static AsyncJobRef of(AsyncJob job) {
+		assertArgNotNull("job", job);
+		var r = new AsyncJobRef();
+		r.jobId = job.id();
+		r.streamUrl = "servlet:" + AsyncJobsMixin.streamPath(job.id());
+		r.cancelUrl = "servlet:" + AsyncJobsMixin.cancelPath(job.id());
+		return r;
+	}
+
+	/**
+	 * Builds a reference carrying explicit stream/cancel URLs (for a consumer that resolves URLs itself, e.g. a
+	 * template-rendered surface downstream of the {@code servlet:} rewrite).
+	 *
+	 * @param jobId The job's capability id.  Must not be <jk>null</jk> or blank.
+	 * @param streamUrl The SSE stream URL.  Must not be <jk>null</jk> or blank.
+	 * @param cancelUrl The cancel URL.  Must not be <jk>null</jk> or blank.
+	 * @return A new reference.
+	 */
+	public static AsyncJobRef of(String jobId, String streamUrl, String cancelUrl) {
+		var r = new AsyncJobRef();
+		r.jobId = jobId;
+		r.streamUrl = streamUrl;
+		r.cancelUrl = cancelUrl;
+		return r;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
new file mode 100644
index 0000000000..f88b192ca2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
@@ -0,0 +1,246 @@
+/*
+ * 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 org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.security.*;
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * An in-memory, per-process registry of {@link AsyncJob async jobs} &mdash; the single-process job store the async
+ * write variant runs on (design doc §6.3; {@code TODO-425}, Q2).
+ *
+ * <h5 class='section'>Single-process, by decision</h5>
+ * <p>
+ * Job state lives only in this object's heap, which is enough for a single-user loopback console and a documented
+ * limitation for anything else (Q2): a process restart drops every in-flight job, exactly as a restart invalidates
+ * every {@link org.apache.juneau.rest.server.filter.SynchronizerToken}.  There is deliberately no store abstraction.
+ *
+ * <h5 class='section'>The id is the capability; the caps are load-bearing</h5>
+ * <p>
+ * Each job is keyed by an unguessable {@value #CAPABILITY_BITS}-bit {@link SecureRandom} id
+ * ({@value #CAPABILITY_BYTES} bytes, hex-encoded), matching
+ * {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s width &mdash; the id <b>is</b> the stream's
+ * access control (HIGH-4).  {@link #get(String)} of an unknown id returns empty, so a miss is indistinguishable from
+ * any other and the id space cannot be enumerated.
+ * <p>
+ * Because jobs keep running after the page navigates away (Q3) and stream customer-adjacent content, the following
+ * caps are enforced server-side and are security controls, not tuning knobs (MED-6, a Task-11 disclosure bound):
+ * <ul class='spaced-list'>
+ * 	<li>{@link #MAX_CONCURRENT_JOBS} concurrent (non-terminal) jobs &mdash; {@link #tryCreate()} refuses beyond it;
+ * 	<li>{@link AsyncJobRegistry#HARD_TIMEOUT} hard per-job timeout &mdash; scheduled here and also swept by
+ * 		{@link #sweepTimeouts()};
+ * 	<li>{@link AsyncJob#maxOutputBytes()} of streamed output per job &mdash; enforced in {@link AsyncJob#progress};
+ * 	<li>{@link AsyncJob#maxSubscribers()} concurrent subscribers per job &mdash; enforced in
+ * 		{@link AsyncJob#acquireSubscriber()}.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link AsyncJob}
+ * 	<li class='jc'>{@link AsyncJobsMixin}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class AsyncJobRegistry implements AutoCloseable {
+
+	/** Number of random bytes behind a capability id.  {@value #CAPABILITY_BITS} bits, matching {@code SynchronizerToken}. */
+	public static final int CAPABILITY_BYTES = 32;
+
+	/** Width of a capability id, in bits &mdash; well beyond the &ge;128-bit bar. */
+	public static final int CAPABILITY_BITS = CAPABILITY_BYTES * 8;
+
+	/** Maximum number of concurrent (non-terminal) jobs; {@link #tryCreate()} refuses beyond this (MED-6). */
+	public static final int MAX_CONCURRENT_JOBS = 8;
+
+	/** Maximum number of concurrent subscribers per job (a reload plus one straggler) (MED-6). */
+	public static final int MAX_SUBSCRIBERS_PER_JOB = 2;
+
+	/** Maximum streamed output per job, in bytes (1 MiB); a data-egress bound, not only a heap bound (MED-6). */
+	public static final long MAX_OUTPUT_BYTES = 1024L * 1024L;
+
+	/** Hard per-job timeout; caps how long triage keeps streaming customer-adjacent content (HIGH-4/MED-6/Task-11). */
+	public static final Duration HARD_TIMEOUT = Duration.ofSeconds(120);
+
+	/** How long a terminal job is retained (for a page-reload re-attach) before it is reaped from the heap. */
+	static final Duration RETENTION = Duration.ofSeconds(60);
+
+	private final Map<String,AsyncJob> jobs = new ConcurrentHashMap<>();
+	private final SecureRandom random = new SecureRandom();
+	private final Clock clock;
+	private final Duration timeout;
+	private final long maxOutputBytes;
+	private final int maxSubscribers;
+	private final ScheduledExecutorService scheduler;
+	private final boolean ownsScheduler;
+
+	/**
+	 * Creates a registry with the production defaults: a system clock, the {@link #HARD_TIMEOUT} timeout,
+	 * {@link #MAX_OUTPUT_BYTES} / {@link #MAX_SUBSCRIBERS_PER_JOB} caps, and a private daemon scheduler that enforces
+	 * each job's hard timeout.
+	 */
+	public AsyncJobRegistry() {
+		this(Clock.systemUTC(), HARD_TIMEOUT, MAX_OUTPUT_BYTES, MAX_SUBSCRIBERS_PER_JOB, defaultScheduler(), true);
+	}
+
+	/**
+	 * Test/advanced constructor allowing an injected clock, timeout, caps and scheduler.
+	 *
+	 * @param clock The clock supplying job-creation and timeout-check instants.  Must not be <jk>null</jk>.
+	 * @param timeout The hard per-job timeout.  Must not be <jk>null</jk>.
+	 * @param maxOutputBytes The per-job streamed-output cap, in bytes.
+	 * @param maxSubscribers The per-job concurrent-subscriber cap.
+	 * @param scheduler The scheduler that enforces each job's hard timeout, or <jk>null</jk> to rely solely on
+	 * 	{@link #sweepTimeouts()} (used by deterministic tests).
+	 */
+	AsyncJobRegistry(Clock clock, Duration timeout, long maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler) {
+		this(clock, timeout, maxOutputBytes, maxSubscribers, scheduler, false);
+	}
+
+	private AsyncJobRegistry(Clock clock, Duration timeout, long maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler, boolean ownsScheduler) {
+		this.clock = assertArgNotNull("clock", clock);
+		this.timeout = assertArgNotNull("timeout", timeout);
+		this.maxOutputBytes = maxOutputBytes;
+		this.maxSubscribers = maxSubscribers;
+		this.scheduler = scheduler;
+		this.ownsScheduler = ownsScheduler;
+	}
+
+	private static ScheduledExecutorService defaultScheduler() {
+		return Executors.newSingleThreadScheduledExecutor(r -> {
+			var t = new Thread(r, "juneau-async-jobs");
+			t.setDaemon(true);
+			return t;
+		});
+	}
+
+	/**
+	 * Attempts to create and register a new job with a freshly-minted capability id.
+	 *
+	 * <p>
+	 * Sweeps timeouts first (so a slot held only by an expired job is freed), then refuses when the running-job cap
+	 * is already reached &mdash; an unbounded set of keep-running jobs would be unbounded egress of customer-adjacent
+	 * content (MED-6), so this refusal is load-bearing.
+	 *
+	 * @return The new job, or empty if {@link #MAX_CONCURRENT_JOBS} concurrent jobs are already running.
+	 */
+	public Optional<AsyncJob> tryCreate() {
+		sweepTimeouts();
+		if (runningCount() >= MAX_CONCURRENT_JOBS)
+			return Optional.empty();
+		var job = new AsyncJob(mintId(), clock.instant(), timeout, maxOutputBytes, maxSubscribers);
+		jobs.put(job.id(), job);
+		scheduleTimeout(job);
+		return Optional.of(job);
+	}
+
+	/**
+	 * Creates and registers a new job, throwing when the concurrency cap is reached.
+	 *
+	 * @return The new job.
+	 * @throws IllegalStateException If {@link #MAX_CONCURRENT_JOBS} concurrent jobs are already running.
+	 */
+	public AsyncJob create() {
+		return tryCreate().orElseThrow(() -> new IllegalStateException(
+			"Refusing to create an async job: the " + MAX_CONCURRENT_JOBS + "-concurrent-job cap is reached."));
+	}
+
+	/**
+	 * Looks up a job by its capability id.
+	 *
+	 * <p>
+	 * An unknown id returns empty &mdash; a miss is indistinguishable from any other, so the unguessable id space
+	 * cannot be enumerated (HIGH-4).
+	 *
+	 * @param id The capability id.  Can be <jk>null</jk>/blank (returns empty).
+	 * @return The job, or empty if no such id is registered.
+	 */
+	public Optional<AsyncJob> get(String id) {
+		if (id == null || id.isBlank())
+			return Optional.empty();
+		return Optional.ofNullable(jobs.get(id));
+	}
+
+	/**
+	 * Cancels the job with the given id (see {@link AsyncJob#cancel()}).
+	 *
+	 * @param id The capability id.  Can be <jk>null</jk>/blank.
+	 * @return The (now terminal) job, or empty if no such id is registered.
+	 */
+	public Optional<AsyncJob> cancel(String id) {
+		var job = get(id);
+		job.ifPresent(AsyncJob::cancel);
+		return job;
+	}
+
+	/** @return The number of concurrent (non-terminal) jobs currently registered. */
+	public int runningCount() {
+		var n = 0;
+		for (var job : jobs.values())
+			if (! job.isTerminal())
+				n++;
+		return n;
+	}
+
+	/** @return The total number of jobs currently registered (running plus retained-terminal). */
+	public int size() { return jobs.size(); }
+
+	/**
+	 * Enforces the hard timeout on every job at the current clock instant and reaps terminal jobs past their
+	 * retention window.  Called on every {@link #tryCreate()}, scheduled per-job by the private scheduler, and callable
+	 * directly by deterministic tests (with a mutable clock) instead of waiting on wall time.
+	 */
+	public void sweepTimeouts() {
+		var now = clock.instant();
+		var reapBefore = now.minus(RETENTION);
+		var it = jobs.values().iterator();
+		while (it.hasNext()) {
+			var job = it.next();
+			job.enforceTimeout(now);
+			if (job.isTerminal() && job.createdAt().isBefore(reapBefore))
+				it.remove();
+		}
+	}
+
+	private void scheduleTimeout(AsyncJob job) {
+		if (scheduler == null)
+			return;
+		var delay = Duration.between(clock.instant(), job.deadline()).toMillis();
+		try {
+			scheduler.schedule(() -> job.enforceTimeout(clock.instant()), Math.max(0L, delay), TimeUnit.MILLISECONDS);
+		} catch (RejectedExecutionException e) {
+			// Scheduler shut down (registry closed); the sweep on the next tryCreate() still enforces the timeout.
+		}
+	}
+
+	private String mintId() {
+		var bytes = new byte[CAPABILITY_BYTES];
+		random.nextBytes(bytes);
+		return HexFormat.of().formatHex(bytes);
+	}
+
+	/** Shuts down the private scheduler (if this registry created one).  Registered jobs are left as-is. */
+	@Override /* AutoCloseable */
+	public void close() {
+		if (ownsScheduler && scheduler != null)
+			scheduler.shutdownNow();
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobsMixin.java
new file mode 100644
index 0000000000..5ed6db03ac
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobsMixin.java
@@ -0,0 +1,188 @@
+/*
+ * 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 java.io.*;
+import java.time.*;
+
+import org.apache.juneau.http.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.*;
+
+/**
+ * Mixin interface that exposes the async-job SSE progress stream and cancel endpoint on any Juneau REST resource
+ * (design doc §6.3; {@code TODO-425}).
+ *
+ * <p>
+ * An implementing resource supplies its {@link AsyncJobRegistry} via {@link #asyncJobRegistry()} &mdash; typically a
+ * single per-process instance &mdash; and gets two endpoints for free:
+ * <ul class='spaced-list'>
+ * 	<li><b>{@code GET /juneau-jobs/{jobId}/stream}</b> &mdash; a held-open {@code text/event-stream} of the job's
+ * 		progress, terminated by one {@code result} event carrying the terminal {@link ActionResult}.
+ * 	<li><b>{@code POST /juneau-jobs/{jobId}/cancel}</b> &mdash; cancels the job and returns its terminal
+ * 		{@link ActionResult} (a {@code cancelled} / {@code cancelled-after-effect} outcome).
+ * </ul>
+ *
+ * <h5 class='section'>Authorization: the stream URL is the capability (HIGH-4)</h5>
+ * <p>
+ * The stream is a held-open <b>GET</b>, so {@link org.apache.juneau.rest.server.filter.LoopbackBoundary} applies only
+ * its <b>Host</b> check (DNS-rebinding stays covered on every request); the Origin/CSRF/JSON checks apply to non-safe
+ * methods only.  A browser {@code EventSource} cannot set an {@code X-Csrf-Token} header and cookies are unsound on a
+ * loopback port, so CSRF-on-GET is not an available control.  Access is therefore gated by the <b>unguessability of
+ * the {@code jobId}</b> path segment &mdash; an {@value AsyncJobRegistry#CAPABILITY_BITS}-bit
+ * {@link java.security.SecureRandom} capability &mdash; and nothing else.  An unknown id answers {@code 404} (a miss
+ * is indistinguishable, so the id space cannot be enumerated); a job already at its subscriber cap answers
+ * {@code 429}.
+ *
+ * <h5 class='section'>Server-side hard limits</h5>
+ * <p>
+ * Every limit is enforced in {@link AsyncJob} / {@link AsyncJobRegistry}, not here and not on the client: the hard
+ * timeout, the per-job output cap, the concurrent-job cap and the per-job subscriber cap.  The stream loop simply
+ * drains the job until it is terminal, which the timeout guarantees will happen.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>
+ * 	<jk>public class</jk> MyResource <jk>extends</jk> BasicRestServlet <jk>implements</jk> AsyncJobsMixin {
+ * 		<jk>private final</jk> AsyncJobRegistry <jf>jobs</jf> = <jk>new</jk> AsyncJobRegistry();
+ * 		<ja>@Override</ja> <jk>public</jk> AsyncJobRegistry asyncJobRegistry() { <jk>return</jk> <jf>jobs</jf>; }
+ * 	}
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link AsyncJobRegistry}
+ * 	<li class='jc'>{@link AsyncJob}
+ * 	<li class='jc'>{@link AsyncJobRef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface AsyncJobsMixin {
+
+	/** The URL path prefix for the async-job endpoints (relative to the host mount). */
+	String JOBS_PREFIX = "/juneau-jobs";
+
+	/** The URL path template of the SSE progress stream. */
+	String STREAM_PATH = JOBS_PREFIX + "/{jobId}/stream";
+
+	/** The URL path template of the cancel endpoint. */
+	String CANCEL_PATH = JOBS_PREFIX + "/{jobId}/cancel";
+
+	/** How long the stream loop blocks per poll while a job is still running (a liveness tick, not a timeout). */
+	Duration STREAM_POLL_INTERVAL = Duration.ofMillis(250);
+
+	/**
+	 * Returns the {@link AsyncJobRegistry} backing this resource's async jobs.
+	 *
+	 * @return The registry.  Never <jk>null</jk>.
+	 */
+	AsyncJobRegistry asyncJobRegistry();
+
+	/**
+	 * [GET /juneau-jobs/{jobId}/stream] &mdash; stream a job's progress as Server-Sent Events.
+	 *
+	 * <p>
+	 * Replays any buffered progress (so a page reload re-attaches, per Q3), streams live progress as {@code progress}
+	 * events, then sends exactly one {@code result} event carrying the terminal {@link ActionResult} as JSON and
+	 * closes.  Access is gated solely by the unguessable {@code jobId} (see the interface javadoc): an unknown id is a
+	 * {@code 404}; a job already at its subscriber cap is a {@code 429}.
+	 *
+	 * @param jobId The job's unguessable capability id.
+	 * @param res The REST response.
+	 * @throws IOException If the stream could not be written.
+	 */
+	@SuppressWarnings({
+		"resource" // False positive: asyncJobRegistry() returns the shared AutoCloseable registry (not owned here) and the fluent sse.sendEvent/flush calls return the same 'sse' already managed by the try-with-resources.
+	})
+	@RestGet(path=STREAM_PATH, summary="Async job SSE progress stream", swagger=@OpSwagger(ignore=true))
+	default void streamJob(@Path("jobId") String jobId, RestResponse res) throws IOException {
+		var job = asyncJobRegistry().get(jobId).orElse(null);
+		if (job == null) {
+			res.setStatus(404);
+			return;
+		}
+		if (! job.acquireSubscriber()) {
+			res.setStatus(429);
+			return;
+		}
+		try (var sse = res.sse()) {
+			var cursor = 0;
+			while (true) {
+				var update = job.awaitUpdate(cursor, STREAM_POLL_INTERVAL);
+				for (var event : update.events()) {
+					sse.sendEvent("progress", event);
+					cursor++;
+				}
+				sse.flush();
+				if (update.result() != null) {
+					sse.sendEvent("result", Json.of(update.result()));
+					sse.flush();
+					return;
+				}
+			}
+		} finally {
+			job.releaseSubscriber();
+		}
+	}
+
+	/**
+	 * [POST /juneau-jobs/{jobId}/cancel] &mdash; cancel a job and return its terminal result.
+	 *
+	 * <p>
+	 * A non-safe POST, so the boundary applies its full Origin/CSRF/JSON checks.  Cancellation is enforced
+	 * server-side (a client cannot be trusted to stop the work); the returned outcome distinguishes {@code cancelled}
+	 * from {@code cancelled-after-effect} (Q4).  An unknown id is a {@code 404} carrying a named refusal.
+	 *
+	 * @param jobId The job's unguessable capability id.
+	 * @param res The REST response.
+	 * @return The job's terminal {@link ActionResult}.
+	 */
+	@SuppressWarnings({
+		"resource" // False positive: asyncJobRegistry() returns the shared AutoCloseable registry, which this method borrows but does not own.
+	})
+	@RestPost(path=CANCEL_PATH, summary="Cancel an async job", swagger=@OpSwagger(ignore=true))
+	default ActionResult cancelJob(@Path("jobId") String jobId, RestResponse res) {
+		var job = asyncJobRegistry().get(jobId).orElse(null);
+		if (job == null) {
+			res.setStatus(404);
+			return ActionResult.refusal("app:unknown-job");
+		}
+		job.cancel();
+		return job.result();
+	}
+
+	/**
+	 * Builds the servlet-relative stream path for a job id.
+	 *
+	 * @param jobId The job's capability id.
+	 * @return The stream path, e.g. {@code /juneau-jobs/<id>/stream}.
+	 */
+	static String streamPath(String jobId) {
+		return JOBS_PREFIX + "/" + jobId + "/stream";
+	}
+
+	/**
+	 * Builds the servlet-relative cancel path for a job id.
+	 *
+	 * @param jobId The job's capability id.
+	 * @return The cancel path, e.g. {@code /juneau-jobs/<id>/cancel}.
+	 */
+	static String cancelPath(String jobId) {
+		return JOBS_PREFIX + "/" + jobId + "/cancel";
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BulkMutateDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BulkMutateDef.java
new file mode 100644
index 0000000000..a1521a55fe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BulkMutateDef.java
@@ -0,0 +1,169 @@
+/*
+ * 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 org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The bulk-mutate opt-in for a {@link ViewTable} &mdash; the second, INDEPENDENT half of {@code TODO-428}'s
+ * separability guarantee (design doc §9.3/§6.2; HIGH-5).
+ *
+ * <h5 class='section'>Two required parameters make it structurally, not conventionally, safe</h5>
+ * <p>
+ * There is no zero-argument (or permit-less) way to obtain a {@link BulkMutateDef}: {@link #create(WritePermit,
+ * SelectionDef)} takes both a {@link WritePermit} <b>and</b> the {@link SelectionDef} it operates against as
+ * required positional parameters. Omitting either is a <b>compile</b> failure, not a missed javadoc sentence
+ * &mdash; the same discipline the console's own {@code WriteGuard} uses for its {@code WritePermit} (source
+ * design §5.4). This closes both halves of HIGH-5 by construction:
+ * <ul class='spaced-list'>
+ * 	<li>The {@link WritePermit} proves the caller's own write-gate was consulted before this affordance is even
+ * 		configured &mdash; a bulk-mutate control cannot be reached by a caller who forgot to check.
+ * 	<li>The {@link SelectionDef} proves a stable-row-id selection mechanism already exists for this table to
+ * 		target &mdash; a {@link BulkMutateDef} can never float free of the selection it acts on, so the reverse
+ * 		question ("which rows does this bulk action see?") is never ambiguous. This is the only direction of
+ * 		coupling between the two opt-ins: bulk-mutate requires selection, but selection never requires (or even
+ * 		references) bulk-mutate &mdash; so enabling selection alone can never surface a bulk-mutate control.
+ * </ul>
+ *
+ * <h5 class='section'>Reuses {@link RowAction} and {@link ActionResult} verbatim &mdash; N per-row writes, per-target results</h5>
+ * <p>
+ * A bulk action is declared as an ordinary {@link RowAction} (the {@link RowAction} wire schema is completely
+ * untouched by this type); {@code juneau-views.js} executes a bulk action as N independent, per-row invocations
+ * of the SAME single-action submit path ({@code submitRowAction}) it already uses for the per-row action menu
+ * &mdash; one fail-closed CSRF POST per selected row, each with its OWN {@code data-juneau-inflight} marker (the
+ * {@code TODO-417} per-row in-flight model, driven per target) and its OWN typed {@link ActionResult}, rendered
+ * independently into that row's own outcome banner. There is deliberately <b>no</b> aggregate "bulk result": a
+ * partial failure among N targets can never be hidden behind an aggregate success, and each target's
+ * {@code data-juneau-inflight} marker clears on ITS OWN terminal outcome (success/failure/refusal/unknown) so a
+ * stuck marker on one target can never halt the whole table's polling (MED-4).
+ *
+ * <h5 class='section'>Its own, independent contract version</h5>
+ * <p>
+ * Unlike {@link SelectionDef} (which is pure DOM attributes, never wire data), a bulk action LIST is itself wire
+ * data the client must parse &mdash; so it travels as its own JSON sidecar (distinct from the {@code VIEW_META}
+ * one), with its own {@link #CONTRACT_VERSION}, exactly mirroring how {@link ActionResult} versions itself
+ * independently of {@link ViewDef#CONTRACT_VERSION} rather than aliasing it. A version bump here can therefore
+ * NEVER force a {@link ViewDef#CONTRACT_VERSION} bump (or vice versa): the two contracts are unrelated, so this
+ * opt-in cannot back the framework into the very lockstep {@code TODO-428}'s R2 guard exists to avoid. The
+ * {@link WritePermit} carried by this builder is a server-side-only construction guard and is <b>never</b>
+ * serialized &mdash; only {@link #contractVersion} and {@link #actions} reach the wire.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	SelectionDef <jv>selection</jv> = SelectionDef.<jsm>create</jsm>(<js>"id"</js>);
+ * 	<jc>// Requires a permit the CALLER's own write-gate has already minted, and the selection it targets.</jc>
+ * 	BulkMutateDef <jv>bulk</jv> = BulkMutateDef.<jsm>create</jsm>(WritePermit.<jsm>forCapability</jsm>(<js>"incident:bulk-ack"</js>), <jv>selection</jv>)
+ * 		.actions(RowAction.<jsm>create</jsm>(<js>"ack"</js>).label(<js>"Acknowledge"</js>)
+ * 			.endpoint(<js>"servlet:/incidents/ack"</js>).method(RowAction.Method.<jsf>POST</jsf>));
+ * 	Div <jv>markup</jv> = ViewTable.<jsm>of</jsm>(<jv>req</jv>, <jv>viewDef</jv>, <jv>rows</jv>, <jv>bulk</jv>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link SelectionDef}
+ * 	<li class='jc'>{@link WritePermit}
+ * 	<li class='jc'>{@link RowAction}
+ * 	<li class='jc'>{@link ActionResult}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,actions")
+public final class BulkMutateDef {
+
+	/**
+	 * The frozen bulk-actions contract version &mdash; its <b>own</b> discriminator, deliberately NOT aliased to
+	 * {@link ViewDef#CONTRACT_VERSION} (see the class javadoc). Bumped only on a breaking wire change to this
+	 * contract.
+	 */
+	public static final String CONTRACT_VERSION = "1";
+
+	/** The frozen contract-version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+	public String contractVersion = CONTRACT_VERSION;
+
+	/** The declared bulk actions, in menu order. Each is an ordinary, unmodified {@link RowAction}. */
+	public List<RowAction> actions;
+
+	/**
+	 * The construction-time proof that the caller's own write-gate was consulted. Deliberately private and NOT
+	 * listed in {@code @BeanType(properties=...)}: it must never reach the wire.
+	 */
+	private final WritePermit permit;
+
+	/** The selection this bulk config operates against. Also excluded from the wire (see {@link #permit}). */
+	private final SelectionDef selection;
+
+	private BulkMutateDef(WritePermit permit, SelectionDef selection) {
+		this.permit = permit;
+		this.selection = selection;
+	}
+
+	/**
+	 * Starts a new {@link BulkMutateDef}, requiring proof that the caller's write-gate was consulted and the
+	 * selection this bulk config will operate against.
+	 *
+	 * @param permit A permit the caller's OWN write-gate minted after its own checks passed. Must not be
+	 * 	<jk>null</jk>.
+	 * @param selection The {@link SelectionDef} this bulk config operates against. Must not be <jk>null</jk>.
+	 * @return A new {@link BulkMutateDef} with no actions yet declared.
+	 * @throws IllegalArgumentException If {@code permit} or {@code selection} is <jk>null</jk>.
+	 */
+	public static BulkMutateDef create(WritePermit permit, SelectionDef selection) {
+		if (permit == null)
+			throw iaex("BulkMutateDef requires a non-null WritePermit (the caller's own write-gate must mint one first).");
+		if (selection == null)
+			throw iaex("BulkMutateDef requires a non-null SelectionDef (the selection this bulk config operates against).");
+		return new BulkMutateDef(permit, selection);
+	}
+
+	/**
+	 * Declares the bulk actions.
+	 *
+	 * @param value The actions, in menu order. Each declares a mutating request executed once per selected row
+	 * 	(see the class javadoc's N-per-row-writes section). Must not be <jk>null</jk> or empty.
+	 * @return This object.
+	 * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or empty.
+	 */
+	public BulkMutateDef actions(RowAction...value) {
+		if (value == null || value.length == 0)
+			throw iaex("BulkMutateDef requires at least one action.");
+		actions = l(value);
+		return this;
+	}
+
+	/**
+	 * The permit proving the caller's write-gate was consulted before this config was built.
+	 *
+	 * @return The permit. Never <jk>null</jk>.
+	 */
+	public WritePermit permit() {
+		return permit;
+	}
+
+	/**
+	 * The selection this bulk config operates against.
+	 *
+	 * @return The selection. Never <jk>null</jk>.
+	 */
+	public SelectionDef selection() {
+		return selection;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
new file mode 100644
index 0000000000..4bb53972bf
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
@@ -0,0 +1,69 @@
+/*
+ * 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 org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The declarative source of a row-action modal's input form (the form half of {@code TODO-416}; {@code TODO-399}
+ * Decision&nbsp;8's "form inside the dialog").
+ *
+ * <h5 class='section'>FreeMarker-first</h5>
+ * <p>
+ * The form's fields are sourced <b>FreeMarker-template-first</b>, consistent with the rest of this module's
+ * server-render story: a {@link #template} names the server-side template that produces the form's field markup,
+ * rendered against the current record.  The alternative bean&rarr;form generator ({@code FormDef.of(beanType)}) is a
+ * deliberately deferred follow-on ({@code TODO-399} Decision&nbsp;8's "bean&rarr;form later"), so this MVP exposes
+ * only the template source; the {@code beanType} field is reserved and omitted from the wire until then.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	FormDef <jv>form</jv> = FormDef.<jsm>ofTemplate</jsm>(<js>"servlet:/incidents/ack-form.ftl"</js>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ModalDef}
+ * 	<li class='jc'>{@link RowAction}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="template")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class FormDef {
+
+	/** The FreeMarker template reference that renders the form's field markup against the current record. */
+	public String template;
+
+	/**
+	 * Creates a form sourced from the specified FreeMarker template reference.
+	 *
+	 * @param template The template reference (a resolvable URL/name).  Must not be <jk>null</jk> or blank.
+	 * @return A new {@link FormDef}.
+	 * @throws IllegalArgumentException If {@code template} is <jk>null</jk> or blank.
+	 */
+	public static FormDef ofTemplate(String template) {
+		if (template == null || template.isBlank())
+			throw iaex("FormDef template must not be null or blank.");
+		var f = new FormDef();
+		f.template = template;
+		return f;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/IdempotencyKey.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/IdempotencyKey.java
new file mode 100644
index 0000000000..c76e8685f3
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/IdempotencyKey.java
@@ -0,0 +1,186 @@
+/*
+ * 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 org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.nio.charset.*;
+import java.security.*;
+import java.util.*;
+
+/**
+ * A server-minted idempotency key for a row-action submit, bound at mint time to the {@code (action, targetId)} pair
+ * it was issued for (design doc §6.2; the idempotency half of {@code TODO-416}).
+ *
+ * <h5 class='section'>Why the key is server-minted and bound, not a client UUID</h5>
+ * <p>
+ * The key is minted <b>when the modal opens</b> &mdash; the server is already in the loop then, since the modal-open
+ * confirmation is server-rendered against the current record &mdash; so a double-click, an impatient re-submit and a
+ * browser retry all carry the <b>same</b> key and collapse to one effect.  The modal owns the key's <i>carry</i>
+ * lifecycle; it does <b>not</b> mint it.
+ * <p>
+ * A client-generated key is not the security property.  A globally-unique key looked up <i>only by key</i> would let
+ * a recorded terminal outcome for incident&nbsp;A be replayed under a submit for incident&nbsp;B &mdash; returning
+ * A's "success" for a write B never performed, and resurrecting the "resolve an incident that was re-triggered in the
+ * interim" case.  This key is therefore bound to {@code (action, targetId)} at mint time: a submit whose
+ * {@code (action, targetId)} does not {@link #matches(String, String) match} the binding is a <b>named refusal</b>,
+ * never a replayed success.
+ *
+ * <h5 class='section'>Unforgeability</h5>
+ * <p>
+ * The key value is {@value #KEY_BYTES} random bytes ({@value #KEY_BITS}&nbsp;bits) from {@link SecureRandom},
+ * hex-encoded &mdash; well beyond the ≥128-bit bar, matching {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s
+ * 256-bit reference.  The binding is what makes it safe; the width is what makes it unguessable.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	<jc>// At modal-open: mint a key bound to this action + this row.</jc>
+ * 	IdempotencyKey <jv>key</jv> = IdempotencyKey.<jsm>mint</jsm>(<js>"ack"</js>, <jv>incidentId</jv>);
+ * 	<jv>modal</jv>.idempotencyKey(<jv>key</jv>.value());
+ *
+ * 	<jc>// At submit: rehydrate the recorded binding and check it against the submitted (action, targetId).</jc>
+ * 	<jk>if</jk> (! <jv>recorded</jv>.matches(<jv>submittedAction</jv>, <jv>submittedTargetId</jv>))
+ * 		<jk>return</jk> ActionResult.<jsm>refusal</jsm>(<js>"write-guard:idempotency-binding-mismatch"</js>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ModalDef}
+ * 	<li class='jc'>{@link ActionResult}
+ * 	<li class='jc'>{@link org.apache.juneau.rest.server.filter.SynchronizerToken}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class IdempotencyKey {
+
+	/** Width of a minted key value, in bits &mdash; well beyond the ≥128-bit bar. */
+	private static final int KEY_BITS = 256;
+
+	/** Number of random bytes behind a minted key value, derived from {@link #KEY_BITS}. */
+	private static final int KEY_BYTES = KEY_BITS / 8;
+
+	private final String value;
+	private final String action;
+	private final String targetId;
+
+	private IdempotencyKey(String value, String action, String targetId) {
+		this.value = value;
+		this.action = action;
+		this.targetId = targetId;
+	}
+
+	/**
+	 * Mints a fresh key bound to the specified {@code (action, targetId)} pair.
+	 *
+	 * @param action The stable action id this key is issued for.  Must not be <jk>null</jk> or blank.
+	 * @param targetId The id of the row/target this key is issued for.  Must not be <jk>null</jk> or blank.
+	 * @return A new key holding a fresh {@value #KEY_BITS}-bit secret, hex-encoded, bound to {@code (action, targetId)}.
+	 * @throws IllegalArgumentException If {@code action} or {@code targetId} is <jk>null</jk> or blank.
+	 */
+	public static IdempotencyKey mint(String action, String targetId) {
+		requireNonBlank("action", action);
+		requireNonBlank("targetId", targetId);
+		var bytes = new byte[KEY_BYTES];
+		new SecureRandom().nextBytes(bytes);
+		return new IdempotencyKey(HexFormat.of().formatHex(bytes), action, targetId);
+	}
+
+	/**
+	 * Rehydrates a previously-minted key from its recorded value and binding.
+	 *
+	 * <p>
+	 * Intended for the submit path: the consumer looks up the recorded {@code (value, action, targetId)} it minted at
+	 * modal-open and reconstructs this to {@link #matches(String, String) check} it against the submitted pair.
+	 *
+	 * @param value The recorded key value.  Must not be <jk>null</jk> or blank.
+	 * @param action The action id the key was bound to at mint time.  Must not be <jk>null</jk> or blank.
+	 * @param targetId The target id the key was bound to at mint time.  Must not be <jk>null</jk> or blank.
+	 * @return A key holding the recorded value and binding.
+	 * @throws IllegalArgumentException If any argument is <jk>null</jk> or blank.
+	 */
+	public static IdempotencyKey of(String value, String action, String targetId) {
+		requireNonBlank("value", value);
+		requireNonBlank("action", action);
+		requireNonBlank("targetId", targetId);
+		return new IdempotencyKey(value, action, targetId);
+	}
+
+	/**
+	 * The opaque key value, for carrying in the modal and echoing back on the submit.
+	 *
+	 * @return The key value.  Never <jk>null</jk> or blank.
+	 */
+	public String value() { return value; }
+
+	/**
+	 * The action id this key is bound to.
+	 *
+	 * @return The bound action id.
+	 */
+	public String action() { return action; }
+
+	/**
+	 * The target id this key is bound to.
+	 *
+	 * @return The bound target id.
+	 */
+	public String targetId() { return targetId; }
+
+	/**
+	 * Whether a submitted {@code (action, targetId)} matches this key's binding.
+	 *
+	 * <p>
+	 * A mismatch must be answered with a <b>named refusal</b>, never a replayed success: it is a key being submitted
+	 * against a different action or a different target than it was minted for.  Compares the target id in constant
+	 * time so a mismatch cannot be probed character-by-character.
+	 *
+	 * @param submittedAction The action id presented by the submit.  Can be <jk>null</jk>.
+	 * @param submittedTargetId The target id presented by the submit.  Can be <jk>null</jk>.
+	 * @return <jk>true</jk> only if both the action and the target id match this key's binding exactly.
+	 */
+	public boolean matches(String submittedAction, String submittedTargetId) {
+		if (submittedAction == null || submittedTargetId == null)
+			return false;
+		return constantTimeEquals(action, submittedAction) && constantTimeEquals(targetId, submittedTargetId);
+	}
+
+	private static boolean constantTimeEquals(String a, String b) {
+		return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
+	}
+
+	private static void requireNonBlank(String name, String value) {
+		assertArgNotNull(name, value);
+		if (value.isBlank())
+			throw iaex("Argument ''%s'' must not be blank.", name);
+	}
+
+	/**
+	 * Returns a description that does <b>not</b> include the key value.
+	 *
+	 * <p>
+	 * The value is a secret; a bean-dumping logger or a debug view that stringifies it would otherwise write it
+	 * somewhere it can be read back and replayed.  The binding is safe to show.
+	 *
+	 * @return A value-free description.
+	 */
+	@Override /* Object */
+	public String toString() {
+		return "IdempotencyKey(value=<redacted>,action=" + action + ",targetId=" + targetId + ")";
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ModalDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ModalDef.java
new file mode 100644
index 0000000000..9d3d9c3ccf
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ModalDef.java
@@ -0,0 +1,177 @@
+/*
+ * 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 org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The declarative modal (dialog) a {@code present=}{@link RowAction.Present#DIALOG dialog} row action opens &mdash;
+ * the payload the <b>modal-open confirmation fetch</b> returns (design doc §6.2; the modal half of
+ * {@code TODO-416}).
+ *
+ * <h5 class='section'>Safe-by-construction confirmation body</h5>
+ * <p>
+ * The confirmation body is <b>typed structured fields</b> ({@link #fields}: incident #, title, service, current
+ * status &mdash; the source design §6.2 field set), server-rendered against the current, authoritative record and
+ * bound to the target identifier.  The {@code juneau-views.js} runtime paints each field's value with
+ * {@code textContent} &mdash; NEVER {@code innerHTML}, never raw markup, never the non-table panel-content sink.
+ * Live/remote data is attacker-influenceable (anyone who can title an incident or post in a watched thread) and this
+ * origin holds the CSRF token, so an HTML sink here would be stored-XSS&nbsp;&rarr;&nbsp;token-theft&nbsp;&rarr;
+ * arbitrary-write; typed fields painted with {@code textContent} close that off by construction.
+ *
+ * <h5 class='section'>The idempotency key rides here</h5>
+ * <p>
+ * {@link #idempotencyKey} is the server-minted {@link IdempotencyKey#value() key value}, minted at modal-open and
+ * bound to {@code (action, targetId)}.  The runtime carries it verbatim on the submit so a double-click / re-submit /
+ * browser retry all collapse to one effect; the server checks the {@link IdempotencyKey#matches(String, String)
+ * binding} on submit and answers a mismatch with a named refusal, never a replayed success.
+ *
+ * <h5 class='section'>Not a new {@code RowAction} wire field</h5>
+ * <p>
+ * This is the response the {@link RowAction#form form-source URL} returns, <b>not</b> a new field on the frozen
+ * {@link RowAction} wire schema: the modal's declaration lives in this separately-served payload so the row-action
+ * contract stays frozen.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	IdempotencyKey <jv>key</jv> = IdempotencyKey.<jsm>mint</jsm>(<js>"ack"</js>, <jv>incidentId</jv>);
+ * 	ModalDef <jv>modal</jv> = ModalDef.<jsm>create</jsm>(<js>"Acknowledge this incident?"</js>)
+ * 		.field(<js>"Incident"</js>, <jv>incidentNumber</jv>)
+ * 		.field(<js>"Title"</js>, <jv>incidentTitle</jv>)
+ * 		.field(<js>"Service"</js>, <jv>serviceName</jv>)
+ * 		.field(<js>"Current status"</js>, <jv>currentStatus</jv>)
+ * 		.form(FormDef.<jsm>ofTemplate</jsm>(<js>"servlet:/incidents/ack-form.ftl"</js>))
+ * 		.idempotencyKey(<jv>key</jv>.value());
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link FormDef}
+ * 	<li class='jc'>{@link IdempotencyKey}
+ * 	<li class='jc'>{@link ActionResult}
+ * 	<li class='jc'>{@link RowAction}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="title,fields,form,idempotencyKey")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class ModalDef {
+
+	/**
+	 * 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")
+	public static class Field {
+
+		/** The field label shown to the user. */
+		public String label;
+
+		/** The field value, read back from the current authoritative record. */
+		public String value;
+
+		/**
+		 * Creates a typed confirmation field.
+		 *
+		 * @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 of(String label, String value) {
+			if (label == null || label.isBlank())
+				throw iaex("ModalDef.Field label must not be null or blank.");
+			var f = new Field();
+			f.label = label;
+			f.value = value;
+			return f;
+		}
+	}
+
+	/** The modal title / confirmation prompt. */
+	public String title;
+
+	/** The typed confirmation fields, in display order; omitted from the wire when none are declared. */
+	public List<Field> fields;
+
+	/** The optional input form; omitted from the wire when unset (a confirm-only modal). */
+	public FormDef form;
+
+	/**
+	 * The server-minted idempotency key value the runtime carries on the submit; omitted from the wire when unset.
+	 * See {@link IdempotencyKey}.
+	 */
+	public String idempotencyKey;
+
+	/**
+	 * Starts a new {@link ModalDef} with the specified title / confirmation prompt.
+	 *
+	 * @param title The modal title / confirmation prompt.  Must not be <jk>null</jk> or blank.
+	 * @return A new mutable {@link ModalDef} to chain builder calls on.
+	 * @throws IllegalArgumentException If {@code title} is <jk>null</jk> or blank.
+	 */
+	public static ModalDef create(String title) {
+		if (title == null || title.isBlank())
+			throw iaex("ModalDef title must not be null or blank.");
+		var m = new ModalDef();
+		m.title = title;
+		return m;
+	}
+
+	/**
+	 * Adds one typed confirmation field.
+	 *
+	 * @param label The field label.  Must not be <jk>null</jk> or blank.
+	 * @param value The field value (read back from the current record).  Can be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ModalDef field(String label, String value) {
+		if (fields == null)
+			fields = l();
+		fields.add(Field.of(label, value));
+		return this;
+	}
+
+	/**
+	 * Sets the optional input form.
+	 *
+	 * @param value The form.  Can be <jk>null</jk> to unset (a confirm-only modal).
+	 * @return This object.
+	 */
+	public ModalDef form(FormDef value) {
+		form = value;
+		return this;
+	}
+
+	/**
+	 * Sets the server-minted idempotency key value carried on the submit (see {@link IdempotencyKey}).
+	 *
+	 * @param value The key value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public ModalDef idempotencyKey(String value) {
+		idempotencyKey = value;
+		return this;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
index a7f9a1d727..937544d04b 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
@@ -119,11 +119,14 @@ public class PageDef {
 	 * Finalizes the builder, validating the composed tab tree, and returns the wire-ready {@link PageDef}.
 	 *
 	 * <p>
-	 * Validation (design doc §"Bean model"): at least one tab must be declared; every {@link Tab} must declare
-	 * exactly one of {@link Tab#view}/{@link Tab#subtabs} (delegated to {@link Tab#validate()}, which also checks
-	 * sub-tab id uniqueness <i>within</i> that tab); every {@link Tab#id} must be unique across the page; and every
-	 * referenced {@link ViewDef#id} (from a leaf tab's view or any subtab's view) must be unique across the whole
-	 * page, so hash routing and sidecar lookup stay unambiguous.
+	 * Validation (design doc §"Bean model"; TODO-420 widens the panel-body shape from a view/subtabs
+	 * exclusive-or to the matrix documented on {@link Tab}/{@link Subtab}): at least one tab must be declared;
+	 * every {@link Tab} must satisfy its own panel-body matrix and every declared {@link Subtab} its own
+	 * (delegated to {@link Tab#validate()}, which also checks sub-tab id uniqueness <i>within</i> that tab); every
+	 * {@link Tab#id} must be unique across the page; and every referenced {@link ViewDef#id} (from a leaf tab's
+	 * view or any subtab's view &mdash; a tab or subtab carrying {@link Tab#content}/{@link Subtab#content}
+	 * instead references no {@link ViewDef} and contributes nothing here) must be unique across the whole page, so
+	 * hash routing and sidecar lookup stay unambiguous.
 	 *
 	 * @return This object.
 	 * @throws IllegalArgumentException On any validation rule violation.
@@ -142,12 +145,12 @@ public class PageDef {
 			t.validate();
 			if (!tabIds.add(t.id))
 				throw iaex("PageDef '%s': duplicate tab id '%s'.", id, t.id);
-			if (t.view != null) {
+			if (t.view != null)
 				addViewId(viewIds, t.view.id);
-			} else {
+			if (t.subtabs != null)
 				for (var s : t.subtabs)
-					addViewId(viewIds, s.view.id);
-			}
+					if (s.view != null)
+						addViewId(viewIds, s.view.id);
 		}
 	}
 
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
index 7e0dbb56dd..6d902cf500 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
@@ -79,7 +79,27 @@ import org.apache.juneau.marshall.marshaller.*;
  * <p>
  * A light-weight tab tree (id/label/subtabs only &mdash; no embedded {@code ViewDef} content, since the page
  * runtime's lazy-init binding layer locates a panel's {@code table[data-juneau-view]} elements directly via the DOM
- * rather than via PAGE_META): <c>{contractVersion, id, tabs:[{id, label, subtabs?:[{id, label}]}]}</c>.
+ * rather than via PAGE_META): <c>{contractVersion, id, tabs:[{id, label, subtabs?:[{id, label}]}]}</c>. This
+ * projection is built by {@link #buildMeta(PageDef)}, which copies only the fields above from each {@link Tab}/
+ * {@link Subtab} &mdash; {@link Tab#content}/{@link Subtab#content} (TODO-420) are never read by it and so
+ * structurally cannot reach PAGE_META, regardless of what {@code @BeanType} pins on {@link Tab}/{@link Subtab};
+ * this is a stronger guarantee than reserve-and-omit (there is nothing to omit because there is no code path that
+ * copies it), and it is why the field is also simply absent from {@link Tab}/{@link Subtab}'s own
+ * {@code @BeanType} property lists, so the framework's wire contract (and {@link PageDef#CONTRACT_VERSION}) needs
+ * no bump to add it.
+ *
+ * <h5 class='section'>Panel-body content (TODO-420): template engine, trusted / first-party content only</h5>
+ * <p>
+ * {@link Tab#content}/{@link Subtab#content} let a panel hold raw markup instead of (or, for {@link Tab#content},
+ * above) a {@code ViewDef}-backed table &mdash; see the panel-body matrices on {@link Tab}/{@link Subtab}. This
+ * emitter writes that markup <b>verbatim</b>, via {@code rawText(...)}, exactly like the PAGE_META/VIEW_META JSON
+ * sidecars use {@code rawText(...)} for their own (separately escaped) payloads. Unlike the sidecars, {@code
+ * content} passes through with <b>no</b> escaping of any kind &mdash; the framework is a template engine on this
+ * path: the caller pre-sanitizes, this method emits verbatim. {@code content} is for trusted, first-party prose
+ * only (the FG-2 docs-page use case) and MUST NEVER carry live/remote/attacker-influenceable data; a write-path
+ * confirmation/detail body must use a typed/escaped path instead. A build-gating scanner
+ * ({@code RawContentSinkScanner}, this module's test tree) enforces that this framework's own sources never pour a
+ * non-literal (i.e. plausibly live-data-derived) value into {@link Tab#content}/{@link Subtab#content}.
  *
  * <h5 class='section'>See Also:</h5>
  * <ul>
@@ -205,7 +225,11 @@ public class PageTable {
 		return div(tabBar, panels, sidecar).id(id).attr(MARKER_ATTR, id).class_(PAGE_CLASS);
 	}
 
-	/** Builds one top-level tab's panel: either a leaf view panel, or a sub-tab bar + one sub-panel per subtab. */
+	/**
+	 * Builds one top-level tab's panel: a leaf view panel, a leaf raw-content panel, or a (optionally
+	 * content-prefaced) sub-tab bar + one sub-panel per subtab &mdash; the {@code Tab} panel-body matrix
+	 * ({@code {view} | {subtabs} | {content} | {content+subtabs}}, TODO-420) mirrored from {@link Tab#validate()}.
+	 */
 	private static Div buildTabPanel(MarshallingContext ctx, String pageId, Tab t) {
 		if (t.view != null) {
 			var body = ViewTable.of(ctx, t.view, null);
@@ -214,6 +238,11 @@ public class PageTable {
 
 		var subtabs = t.subtabs == null ? List.<Subtab>of() : t.subtabs;
 
+		if (subtabs.isEmpty())
+			// Leaf content-only tab (Tab = {content}): the panel body IS the raw content, emitted verbatim (see
+			// Tab#content's ownership contract - caller sanitizes, this emits as-is).
+			return div(rawText(t.content)).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
+
 		var subtabBarChildren = new ArrayList<>();
 		for (var s : subtabs)
 			subtabBarChildren.add(
@@ -225,7 +254,8 @@ public class PageTable {
 
 		var subpanelsChildren = new ArrayList<>();
 		for (var s : subtabs) {
-			var body = ViewTable.of(ctx, s.view, null);
+			// Subtab = {view} | {content} (Subtab#validate()): exactly one of these is non-null here.
+			Object body = s.view != null ? ViewTable.of(ctx, s.view, null) : rawText(s.content);
 			subpanelsChildren.add(
 				div(body).class_(SUBPANEL_CLASS).attr(PANEL_TAB_ATTR, t.id).attr(PANEL_SUBTAB_ATTR, s.id));
 		}
@@ -234,7 +264,16 @@ public class PageTable {
 		// Tab-scoped only, on purpose: this panel wraps the sub-tab bar and must be visible for EVERY sub-tab, so it
 		// stays sub-tab-agnostic (see the panel markup contract in this class's javadoc).  Do not add
 		// data-panel-subtab here - it would pin the whole tab to a single sub-tab and blank it for the others.
-		return div(subtabBar, subpanels).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
+		//
+		// The optional Tab#content preamble (Tab = {content+subtabs}) is emitted first, inside this SAME outer
+		// panel - so it renders above the sub-tab bar and stays visible for every sub-tab exactly like the bar
+		// itself, with no separate routing attribute (design doc open question 3).
+		var outerChildren = new ArrayList<>();
+		if (t.content != null)
+			outerChildren.add(rawText(t.content));
+		outerChildren.add(subtabBar);
+		outerChildren.add(subpanels);
+		return div(outerChildren.toArray()).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
 	}
 
 	/** Builds the deep-linkable hash href: {@code #pageId/tabId} or {@code #pageId/tabId/subtabId}. */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
new file mode 100644
index 0000000000..2fbd46afbe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
@@ -0,0 +1,320 @@
+/*
+ * 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 org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A per-row action descriptor in the {@code VIEW_META} wire contract (design doc §6.10; the transport-agnostic
+ * "row-action intent" model of {@code TODO-399} Decision&nbsp;8).
+ *
+ * <p>
+ * A {@link ViewDef} declares zero or more of these via {@link ViewDef#rowActions(RowAction...)}; the
+ * {@code juneau-views.js} runtime renders each as a row-menu item and, on activation, submits the declared
+ * {@link #endpoint} with the declared {@link #method} as a JSON request carrying the process's CSRF token.
+ *
+ * <h5 class='section'>The frozen wire schema</h5>
+ * <p>
+ * This is the <b>complete</b> set of wire fields; it is pinned in full now so that later waves (the declarative
+ * modal/form + typed result of {@code TODO-416}) can rely on it verbatim without forcing another fail-loud
+ * contract bump.  Only {@link #id} is required; every other field is optional and is omitted from the serialized
+ * form when unset (null-valued properties are dropped).
+ * <table class='styled'>
+ * 	<tr><th>Field</th><th>Type / tokens</th><th>Meaning</th></tr>
+ * 	<tr><td>{@code id}</td><td>string</td><td>Stable action id (menu-item key; also the submit's logical name).</td></tr>
+ * 	<tr><td>{@code label}</td><td>string</td><td>The menu-item text.</td></tr>
+ * 	<tr><td>{@code icon}</td><td>string</td><td>Optional glyph name (resolved by the shared icon registry).</td></tr>
+ * 	<tr><td>{@code endpoint}</td><td>string</td><td>The URL the action submits to.</td></tr>
+ * 	<tr><td>{@code method}</td><td>{@code POST}|{@code PUT}|{@code PATCH}|{@code DELETE}</td>
+ * 		<td>The non-safe HTTP method (see {@link Method}).</td></tr>
+ * 	<tr><td>{@code confirm}</td><td>string</td><td>Optional confirmation prompt shown before the submit.</td></tr>
+ * 	<tr><td>{@code form}</td><td>string</td><td>Optional form-source URL supplying the action's input fields
+ * 		(the declarative form {@code TODO-416} renders).</td></tr>
+ * 	<tr><td>{@code present}</td><td>{@code page}|{@code dialog}|{@code inline}</td>
+ * 		<td>How the action's form/confirmation is presented (see {@link Present}).</td></tr>
+ * 	<tr><td>{@code onSuccess}</td><td>{@code redraw}|{@code mergeRow}|{@code navigate}</td>
+ * 		<td>What the runtime does with a successful result (see {@link OnSuccess}).</td></tr>
+ * </table>
+ *
+ * <h5 class='section'>Why {@link #method} cannot be a safe method</h5>
+ * <p>
+ * {@link Method} enumerates only {@code POST}/{@code PUT}/{@code PATCH}/{@code DELETE}, so a safe (CSRF-exempt)
+ * method cannot be declared at all &mdash; a build-time refusal by construction.  This mirrors the server boundary:
+ * {@link org.apache.juneau.rest.server.MethodSafety} fails any {@code @Mutating} operation bound to a safe method at
+ * startup, and {@link org.apache.juneau.rest.server.filter.LoopbackBoundary} applies its Origin/CSRF/JSON checks
+ * only to non-safe methods.  A mutating action bound to {@code GET} would be a CSRF-able write; the type system
+ * forbids expressing one.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	RowAction <jv>ack</jv> = RowAction.<jsm>create</jsm>(<js>"ack"</js>)
+ * 		.label(<js>"Acknowledge"</js>)
+ * 		.endpoint(<js>"servlet:/incidents/{id}/ack"</js>)
+ * 		.method(RowAction.Method.<jsf>POST</jsf>)
+ * 		.confirm(<js>"Acknowledge this incident?"</js>)
+ * 		.present(RowAction.Present.<jsf>DIALOG</jsf>)
+ * 		.onSuccess(RowAction.OnSuccess.<jsf>MERGE_ROW</jsf>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,label,icon,endpoint,method,confirm,form,present,onSuccess")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class RowAction {
+
+	/**
+	 * The non-safe HTTP method a row action submits with.
+	 *
+	 * <p>
+	 * Deliberately limited to the four non-safe methods.  A safe method (GET/HEAD/OPTIONS/TRACE) skips the
+	 * Origin/CSRF/JSON checks at {@link org.apache.juneau.rest.server.filter.LoopbackBoundary}, so a mutating
+	 * action bound to one would be CSRF-able; this enum makes that unexpressible.  Each constant's wire token is
+	 * its uppercase name, which is exactly what the client sends as the request method.
+	 */
+	public enum Method {
+
+		/** HTTP {@code POST}. */
+		POST,
+
+		/** HTTP {@code PUT}. */
+		PUT,
+
+		/** HTTP {@code PATCH}. */
+		PATCH,
+
+		/** HTTP {@code DELETE}. */
+		DELETE;
+
+		/**
+		 * Returns the wire token for this method (its uppercase name).
+		 *
+		 * @return The wire token (e.g. {@code "POST"}).
+		 */
+		public String wire() {
+			return name();
+		}
+	}
+
+	/**
+	 * How a row action's form/confirmation is presented.
+	 *
+	 * <p>
+	 * Each constant carries the lowercase wire token emitted for the {@code present} field.  The behavior behind
+	 * each token is implemented by a later wave ({@code TODO-416}); this type freezes the vocabulary.
+	 */
+	public enum Present {
+
+		/** Navigate to / render the action's form as a full page. */
+		PAGE("page"),
+
+		/** Render the action's form/confirmation in a modal dialog. */
+		DIALOG("dialog"),
+
+		/** Render the action inline within the row. */
+		INLINE("inline");
+
+		private final String wire;
+
+		Present(String wire) {
+			this.wire = wire;
+		}
+
+		/**
+		 * Returns the lowercase wire token for this presentation.
+		 *
+		 * @return The wire token (e.g. {@code "dialog"}).
+		 */
+		public String wire() {
+			return wire;
+		}
+	}
+
+	/**
+	 * What the runtime does with a successful action result.
+	 *
+	 * <p>
+	 * Each constant carries the wire token emitted for the {@code onSuccess} field.  The behavior behind each
+	 * token is implemented by a later wave ({@code TODO-416}/{@code TODO-417}); this type freezes the vocabulary.
+	 */
+	public enum OnSuccess {
+
+		/** Redraw (reload) the whole table. */
+		REDRAW("redraw"),
+
+		/** Merge the returned row into the table in place. */
+		MERGE_ROW("mergeRow"),
+
+		/** Navigate away using the result. */
+		NAVIGATE("navigate");
+
+		private final String wire;
+
+		OnSuccess(String wire) {
+			this.wire = wire;
+		}
+
+		/**
+		 * Returns the wire token for this success behavior.
+		 *
+		 * @return The wire token (e.g. {@code "mergeRow"}).
+		 */
+		public String wire() {
+			return wire;
+		}
+	}
+
+	/** The stable action id (menu-item key; also the submit's logical name). */
+	public String id;
+
+	/** The menu-item text. */
+	public String label;
+
+	/** Optional glyph/icon name (resolved by the shared icon registry). */
+	public String icon;
+
+	/** The URL this action submits to. */
+	public String endpoint;
+
+	/** The non-safe HTTP method wire token (see {@link Method#wire()}). */
+	public String method;
+
+	/** Optional confirmation prompt shown before the submit. */
+	public String confirm;
+
+	/** Optional form-source URL supplying the action's input fields. */
+	public String form;
+
+	/** Optional presentation hint wire token (see {@link Present#wire()}). */
+	public String present;
+
+	/** Optional success-behavior hint wire token (see {@link OnSuccess#wire()}). */
+	public String onSuccess;
+
+	/**
+	 * Starts a new {@link RowAction} with the specified stable action id.
+	 *
+	 * @param id The stable action id.  Must not be <jk>null</jk> or blank.
+	 * @return A new mutable {@link RowAction} to chain builder calls on.
+	 * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank.
+	 */
+	public static RowAction create(String id) {
+		if (id == null || id.isBlank())
+			throw iaex("RowAction id must not be null or blank.");
+		var a = new RowAction();
+		a.id = id;
+		return a;
+	}
+
+	/**
+	 * Sets the menu-item text.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RowAction label(String value) {
+		label = value;
+		return this;
+	}
+
+	/**
+	 * Sets the glyph/icon name.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RowAction icon(String value) {
+		icon = value;
+		return this;
+	}
+
+	/**
+	 * Sets the URL this action submits to.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RowAction endpoint(String value) {
+		endpoint = value;
+		return this;
+	}
+
+	/**
+	 * Sets the non-safe HTTP method this action submits with.
+	 *
+	 * <p>
+	 * Only a {@link Method} (POST/PUT/PATCH/DELETE) can be passed, so a safe method can never be declared &mdash;
+	 * the build-time half of the HIGH-7 safe-method refusal.
+	 *
+	 * @param value The method.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public RowAction method(Method value) {
+		method = value.wire();
+		return this;
+	}
+
+	/**
+	 * Sets the confirmation prompt shown before the submit.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RowAction confirm(String value) {
+		confirm = value;
+		return this;
+	}
+
+	/**
+	 * Sets the form-source URL supplying the action's input fields.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RowAction form(String value) {
+		form = value;
+		return this;
+	}
+
+	/**
+	 * Sets the presentation hint.
+	 *
+	 * @param value The presentation.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public RowAction present(Present value) {
+		present = value.wire();
+		return this;
+	}
+
+	/**
+	 * Sets the success-behavior hint.
+	 *
+	 * @param value The success behavior.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public RowAction onSuccess(OnSuccess value) {
+		onSuccess = value.wire();
+		return this;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SelectionDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SelectionDef.java
new file mode 100644
index 0000000000..5c76d74edd
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SelectionDef.java
@@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.juneau.commons.utils.Shorts.*;
+
+/**
+ * The row-selection opt-in for a {@link ViewTable} &mdash; the first of {@code TODO-428}'s two INDEPENDENT
+ * opt-ins (design doc §9.3/§6.2; HIGH-5).
+ *
+ * <h5 class='section'>Independent from bulk mutation, by construction</h5>
+ * <p>
+ * Declaring a {@link SelectionDef} adds a per-row checkbox column (and, when {@link #selectAll()} is set, a
+ * select-all header checkbox) to a view table &mdash; nothing else. It does <b>not</b>, by any default or
+ * convenience, surface a bulk-mutate control: {@link ViewTable}'s selection-only overloads
+ * ({@link ViewTable#of(ViewDef, java.util.Collection, SelectionDef)}, and the request-bearing sibling) have no code
+ * path that can render one. A view can therefore declare "selectable, for export" while categorically excluding
+ * bulk mutation &mdash; e.g. a PagerDuty-style incident table that wants selection for a client-side export/copy
+ * action but must never expose a multi-row mutate. A {@link BulkMutateDef} is a separate, independent type that
+ * <b>consumes</b> a {@link SelectionDef} (it requires one to be constructed at all) but a {@link SelectionDef}
+ * never requires, implies, or references one back.
+ *
+ * <h5 class='section'>Non-wire: never touches {@code VIEW_META} or {@link ViewDef#CONTRACT_VERSION}</h5>
+ * <p>
+ * A {@link SelectionDef} is deliberately <b>not</b> a bean field on {@link ViewDef} and never reaches the
+ * {@code VIEW_META} JSON sidecar or its {@link ViewDef#CONTRACT_VERSION}. {@link ViewTable} instead stamps it as
+ * plain HTML attributes directly on the emitted {@code <table>} &mdash; {@link ViewTable#SELECT_ATTR},
+ * {@link ViewTable#ROW_ID_FIELD_ATTR}, {@link ViewTable#SELECT_ALL_ATTR} &mdash; mirroring the existing
+ * {@link ViewTable#CSRF_ATTR} auto-embed pattern. The {@code juneau-views.js} runtime reads them straight off the
+ * DOM, so a table with no {@link SelectionDef} carries none of these attributes and renders no selection UI at
+ * all; there is no shared, independently-versioned wire contract to keep in lockstep (contrast
+ * {@link BulkMutateDef}, whose actual bulk-action list <i>is</i> wire data and therefore carries its own,
+ * separate {@link BulkMutateDef#CONTRACT_VERSION}).
+ *
+ * <h5 class='section'>Selection identity is a stable row id, never a DOM/table index (MED-11)</h5>
+ * <p>
+ * {@link #rowIdField()} names the row-data key {@code juneau-views.js} reads to stamp a stable
+ * {@code data-juneau-row-id} attribute on each rendered {@code <tr>} (see {@code createdRow} in that runtime).
+ * Selection state is keyed by that value, never by a row's position in the DOM or in the DataTables draw &mdash;
+ * so a poll ({@code TODO-426}), a sort, or a page change can never retarget a selection (or a bulk mutate driven
+ * from one) onto whichever rows now occupy those same positions. The runtime's persistence rule drops any
+ * previously-selected id that is no longer present in the newly drawn row set, so a selection whose rows have
+ * scrolled off screen or off the current page is never kept as a live, actionable target.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	<jc>// Selectable for export, with NO bulk-mutate capability - satisfies the separability condition by construction.</jc>
+ * 	SelectionDef <jv>selection</jv> = SelectionDef.<jsm>create</jsm>(<js>"id"</js>);
+ * 	Div <jv>markup</jv> = ViewTable.<jsm>of</jsm>(<jv>req</jv>, <jv>viewDef</jv>, <jv>rows</jv>, <jv>selection</jv>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link BulkMutateDef}
+ * 	<li class='jc'>{@link ViewTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class SelectionDef {
+
+	private final String rowIdField;
+	private boolean selectAll = true;
+
+	private SelectionDef(String rowIdField) {
+		this.rowIdField = rowIdField;
+	}
+
+	/**
+	 * Starts a new {@link SelectionDef} bound to the specified stable row-id field.
+	 *
+	 * @param rowIdField The row-data key whose value is this row's stable identity (e.g. {@code "id"} or
+	 * 	{@code "incidentId"}). Must not be <jk>null</jk> or blank.
+	 * @return A new {@link SelectionDef}, with {@link #selectAll()} defaulting to <jk>true</jk>.
+	 * @throws IllegalArgumentException If {@code rowIdField} is <jk>null</jk> or blank.
+	 */
+	public static SelectionDef create(String rowIdField) {
+		if (rowIdField == null || rowIdField.isBlank())
+			throw iaex("SelectionDef rowIdField must not be null or blank.");
+		return new SelectionDef(rowIdField);
+	}
+
+	/**
+	 * Sets whether a select-all header checkbox is rendered alongside the per-row checkboxes.
+	 *
+	 * <p>
+	 * Select-all always applies only to the rows currently on screen (the current page's draw) &mdash; it can
+	 * never reach into rows on another page, which would be indistinguishable from the off-screen-retarget risk
+	 * this whole opt-in exists to close.
+	 *
+	 * @param value The new value. Defaults to <jk>true</jk>.
+	 * @return This object.
+	 */
+	public SelectionDef selectAll(boolean value) {
+		selectAll = value;
+		return this;
+	}
+
+	/**
+	 * The row-data key used as this table's stable row identity.
+	 *
+	 * @return The row-id field name.
+	 */
+	public String rowIdField() {
+		return rowIdField;
+	}
+
+	/**
+	 * Whether a select-all header checkbox is rendered.
+	 *
+	 * @return <jk>true</jk> if select-all is enabled.
+	 */
+	public boolean selectAll() {
+		return selectAll;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
index ec43b576f9..185f14f1d3 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
@@ -21,18 +21,27 @@ import static org.apache.juneau.commons.utils.Shorts.*;
 import org.apache.juneau.commons.bean.*;
 
 /**
- * A single sub-tab within a {@link Tab} that has {@link Tab#subtabs}, referencing exactly one child {@link ViewDef}
- * (design doc §"Bean model").
+ * A single sub-tab within a {@link Tab} that has {@link Tab#subtabs}, referencing exactly one of a child
+ * {@link ViewDef} or raw panel {@link #content} (design doc §"Bean model"; TODO-420).
  *
  * <p>
  * A {@link Subtab} carries a stable {@code id} (the third hash segment, {@code #pageId/tabId/<subtabId>}) and
- * {@code label} (the sub-tab bar button text), plus the referenced {@link #view}.  Built via
- * {@link #create(String, String)} + {@link #view(ViewDef)}, mirroring the Phase B builder ergonomics.
+ * {@code label} (the sub-tab bar button text), plus <b>exactly one</b> of {@link #view} or {@link #content}
+ * (matrix: {@code Subtab = {view} | {content}}) &mdash; enforced by {@link #validate()}. Built via
+ * {@link #create(String, String)} + {@link #view(ViewDef)}/{@link #content(String)}, mirroring the Phase B builder
+ * ergonomics.
+ *
+ * <h5 class='section'>{@code content}'s ownership contract: template engine, trusted / first-party content only</h5>
+ * <p>
+ * See {@link #content} for the full contract. In short: this framework is a template engine on this path &mdash;
+ * the <b>caller pre-sanitizes</b>, the framework emits {@link #content} <b>verbatim</b>, unescaped. It exists for
+ * first-party, trusted prose (the FG-2 docs-page use case), never for live/remote/attacker-influenceable data.
  *
  * <h5 class='section'>See Also:</h5>
  * <ul>
  * 	<li class='jc'>{@link PageDef}
  * 	<li class='jc'>{@link Tab}
+ * 	<li class='jc'>{@link PageTable}
  * </ul>
  *
  * @since 10.0.0
@@ -47,9 +56,21 @@ public class Subtab {
 	/** The sub-tab bar button text. */
 	public String label;
 
-	/** The referenced child view. */
+	/** The referenced child view (mutually exclusive with {@link #content}). */
 	public ViewDef view;
 
+	/**
+	 * Raw panel-body markup for this sub-tab (mutually exclusive with {@link #view}).  Emitted <b>verbatim</b> by
+	 * {@link PageTable#of(PageDef)} via the html5 {@code rawText(...)} primitive.
+	 *
+	 * <h5 class='section'>Ownership contract: template engine, trusted / first-party content only</h5>
+	 * <p>
+	 * Identical contract to {@link Tab#content}: the <b>caller sanitizes; the framework emits verbatim</b>.
+	 * {@code content} MUST carry trusted, first-party content only, and MUST NOT carry
+	 * live/remote/attacker-influenceable data. See {@link Tab#content} for the full threat-model writeup.
+	 */
+	public String content;
+
 	/**
 	 * Starts a new {@link Subtab} builder with the specified stable id and display label.
 	 *
@@ -76,4 +97,29 @@ public class Subtab {
 		view = value;
 		return this;
 	}
+
+	/**
+	 * Sets the raw panel-body markup (see {@link #content} for the full ownership contract &mdash; trusted /
+	 * first-party content only, emitted verbatim).
+	 *
+	 * @param value The raw markup.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public Subtab content(String value) {
+		content = value;
+		return this;
+	}
+
+	/**
+	 * Validates this sub-tab in isolation (TODO-420): exactly one of {@link #view} or {@link #content} must be set
+	 * (matrix: {@code Subtab = {view} | {content}}).
+	 *
+	 * @throws IllegalArgumentException On any rule violation.
+	 */
+	void validate() {
+		var hasView = view != null;
+		var hasContent = content != null;
+		if (hasView == hasContent)
+			throw iaex("Subtab '%s' must declare exactly one of view or content.", id);
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
index b2271b4b76..f828d7232d 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
@@ -23,18 +23,36 @@ import java.util.*;
 import org.apache.juneau.commons.bean.*;
 
 /**
- * A single top-level tab within a {@link PageDef}, referencing either one child {@link ViewDef} directly or an
- * ordered list of {@link Subtab} (design doc §"Bean model").
+ * A single top-level tab within a {@link PageDef}, referencing one child {@link ViewDef}, an ordered list of
+ * {@link Subtab}, raw panel {@link #content}, or {@link #content} paired with {@link #subtabs} as a preamble
+ * (design doc §"Bean model"; TODO-420).
  *
  * <p>
  * A {@link Tab} carries a stable {@code id} (the second hash segment, {@code #pageId/<tabId>/...}) and {@code label}
- * (the tab-bar button text), plus <b>exactly one</b> of {@link #view} or {@link #subtabs} &mdash; enforced by
- * {@link PageDef#build()}, not by the individual setters, so builder call order stays unconstrained.
+ * (the tab-bar button text), plus a panel body governed by the following matrix &mdash; enforced by
+ * {@link #validate()} (called from {@link PageDef#build()}, not by the individual setters, so builder call order
+ * stays unconstrained):
+ *
+ * <h5 class='section'>Panel-body matrix</h5>
+ * <p>
+ * <b>{@code Tab = {view} | {subtabs} | {content} | {content+subtabs}}</b> &mdash; exactly one of {@link #view} /
+ * {@link #subtabs} / {@link #content}, <b>except</b> that {@link #content} may co-occur with {@link #subtabs}: in
+ * that combination {@link #content} is a preamble rendered above the sub-tab bar, inside this tab's outer panel
+ * (visible for every sub-tab, no new routing attribute &mdash; see {@link PageTable}'s panel markup contract). This
+ * is deliberately not a clean three-way exclusive-or; {@link #view} never combines with anything, but
+ * {@link #content} and {@link #subtabs} do.
+ *
+ * <h5 class='section'>{@code content}'s ownership contract: template engine, trusted / first-party content only</h5>
+ * <p>
+ * See {@link #content} for the full contract. In short: this framework is a template engine on this path &mdash;
+ * the <b>caller pre-sanitizes</b>, the framework emits {@link #content} <b>verbatim</b>, unescaped. It exists for
+ * first-party, trusted prose (the FG-2 docs-page use case), never for live/remote/attacker-influenceable data.
  *
  * <h5 class='section'>See Also:</h5>
  * <ul>
  * 	<li class='jc'>{@link PageDef}
  * 	<li class='jc'>{@link Subtab}
+ * 	<li class='jc'>{@link PageTable}
  * </ul>
  *
  * @since 10.0.0
@@ -49,12 +67,41 @@ public class Tab {
 	/** The tab-bar button text. */
 	public String label;
 
-	/** The referenced child view (mutually exclusive with {@link #subtabs}). */
+	/** The referenced child view (mutually exclusive with {@link #subtabs} and {@link #content}). */
 	public ViewDef view;
 
-	/** The ordered sub-tabs (mutually exclusive with {@link #view}). */
+	/** The ordered sub-tabs (mutually exclusive with {@link #view}; may co-occur with {@link #content}). */
 	public List<Subtab> subtabs;
 
+	/**
+	 * Raw panel-body markup for this tab (mutually exclusive with {@link #view}; may co-occur with {@link #subtabs}
+	 * as a preamble rendered above the sub-tab bar &mdash; see the panel-body matrix in this class's javadoc).
+	 * Emitted <b>verbatim</b> &mdash; unescaped &mdash; by {@link PageTable#of(PageDef)} via the html5
+	 * {@code rawText(...)} primitive (a plain {@code String} child would instead be entity-escaped and would break
+	 * markup-bearing prose).
+	 *
+	 * <h5 class='section'>Ownership contract: template engine, trusted / first-party content only</h5>
+	 * <p>
+	 * This framework is a <b>template engine</b> on this path: <b>the caller is responsible for sanitizing
+	 * {@code content} before setting it; the framework performs no sanitization and emits the string exactly as
+	 * given.</b> Nothing in this framework neutralizes {@code <script>}, {@code <style>}, inline event handlers, or
+	 * {@code url(...)} sinks in this value &mdash; {@link org.apache.juneau.commons.utils.StringUtils#escapeForScript
+	 * StringUtils.escapeForScript} is JSON-in/JSON-out and is the wrong tool for an HTML body, and no CSS/script
+	 * scanner in this framework inspects consumer-supplied markup.
+	 *
+	 * <p>
+	 * Accordingly, {@code content} MUST carry <b>trusted, first-party content only</b> &mdash; markup the
+	 * application itself authored or rendered from a trusted, non-attacker-controlled source (e.g. hand-written
+	 * page prose, or HTML rendered from a first-party markdown document). {@code content} MUST NOT carry
+	 * live/remote/attacker-influenceable data (a request parameter, a third-party API response, any value derived
+	 * from something an untrusted party can influence). Pouring such data into this sink is stored XSS in the
+	 * page's own trusted origin. A write-path confirmation/detail body built from live data must use a typed/escaped
+	 * path instead (an html5 bean tree, or {@code escapeHtml}), never this field &mdash; a build-gating scanner
+	 * enforces that separation for this framework's own sources (see the {@code RawContentSinkScanner} test-only
+	 * guard in this module's test tree).
+	 */
+	public String content;
+
 	/**
 	 * Starts a new {@link Tab} builder with the specified stable id and display label.
 	 *
@@ -94,23 +141,43 @@ public class Tab {
 	}
 
 	/**
-	 * Validates this tab in isolation (design doc §"Bean model" validation rules): exactly one of {@link #view}/
-	 * {@link #subtabs} must be set, and sub-tab ids must be unique <b>within this tab</b> (a sub-tab id may safely
-	 * recur under a different tab &mdash; the hash's {@code tabId} segment already disambiguates the parent before
-	 * {@code subtabId} is ever resolved).
+	 * Sets the raw panel-body markup (see {@link #content} for the full ownership contract &mdash; trusted /
+	 * first-party content only, emitted verbatim).
+	 *
+	 * @param value The raw markup.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public Tab content(String value) {
+		content = value;
+		return this;
+	}
+
+	/**
+	 * Validates this tab in isolation (design doc §"Bean model" validation rules; TODO-420 widens this from a
+	 * three-way exclusive-or to the panel-body matrix documented in this class's javadoc):
+	 * {@code {view} | {subtabs} | {content} | {content+subtabs}}. {@link #view} is exclusive of everything else;
+	 * {@link #content} and {@link #subtabs} may co-occur (the preamble case). Every declared sub-tab is also
+	 * validated in isolation (see {@link Subtab#validate()}), and sub-tab ids must be unique <b>within this tab</b>
+	 * (a sub-tab id may safely recur under a different tab &mdash; the hash's {@code tabId} segment already
+	 * disambiguates the parent before {@code subtabId} is ever resolved).
 	 *
 	 * @throws IllegalArgumentException On any rule violation.
 	 */
 	void validate() {
 		var hasView = view != null;
 		var hasSubtabs = subtabs != null && !subtabs.isEmpty();
-		if (hasView == hasSubtabs)
-			throw iaex("Tab '%s' must declare exactly one of view or subtabs.", id);
+		var hasContent = content != null;
+		if (hasView) {
+			if (hasSubtabs || hasContent)
+				throw iaex("Tab '%s' must declare exactly one of view, subtabs, or content "
+					+ "(view cannot combine with subtabs or content).", id);
+		} else if (!hasSubtabs && !hasContent) {
+			throw iaex("Tab '%s' must declare exactly one of view, subtabs, or content.", id);
+		}
 		if (hasSubtabs) {
 			var ids = new HashSet<String>();
 			for (var s : subtabs) {
-				if (s.view == null)
-					throw iaex("Subtab '%s' in tab '%s' must declare a view.", s.id, id);
+				s.validate();
 				if (!ids.add(s.id))
 					throw iaex("Tab '%s': duplicate subtab id '%s'.", id, s.id);
 			}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
index b096a36cc5..c944676831 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
@@ -35,10 +35,10 @@ import org.apache.juneau.rest.server.datatables.*;
  * sidecar the server writes and the {@code juneau-views.js} runtime consumes stay wire-stable.
  *
  * <p>
- * {@code details} (the row-details expander's field list) is implemented; see {@link #details(DetailDef...)}.
- * The remaining reserved row-action/catalog fields ({@code rowActions}, {@code catalog}) are <b>not</b> part of this
- * MVP builder and are therefore omitted from the serialized contract (design doc §6.10 reserved stubs) &mdash;
- * omitted, not emitted as {@code null}.
+ * {@code details} (the row-details expander's field list) and {@code rowActions} (the per-row action menu; see
+ * {@link #rowActions(RowAction...)}) are both implemented.  The remaining reserved catalog fields ({@code catalog},
+ * {@code format}, ...) are <b>not</b> part of this MVP builder and are therefore omitted from the serialized
+ * contract (design doc §6.10 reserved stubs) &mdash; omitted, not emitted as {@code null}.
  *
  * <h5 class='section'>Example:</h5>
  * <p class='bjava'>
@@ -56,16 +56,17 @@ import org.apache.juneau.rest.server.datatables.*;
  * 	<li class='jc'>{@link Column}
  * 	<li class='jc'>{@link RibbonAction}
  * 	<li class='jc'>{@link RowClassRule}
+ * 	<li class='jc'>{@link RowAction}
  * </ul>
  *
  * @since 10.0.0
  */
-@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules,details,pollIntervalMs")
+@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules,rowActions,details,pollIntervalMs")
 @SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
 public class ViewDef {
 
 	/** The frozen contract version.  Bumped only on a breaking wire change. */
-	public static final String CONTRACT_VERSION = "2";
+	public static final String CONTRACT_VERSION = "3";
 
 	/**
 	 * The minimum honored polling interval, in milliseconds.
@@ -238,6 +239,16 @@ public class ViewDef {
 	/** The declarative row-decorator rules. */
 	public List<RowClassRule> rowClassRules;
 
+	/**
+	 * The per-row action descriptors, in menu order; omitted from the wire when unset (no row menu).
+	 *
+	 * <p>
+	 * Each {@link RowAction} declares a mutating request the {@code juneau-views.js} runtime renders as a row-menu
+	 * item and submits with the auto-embedded CSRF token (see {@link RowAction} for the frozen wire schema and
+	 * the fail-closed submit contract).
+	 */
+	public List<RowAction> rowActions;
+
 	/**
 	 * The row-details expander's field list; omitted from the wire when unset (no expander).
 	 *
@@ -410,6 +421,22 @@ public class ViewDef {
 		return this;
 	}
 
+	/**
+	 * Declares the per-row action menu.
+	 *
+	 * <p>
+	 * Each {@link RowAction} is a mutating request the {@code juneau-views.js} runtime renders as a row-menu item
+	 * and submits with the process's CSRF token.  A {@link RowAction} can only carry a non-safe HTTP method
+	 * ({@link RowAction.Method}), so a mutating action can never be declared against a safe (CSRF-exempt) method.
+	 *
+	 * @param value The row actions, in menu order.
+	 * @return This object.
+	 */
+	public ViewDef rowActions(RowAction...value) {
+		rowActions = l(value);
+		return this;
+	}
+
 	/**
 	 * Finalizes the builder and returns the wire-ready {@link ViewDef}.
 	 *
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index 10b7ccc684..272c6fb85b 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -17,6 +17,7 @@
 package org.apache.juneau.rest.server.views;
 
 import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
 import static org.apache.juneau.commons.utils.StringUtils.escapeForScript;
 
 import java.util.*;
@@ -25,6 +26,9 @@ import org.apache.juneau.bean.html5.*;
 import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.marshall.*;
 import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.filter.*;
+
+import jakarta.servlet.http.*;
 
 /**
  * Builds the HTML delivery shell for a {@link ViewDef} &mdash; the {@code data-juneau-view} table plus the
@@ -80,6 +84,40 @@ public class ViewTable {
 	/** Prefix of the sidecar {@code <script>} element id: {@code juneau-view:<viewId>}. */
 	public static final String SIDECAR_ID_PREFIX = "juneau-view:";
 
+	/**
+	 * Attribute the auto-embedded CSRF token is stamped into on the emitted {@code <table>}.
+	 *
+	 * <p>
+	 * On an allowed request the {@link LoopbackBoundaryFilter} publishes the process's CSRF token under
+	 * {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE}; the request-bearing {@code of(...)} overloads read it and
+	 * stamp it here so the {@code juneau-views.js} row-action submit can attach it without the host having to
+	 * remember a step.  A host may also set this attribute (or a documented init call) itself as an
+	 * override/fallback.  The runtime <b>fails closed</b> when this attribute is absent, empty, or whitespace: it
+	 * visibly refuses to issue any row-action request rather than sending one the server would 403.
+	 */
+	public static final String CSRF_ATTR = "data-juneau-csrf";
+
+	/**
+	 * Marker attribute stamped when a {@link SelectionDef} is declared ({@code TODO-428}). Pure DOM signaling
+	 * &mdash; never part of the {@code VIEW_META} wire contract; see {@link SelectionDef}'s class javadoc.
+	 */
+	public static final String SELECT_ATTR = "data-juneau-select";
+
+	/** Attribute carrying {@link SelectionDef#rowIdField()} &mdash; the row-data key the runtime stamps as each row's stable id. */
+	public static final String ROW_ID_FIELD_ATTR = "data-juneau-row-id-field";
+
+	/** Attribute carrying {@code "1"}/{@code "0"} for {@link SelectionDef#selectAll()}. */
+	public static final String SELECT_ALL_ATTR = "data-juneau-select-all";
+
+	/**
+	 * Marker attribute stamped when a {@link BulkMutateDef} is declared ({@code TODO-428}); pairs with the
+	 * {@link #BULK_SIDECAR_ID_PREFIX} sidecar carrying the actual bulk-action list.
+	 */
+	public static final String BULK_ATTR = "data-juneau-bulk";
+
+	/** Prefix of the bulk-actions sidecar {@code <script>} element id: {@code juneau-view-bulk:<viewId>}. */
+	public static final String BULK_SIDECAR_ID_PREFIX = "juneau-view-bulk:";
+
 	private ViewTable() {}
 
 	/**
@@ -105,6 +143,128 @@ public class ViewTable {
 		return of(MarshallingContext.DEFAULT, viewDef, rows);
 	}
 
+	/**
+	 * Builds the view-table shell for a server-side view, auto-embedding the request's CSRF token so a declared
+	 * row action can submit with it.
+	 *
+	 * <p>
+	 * The token is read from {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} &mdash; the value the boundary filter
+	 * stamps on every allowed request &mdash; and stamped into {@link #CSRF_ATTR} on the emitted {@code <table>}.
+	 * When the request carries no such token (no boundary filter in front of this application, or the attribute is
+	 * blank), no attribute is emitted and the runtime fails closed on any row-action attempt.  This is the
+	 * auto-embed entry point of the token contract; a {@link #CSRF_ATTR} the host sets itself is the
+	 * override/fallback.
+	 *
+	 * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the token.
+	 * 	Can be <jk>null</jk> (no token embedded).
+	 * @param viewDef The built view definition.  Must not be <jk>null</jk>.
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view>} and the JSON sidecar.
+	 */
+	public static Div of(HttpServletRequest req, ViewDef viewDef) {
+		return of(MarshallingContext.DEFAULT, viewDef, null, csrfToken(req));
+	}
+
+	/**
+	 * Builds the view-table shell and renders {@code rows}, auto-embedding the request's CSRF token.
+	 *
+	 * <p>
+	 * The request-bearing counterpart of {@link #of(ViewDef, Collection)}; see {@link #of(HttpServletRequest, ViewDef)}
+	 * for the token-embed and fail-closed contract.
+	 *
+	 * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the token.
+	 * 	Can be <jk>null</jk> (no token embedded).
+	 * @param viewDef The built view definition.  Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps).  Can be <jk>null</jk> (server-side mode) or empty.
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+	 * 	JSON sidecar.
+	 */
+	public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows) {
+		return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req));
+	}
+
+	/**
+	 * Builds the view-table shell with row selection enabled (design doc §9.3; {@code TODO-428}), auto-embedding
+	 * the request's CSRF token.
+	 *
+	 * <p>
+	 * This overload has no code path that can render a bulk-mutate control &mdash; a {@link SelectionDef} alone
+	 * can only ever add per-row checkboxes (and, per {@link SelectionDef#selectAll()}, a select-all header
+	 * checkbox). Use {@link #of(HttpServletRequest, ViewDef, Collection, BulkMutateDef)} when bulk mutation is
+	 * also required (that overload requires its own {@link SelectionDef}, supplied via
+	 * {@link BulkMutateDef#create(WritePermit, SelectionDef)}).
+	 *
+	 * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the CSRF
+	 * 	token. Can be <jk>null</jk> (no token embedded).
+	 * @param viewDef The built view definition. Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+	 * @param selection The selection opt-in. Must not be <jk>null</jk> (use one of the other overloads for a table
+	 * 	with no selection).
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view data-juneau-select>}, its VIEW_META
+	 * 	sidecar, and no bulk-actions sidecar.
+	 */
+	public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows, SelectionDef selection) {
+		if (selection == null)
+			throw iaex("selection must not be null; use one of the other of(...) overloads for a table with no selection.");
+		return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), selection, null);
+	}
+
+	/**
+	 * Builds the view-table shell with row selection AND bulk mutation enabled (design doc §9.3; {@code
+	 * TODO-428}), auto-embedding the request's CSRF token.
+	 *
+	 * <p>
+	 * The selection this table renders is {@link BulkMutateDef#selection()} &mdash; the one {@code bulkMutate} was
+	 * itself constructed against ({@link BulkMutateDef#create(WritePermit, SelectionDef)} requires one) &mdash; so
+	 * there is exactly one {@link SelectionDef} in play and no way for it to disagree with what the bulk actions
+	 * target.
+	 *
+	 * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the CSRF
+	 * 	token. Can be <jk>null</jk> (no token embedded).
+	 * @param viewDef The built view definition. Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+	 * @param bulkMutate The bulk-mutate opt-in. Must not be <jk>null</jk> (use the {@link SelectionDef} overload,
+	 * 	or one of the pre-{@code TODO-428} overloads, for a table with no bulk mutation).
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view data-juneau-select data-juneau-bulk>},
+	 * 	its VIEW_META sidecar, AND the independently-versioned bulk-actions sidecar.
+	 */
+	public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows, BulkMutateDef bulkMutate) {
+		if (bulkMutate == null)
+			throw iaex("bulkMutate must not be null; use the SelectionDef overload for selection without bulk mutation.");
+		return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), bulkMutate.selection(), bulkMutate);
+	}
+
+	/**
+	 * Builds the view-table shell with row selection enabled, using the default marshalling context and no CSRF
+	 * token embed (test/no-request convenience; see {@link #of(HttpServletRequest, ViewDef, Collection, SelectionDef)}
+	 * for the auto-embedding counterpart).
+	 *
+	 * @param viewDef The built view definition. Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+	 * @param selection The selection opt-in. Must not be <jk>null</jk>.
+	 * @return A new {@link Div} as described above.
+	 */
+	public static Div of(ViewDef viewDef, Collection<?> rows, SelectionDef selection) {
+		if (selection == null)
+			throw iaex("selection must not be null; use one of the other of(...) overloads for a table with no selection.");
+		return of(MarshallingContext.DEFAULT, viewDef, rows, null, selection, null);
+	}
+
+	/**
+	 * Builds the view-table shell with row selection AND bulk mutation enabled, using the default marshalling
+	 * context and no CSRF token embed (test/no-request convenience; see
+	 * {@link #of(HttpServletRequest, ViewDef, Collection, BulkMutateDef)} for the auto-embedding counterpart).
+	 *
+	 * @param viewDef The built view definition. Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+	 * @param bulkMutate The bulk-mutate opt-in. Must not be <jk>null</jk>.
+	 * @return A new {@link Div} as described above.
+	 */
+	public static Div of(ViewDef viewDef, Collection<?> rows, BulkMutateDef bulkMutate) {
+		if (bulkMutate == null)
+			throw iaex("bulkMutate must not be null; use the SelectionDef overload for selection without bulk mutation.");
+		return of(MarshallingContext.DEFAULT, viewDef, rows, null, bulkMutate.selection(), bulkMutate);
+	}
+
 	/**
 	 * Builds the view-table shell using the specified marshalling context for bean-property cell reads.
 	 *
@@ -115,11 +275,72 @@ public class ViewTable {
 	 * 	JSON sidecar.
 	 */
 	public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows) {
+		return of(ctx, viewDef, rows, null);
+	}
+
+	/**
+	 * Builds the view-table shell, optionally stamping a pre-resolved CSRF token onto the emitted {@code <table>}.
+	 *
+	 * <p>
+	 * The shared core the request-bearing and context-only overloads delegate to.  A non-blank {@code csrfToken}
+	 * is stamped into {@link #CSRF_ATTR}; a blank or {@code null} token stamps nothing, leaving the runtime to
+	 * fail closed on any row-action attempt.
+	 *
+	 * @param ctx The marshalling context used to read bean-property cell values.  Must not be <jk>null</jk>.
+	 * @param viewDef The built view definition.  Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps).  Can be <jk>null</jk> (server-side mode) or empty.
+	 * @param csrfToken The CSRF token to embed, or <jk>null</jk>/blank to embed none.
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+	 * 	JSON sidecar.
+	 */
+	public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows, String csrfToken) {
+		return of(ctx, viewDef, rows, csrfToken, null, null);
+	}
+
+	/**
+	 * Builds the view-table shell, optionally stamping a CSRF token, a {@link SelectionDef}, and/or a
+	 * {@link BulkMutateDef} &mdash; the shared core every public overload (pre- and post-{@code TODO-428})
+	 * ultimately delegates to.
+	 *
+	 * <p>
+	 * {@code selection}/{@code bulkMutate} are independent opt-ins (design doc §9.3; HIGH-5): passing
+	 * {@code selection} alone renders per-row checkboxes and nothing else &mdash; there is no branch below that
+	 * can render a bulk-mutate control from {@code selection} alone. When {@code bulkMutate} is non-<jk>null</jk>,
+	 * its own {@link BulkMutateDef#selection()} is what selection-related markup is rendered from; a caller-passed
+	 * {@code selection} that is not that SAME instance is rejected, so the two can never silently disagree about
+	 * which rows a bulk action targets.
+	 *
+	 * @param ctx The marshalling context used to read bean-property cell values.  Must not be <jk>null</jk>.
+	 * @param viewDef The built view definition.  Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps).  Can be <jk>null</jk> (server-side mode) or empty.
+	 * @param csrfToken The CSRF token to embed, or <jk>null</jk>/blank to embed none.
+	 * @param selection The selection opt-in, or <jk>null</jk> for none. When {@code bulkMutate} is non-<jk>null</jk>,
+	 * 	must be either <jk>null</jk> or exactly {@code bulkMutate.selection()}.
+	 * @param bulkMutate The bulk-mutate opt-in, or <jk>null</jk> for none.
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, the
+	 * 	VIEW_META sidecar, and &mdash; only when {@code bulkMutate} is supplied &mdash; the independently-versioned
+	 * 	bulk-actions sidecar.
+	 * @throws IllegalArgumentException If {@code selection} and {@code bulkMutate} are both non-<jk>null</jk> but
+	 * 	{@code selection} is not {@code bulkMutate.selection()}.
+	 */
+	public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows, String csrfToken,
+			SelectionDef selection, BulkMutateDef bulkMutate) {
+		if (bulkMutate != null) {
+			if (selection != null && selection != bulkMutate.selection())
+				throw iaex("selection must be exactly bulkMutate.selection() when both are supplied; "
+					+ "a BulkMutateDef can only render the SelectionDef it was constructed against.");
+			selection = bulkMutate.selection();
+		}
+
 		var id = viewDef.id;
 		var cols = viewDef.columns == null ? List.<Column>of() : viewDef.columns;
 
-		// <thead> of column titles (falling back to the data key when no title was set).
-		var headerCells = new ArrayList<>(cols.size());
+		// <thead> of column titles (falling back to the data key when no title was set); a selection opt-in
+		// prepends a dedicated, unlabeled leading header cell for the checkbox column (never sharing the leading
+		// cell with any other affordance - TODO-428 Q3/owner decision).
+		var headerCells = new ArrayList<>(cols.size() + 1);
+		if (selection != null)
+			headerCells.add(th().attr("class", "juneau-view-select-th").attr("aria-label", "Select"));
 		for (var c : cols)
 			headerCells.add(th(c.title == null ? c.data : c.title));
 
@@ -129,7 +350,9 @@ public class ViewTable {
 		if (rows != null) {
 			var bodyRows = new ArrayList<>(rows.size());
 			for (var row : rows) {
-				var cells = new ArrayList<>(cols.size());
+				var cells = new ArrayList<>(cols.size() + 1);
+				if (selection != null)
+					cells.add(td().attr("class", "juneau-view-select-cell"));
 				for (var c : cols) {
 					var v = value(ctx, row, c.data);
 					cells.add(td(v == null ? "" : v));
@@ -141,11 +364,46 @@ public class ViewTable {
 
 		var table = table(tableChildren.toArray()).id(id).attr(MARKER_ATTR, id);
 
+		// Auto-embed the CSRF token (MED-10/HIGH-1) so a row-action submit can attach it; a blank token stamps
+		// nothing, so the runtime fails closed rather than shipping an empty header the boundary would 403.
+		if (csrfToken != null && ! csrfToken.isBlank())
+			table.attr(CSRF_ATTR, csrfToken);
+
+		// Selection opt-in: pure DOM-attribute signaling (never VIEW_META/ViewDef.CONTRACT_VERSION - see
+		// SelectionDef's class javadoc). Absent entirely when selection is null, so juneau-views.js renders no
+		// checkbox column at all for an ordinary table - the separability guarantee's "off by default" half.
+		if (selection != null) {
+			table.attr(SELECT_ATTR, "1");
+			table.attr(ROW_ID_FIELD_ATTR, selection.rowIdField());
+			table.attr(SELECT_ALL_ATTR, selection.selectAll() ? "1" : "0");
+		}
+
 		// Sidecar: serialize the VIEW_META, neutralize script break-outs, then insert as RAW content (class javadoc).
 		var json = escapeForScript(Json.of(viewDef));
 		var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
 
-		return div(table, sidecar);
+		var children = new ArrayList<>();
+		children.add(table);
+
+		// Bulk-mutate opt-in: its OWN independently-versioned sidecar (BulkMutateDef.CONTRACT_VERSION), never
+		// merged into VIEW_META - a version bump here can never force a ViewDef.CONTRACT_VERSION bump (R2).
+		// Absent entirely when bulkMutate is null, so an ordinary or selection-only table carries no bulk affordance.
+		if (bulkMutate != null) {
+			table.attr(BULK_ATTR, "1");
+			var bulkJson = escapeForScript(Json.of(bulkMutate));
+			children.add(script().type("application/json").id(BULK_SIDECAR_ID_PREFIX + id).text(rawText(bulkJson)));
+		}
+
+		children.add(sidecar);
+		return div(children.toArray());
+	}
+
+	/** Reads the boundary-stamped CSRF token off the request, or {@code null} when absent. */
+	private static String csrfToken(HttpServletRequest req) {
+		if (req == null)
+			return null;
+		var v = req.getAttribute(LoopbackBoundaryFilter.TOKEN_ATTRIBUTE);
+		return v == null ? null : v.toString();
 	}
 
 	/** Reads a column value from a row: a direct key lookup for a {@code Map}, a bean-property read otherwise. */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/WritePermit.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/WritePermit.java
new file mode 100644
index 0000000000..8976ed4c88
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/WritePermit.java
@@ -0,0 +1,104 @@
+/*
+ * 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 org.apache.juneau.commons.utils.Shorts.*;
+
+/**
+ * The views-toolkit's own structural gate token for {@link BulkMutateDef}: proof that the caller's own write-gate
+ * was consulted before a bulk-mutating affordance is configured (design doc §9.3; {@code TODO-428}'s HIGH-5
+ * separability guarantee).
+ *
+ * <h5 class='section'>Why this exists, and why it is NOT the console's own {@code WritePermit}</h5>
+ * <p>
+ * A consuming application (e.g. the Sandbox Support Console) may already have its own, much richer write-gate
+ * token &mdash; one bound to a journal, a box {@code Mode}, an idempotency key, and a target validator (see that
+ * application's own {@code write.WritePermit}/{@code WriteGuard}). That type is deliberately <b>not</b> reused
+ * here: its constructor is package-private to its own {@code write} package, it is defined in a console-ui
+ * artifact this framework module must not depend on, and its shape carries concerns (journal binding, SAFE/LIVE
+ * mode) that are that application's business, not this framework's. Reaching into it would be a layering
+ * violation in the wrong direction (framework depending on a specific consumer).
+ *
+ * <p>
+ * This type is therefore this module's own, minimal, independent token &mdash; the same <i>discipline</i>
+ * (a required-parameter capability proof), a deliberately smaller <i>shape</i> (no journal/mode/idempotency
+ * concerns, which belong to the consumer's own gate). A consuming application's own write-gate is expected to
+ * mint one (typically by wrapping/adapting its own gate result) only after ITS OWN checks have passed, then hand
+ * it to {@link BulkMutateDef#create(WritePermit, SelectionDef)}. This module does not, and cannot, verify that the
+ * caller's gate logic is correct &mdash; that is out of scope for a UI-rendering toolkit &mdash; but it does
+ * guarantee, by construction, that {@link BulkMutateDef} cannot be reached at all without a permit in hand: there
+ * is no zero-argument overload, so omitting one is a <b>compile</b> failure, not a missed javadoc sentence
+ * (mirroring the discipline of the console's own {@code WriteGuard}, source design §5.4).
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	<jc>// After the consumer's OWN write-gate has already approved this capability:</jc>
+ * 	WritePermit <jv>permit</jv> = WritePermit.<jsm>forCapability</jsm>(<js>"incident:bulk-ack"</js>);
+ * 	BulkMutateDef <jv>bulk</jv> = BulkMutateDef.<jsm>create</jsm>(<jv>permit</jv>, <jv>selection</jv>)
+ * 		.actions(RowAction.<jsm>create</jsm>(<js>"ack"</js>).endpoint(<js>"servlet:/incidents/{id}/ack"</js>).method(RowAction.Method.<jsf>POST</jsf>));
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link BulkMutateDef}
+ * 	<li class='jc'>{@link SelectionDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class WritePermit {
+
+	private final String capability;
+
+	private WritePermit(String capability) {
+		this.capability = capability;
+	}
+
+	/**
+	 * Mints a permit for the named capability.
+	 *
+	 * <p>
+	 * This is a bare proof-of-authorization token, not an enforcement seam: minting one asserts that the CALLER's
+	 * own write-gate has already approved {@code capability}. This module trusts that assertion &mdash; it has no
+	 * journal, arming, or idempotency logic of its own to check it against &mdash; its only job is to make the
+	 * bulk-mutate opt-in unreachable without one.
+	 *
+	 * @param capability A stable, human-readable identifier for the capability this permit authorizes (e.g.
+	 * 	{@code "incident:bulk-ack"}). Must not be <jk>null</jk> or blank.
+	 * @return A new {@link WritePermit}.
+	 * @throws IllegalArgumentException If {@code capability} is <jk>null</jk> or blank.
+	 */
+	public static WritePermit forCapability(String capability) {
+		if (capability == null || capability.isBlank())
+			throw iaex("WritePermit capability must not be null or blank.");
+		return new WritePermit(capability);
+	}
+
+	/**
+	 * The capability this permit authorizes.
+	 *
+	 * @return The capability identifier passed to {@link #forCapability(String)}.
+	 */
+	public String capability() {
+		return capability;
+	}
+
+	@Override /* Object */
+	public String toString() {
+		return "WritePermit(capability=" + capability + ")";
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
index ef61550da6..6c84011944 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
@@ -54,7 +54,7 @@
 	// Contract-version handshake for PAGE_META: MUST equal PageDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION
 	// (single source of truth on the server, itself reusing ViewDef.CONTRACT_VERSION).  Distinct from - and does
 	// not replace - the per-view VIEW_META handshake juneau-views.js's initTable(...) already performs.
-	const JUNEAU_PAGE_CONTRACT_VERSION = "2";
+	const JUNEAU_PAGE_CONTRACT_VERSION = "3";
 
 	const NS = window.JuneauViews = window.JuneauViews || {};
 
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index 8494751008..9f1a8b414a 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -435,3 +435,128 @@ table.dataTable > tbody > tr > td {
 .juneau-view-detail-panel dd {
 	margin: 0 0 0.35em 42%;
 }
+
+/*
+ * Declarative row-action modal (present=dialog) + settled-outcome banner (juneau-views.js openActionDialog/
+ * buildDialogOverlay/renderActionOutcome, TODO-416/417).  NEUTRAL - shape/layout only, no palette color; a host's
+ * chrome.css MAY theme these same classes.  The confirmation body is typed fields painted with textContent (never
+ * innerHTML), so there is no markup styling here to get wrong.
+ */
+.juneau-view-dialog-backdrop {
+	position: fixed;
+	inset: 0;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	background: rgba(0, 0, 0, 0.35);
+	z-index: 1000;
+}
+
+.juneau-view-dialog {
+	min-width: 20em;
+	max-width: 90vw;
+	max-height: 90vh;
+	overflow: auto;
+	padding: 1em 1.25em;
+	border: 1px solid;
+	border-radius: 6px;
+	background: #fff;
+}
+
+.juneau-view-dialog-title {
+	margin: 0 0 0.75em;
+	font-size: 1.1em;
+}
+
+.juneau-view-dialog-fields {
+	margin: 0 0 1em;
+}
+
+.juneau-view-dialog-fields dt {
+	float: left;
+	clear: left;
+	width: 40%;
+	font-weight: 600;
+}
+
+.juneau-view-dialog-fields dd {
+	margin: 0 0 0.35em 42%;
+}
+
+.juneau-view-dialog-actions {
+	display: flex;
+	gap: 0.5em;
+	justify-content: flex-end;
+}
+
+/* The post-response settled-outcome banner (success/failure/refusal/unknown) - neutral; role drives assistive text. */
+.juneau-view-action-outcome {
+	margin-top: 0.35em;
+	font-size: 0.9em;
+}
+
+/*
+ * The LIVE async-job progress banner (TODO-425) - shown while a long job streams over SSE.  Deliberately a
+ * DISTINCT class from .juneau-view-action-outcome: a running job and a settled outcome are never the same visual
+ * state.  The row it sits in carries `data-juneau-job` (never `data-juneau-inflight`), so table polling keeps
+ * running for the whole job (HIGH-9).
+ */
+.juneau-view-job-progress {
+	margin-top: 0.35em;
+	font-size: 0.9em;
+	display: flex;
+	align-items: center;
+	gap: 0.5em;
+}
+
+.juneau-view-job-progress-msg {
+	flex: 1 1 auto;
+}
+
+.juneau-view-job-cancel {
+	flex: 0 0 auto;
+}
+
+/*
+ * Row selection + bulk mutation (TODO-428) - two INDEPENDENT opt-ins.  NEUTRAL - shape only, no palette color; a
+ * host's chrome.css MAY theme these same classes.  The selection column itself (`.juneau-view-select-th`/
+ * `.juneau-view-select-cell`) is rendered whenever a `SelectionDef` was declared, WITH or WITHOUT bulk mutation;
+ * the bulk toolbar (`.juneau-view-bulk-toolbar`) only ever exists when a `BulkMutateDef` was ALSO declared - so a
+ * selection-only (e.g. selectable-for-export) table gets the checkbox column and nothing else here.
+ */
+.juneau-view-select-th,
+.juneau-view-select-cell {
+	width: 2.25em;
+	text-align: center;
+}
+
+.juneau-view-select-checkbox,
+.juneau-view-select-all-checkbox {
+	cursor: pointer;
+}
+
+/* The bulk-actions toolbar - inserted into the unified toolbar row's LEFT cluster, after the paging pill. */
+.juneau-view-bulk-toolbar {
+	display: flex;
+	align-items: center;
+	gap: 0.5em;
+}
+
+.juneau-view-bulk-count {
+	font-size: 0.8125rem;
+	white-space: nowrap;
+}
+
+.juneau-view-bulk-action-btn {
+	padding: 0.3em 0.75em;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.25em;
+	background: transparent;
+	font-size: 0.8125rem;
+	cursor: pointer;
+}
+
+.juneau-view-bulk-action-btn:disabled {
+	opacity: 0.4;
+	cursor: not-allowed;
+}
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 12c95177ac..b5f085b4ca 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
@@ -38,10 +38,45 @@
 
 	// Contract-version handshake: MUST equal ViewDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION (single source
 	// of truth on the server).  The initializer fails loud when a sidecar's contractVersion differs.
-	const JUNEAU_VIEW_CONTRACT_VERSION = "2";
+	const JUNEAU_VIEW_CONTRACT_VERSION = "3";
+
+	// The typed action-result contract version (ActionResult.CONTRACT_VERSION on the server).  This is a SEPARATE,
+	// independently-versioned wire contract from VIEW_META - it is deliberately NOT aliased to
+	// JUNEAU_VIEW_CONTRACT_VERSION, so the row-action submit result and the view sidecar version independently.  A
+	// 2xx action-result whose contractVersion differs is rendered as a visible, non-optimistic UNKNOWN rather than
+	// silently mis-read.
+	const JUNEAU_ACTION_RESULT_CONTRACT_VERSION = "1";
+
+	/**
+	 * The bulk-mutate-actions contract version (BulkMutateDef.CONTRACT_VERSION on the server; {@code TODO-428}).
+	 * A THIRD, independently-versioned wire contract - deliberately not aliased to either
+	 * JUNEAU_VIEW_CONTRACT_VERSION or JUNEAU_ACTION_RESULT_CONTRACT_VERSION, so a bulk-actions-list revision can
+	 * never force a VIEW_META (ViewDef) contract bump (R2/design-doc guard). A sidecar whose contractVersion
+	 * differs is refused (fail-loud), leaving row selection itself fully functional - only the bulk toolbar is
+	 * withheld, per the two-independent-opt-ins separability guarantee (HIGH-5).
+	 */
+	const JUNEAU_BULK_CONTRACT_VERSION = "1";
+
+	/**
+	 * TODO-428 selection/bulk DOM attribute names - MUST equal ViewTable's constants of the same names
+	 * (SELECT_ATTR, ROW_ID_FIELD_ATTR, SELECT_ALL_ATTR, BULK_ATTR, BULK_SIDECAR_ID_PREFIX) on the server. Selection
+	 * and bulk-mutation are two INDEPENDENT opt-ins (HIGH-5): a table carries SELECT_ATTR with or without
+	 * BULK_ATTR, but never the reverse (ViewTable.of(..., BulkMutateDef) always stamps both, since a
+	 * BulkMutateDef is only constructible against a SelectionDef it requires at compile time).  All of this is
+	 * pure DOM-attribute signaling - none of it is part of the VIEW_META wire contract (R2 guard; see
+	 * SelectionDef/BulkMutateDef's class javadocs on the server).
+	 */
+	const SELECT_ATTR = "data-juneau-select";
+	const ROW_ID_ATTR = "data-juneau-row-id";
+	const ROW_ID_FIELD_ATTR = "data-juneau-row-id-field";
+	const SELECT_ALL_ATTR = "data-juneau-select-all";
+	const BULK_ATTR = "data-juneau-bulk";
+	const BULK_SIDECAR_ID_PREFIX = "juneau-view-bulk:";
 
 	const NS = window.JuneauViews = window.JuneauViews || {};
 	NS.CONTRACT_VERSION = JUNEAU_VIEW_CONTRACT_VERSION;
+	NS.ACTION_RESULT_CONTRACT_VERSION = JUNEAU_ACTION_RESULT_CONTRACT_VERSION;
+	NS.BULK_CONTRACT_VERSION = JUNEAU_BULK_CONTRACT_VERSION;
 
 	// ==================================================================================================================
 	// PURE LOGIC LAYER  (no DOM, no jQuery, no DataTables)
@@ -121,6 +156,222 @@
 		return out;
 	}
 
+	/**
+	 * Resolves a row's STABLE selection id (MED-11) from its data, via the `rowIdField` key the host declared in
+	 * its {@code SelectionDef} (carried client-side only via the {@code ROW_ID_FIELD_ATTR} DOM attribute - never
+	 * VIEW_META).  Deliberately NEVER a DOM/table index: a poll/sort/page tick reshuffles which data occupies
+	 * which row index, but this always resolves to the SAME id for the SAME underlying record.  Returns
+	 * `undefined` when `rowData`/`rowIdField` is absent, or when the row data has no such key - callers must
+	 * treat that as "this row cannot be selected" rather than inventing a fallback identity (e.g. a DOM index).
+	 */
+	function rowIdOf(rowData, rowIdField) {
+		return (rowData && rowIdField) ? rowData[rowIdField] : undefined;
+	}
+
+	/**
+	 * The persistence rule (Q2/MED-11): given the currently-selected ids and the ids actually present in the
+	 * latest draw (sort/page/poll), returns the subset of `selectedIds` that are STILL present - silently
+	 * DROPPING any id that has left the current page/result set, so a selection can never be kept "live" for a
+	 * row that is no longer on screen (closing the "bulk action hits the wrong target" case).  Pure - no DOM, no
+	 * Set/Map identity assumptions on the caller's side (plain arrays in, plain array out); ids are compared as
+	 * strings so a numeric id and its string form are never silently treated as different rows.
+	 */
+	function pruneSelection(selectedIds, currentIds) {
+		const present = {};
+		(currentIds || []).forEach(function (id) { if (id != null) present[String(id)] = true; });
+		const out = [];
+		(selectedIds || []).forEach(function (id) { if (id != null && Object.hasOwn(present, String(id))) out.push(id); });
+		return out;
+	}
+
+	/**
+	 * The default CSRF header name - MUST equal {@code LoopbackBoundary.DEFAULT_CSRF_HEADER} ("X-Csrf-Token") so the
+	 * runtime and the server boundary agree by default.  A host may override the name per-table (see
+	 * resolveCsrfHeaderName in the binding layer); absent an override the runtime sends this one.
+	 */
+	const DEFAULT_CSRF_HEADER = "X-Csrf-Token";
+
+	/**
+	 * The safe (non-state-changing) HTTP methods - mirrors {@code MethodSafety.SAFE_METHODS}.  A row action bound to
+	 * one of these would skip Origin/CSRF/JSON at the server boundary (a CSRF-able write), so the runtime refuses to
+	 * issue it.  The Java builder ({@code RowAction.Method}) already makes a safe method unexpressible; this is the
+	 * client half of that same HIGH-7 refusal, for a hand-edited or stale sidecar.
+	 */
+	const SAFE_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1 };
+
+	/** Whether a method is safe (GET/HEAD/OPTIONS/TRACE) - null/absent is NOT safe (mirrors MethodSafety.isSafe). */
+	function isSafeMethod(method) {
+		return !!(method && Object.hasOwn(SAFE_METHODS, String(method).toUpperCase()));
+	}
+
+	/**
+	 * The single, shared fail-closed token test.  Absent (null/undefined), present-but-empty, and
+	 * present-but-WHITESPACE all count as blank - matching the actual control, {@code LoopbackBoundary.check}'s
+	 * `presented == null || presented.isBlank()` (NOT `SynchronizerToken.matches`'s `isEmpty`, which would let a
+	 * whitespace token through to a confusing server 403).  Client refusal here is defense-against-omission; the
+	 * landed server-side {@code LoopbackBoundary} is the real security boundary.
+	 */
+	function isBlankToken(v) {
+		return v == null || String(v).trim() === "";
+	}
+
+	/**
+	 * Builds the fail-closed row-action request descriptor from a RowAction intent, a token, and a header name -
+	 * pure, DOM/fetch-free (plain data in, plain data out) so the fail-closed contract is unit-testable without a
+	 * browser.  Returns EITHER a `{refuse:true, reason}` marker (the caller renders a VISIBLE refusal) OR a
+	 * ready-to-issue `{url, method, headers, body}`:
+	 *   - a missing or SAFE method refuses (`reason:"safe-method"`) - HIGH-7;
+	 *   - a blank/absent/whitespace token refuses (`reason:"missing-token"`) - HIGH-1 fail-closed;
+	 *   - otherwise the body is JSON and the headers carry `Content-Type: application/json` (so the write passes
+	 *     `LoopbackBoundary.isJson`) plus the CSRF token under `headerName` (defaulting to DEFAULT_CSRF_HEADER).
+	 *
+	 * The optional `extra` object is merged into the JSON body - the modal submit path (TODO-416) uses it to carry
+	 * the server-minted `idempotencyKey` and the `targetId`, so a double-click/re-submit/browser-retry all carry the
+	 * same key and the server can check the key's `(action, targetId)` binding.  A bare submit (no `extra`) sends
+	 * exactly `{action}` as before.
+	 */
+	function buildActionRequest(action, token, headerName, extra) {
+		if (!action || isSafeMethod(action.method) || !action.method)
+			return { refuse: true, reason: "safe-method" };
+		if (isBlankToken(token))
+			return { refuse: true, reason: "missing-token" };
+		const headers = { "Content-Type": "application/json" };
+		headers[headerName || DEFAULT_CSRF_HEADER] = token;
+		const payload = { action: action.id };
+		if (extra) for (const k in extra) if (Object.hasOwn(extra, k) && extra[k] != null) payload[k] = extra[k];
+		return {
+			url: action.endpoint,
+			method: action.method,
+			headers: headers,
+			body: JSON.stringify(payload)
+		};
+	}
+
+	/** Maps a buildActionRequest refusal reason to the visible message shown in the row-action refusal banner. */
+	function actionRefusalMessage(reason) {
+		if (reason === "safe-method")
+			return "action must use a non-safe method (POST/PUT/PATCH/DELETE)";
+		if (reason === "missing-token")
+			return "no CSRF token available - the page did not supply one, so the request was not sent";
+		if (reason === "request-failed")
+			return "the request could not be completed";
+		return "the action was refused";
+	}
+
+	/**
+	 * The frozen set of typed action-result outcome tokens (ActionResult.Outcome) - the four synchronous outcomes
+	 * plus the two async terminal states reserved for TODO-425 (`cancelled`, `cancelled-after-effect`).  An outcome
+	 * token not in this set is normalized to `unknown` (a visible, non-optimistic state - never an optimistic
+	 * success).
+	 */
+	const ACTION_OUTCOMES = {
+		"success": 1, "failure": 1, "refusal": 1, "unknown": 1, "cancelled": 1, "cancelled-after-effect": 1
+	};
+
+	/** JSON.parse guarded against a null/blank/malformed body - returns the parsed value or null, never throws. */
+	function parseJsonSafe(text) {
+		if (text == null || text === "") return null;
+		try { return JSON.parse(text); } catch (e) { return null; }
+	}
+
+	/**
+	 * Parses a 2xx action-submit body into a typed ActionResult, or null when the body is absent/malformed or does
+	 * NOT carry the contract's load-bearing `outcome` discriminator.  A null return means "no typed result was
+	 * carried" - the caller treats that as a bare success (the pre-416 behavior) rather than inventing an outcome.
+	 */
+	function parseActionResult(text) {
+		const o = parseJsonSafe(text);
+		if (!o || typeof o !== "object" || o.outcome == null) return null;
+		return o;
+	}
+
+	/**
+	 * Normalizes a typed ActionResult's outcome to a known token, mapping anything unrecognized to `unknown` - so an
+	 * unknown/garbled outcome renders as a visible non-optimistic state, never as an optimistic success.
+	 */
+	function normalizeOutcome(result) {
+		const o = result && result.outcome;
+		return (o != null && Object.hasOwn(ACTION_OUTCOMES, o)) ? o : "unknown";
+	}
+
+	/**
+	 * Parses a 2xx action-submit body into an ASYNC "job accepted" pointer (AsyncJobRef), or null when it is not
+	 * one.  Whether an action is asynchronous is a property of the RESPONSE, not the declared RowAction (TODO-425):
+	 * the same POST returns EITHER a terminal ActionResult (has `outcome`) OR this pointer (has `streamUrl`), so no
+	 * new RowAction wire field is needed and the two shapes are disjoint.  A job pointer MUST carry a non-blank
+	 * `streamUrl` (the SSE capability URL); a body without one is not a job pointer and falls through to the normal
+	 * typed-result path.
+	 */
+	function parseJobStarted(text) {
+		const o = parseJsonSafe(text);
+		if (!o || typeof o !== "object") return null;
+		if (isBlankToken(o.streamUrl)) return null;
+		return o;
+	}
+
+	/**
+	 * Builds the fail-closed cancel-request descriptor for an async job (a non-safe POST to the job's cancelUrl) -
+	 * pure, DOM/fetch-free, mirroring buildActionRequest's fail-closed token contract.  Returns EITHER a
+	 * `{refuse:true, reason}` marker OR a ready `{url, method:"POST", headers, body}`; the body is an empty JSON
+	 * object so the write passes LoopbackBoundary.isJson, and the CSRF token rides `headerName`.  Server-side
+	 * cancellation is authoritative (a client cannot be trusted to stop the work); this only asks for it.
+	 */
+	function buildJobCancelRequest(cancelUrl, token, headerName) {
+		if (isBlankToken(cancelUrl))
+			return { refuse: true, reason: "no-cancel-url" };
+		if (isBlankToken(token))
+			return { refuse: true, reason: "missing-token" };
+		const headers = { "Content-Type": "application/json" };
+		headers[headerName || DEFAULT_CSRF_HEADER] = token;
+		return { url: cancelUrl, method: "POST", headers: headers, body: "{}" };
+	}
+
+	/**
+	 * Builds a visible, non-optimistic transport-refusal classification for ANY non-2xx action response (HIGH-3),
+	 * WITHOUT requiring the typed action-result schema.  Prefers the boundary's `X-Loopback-Boundary` reason header,
+	 * then the small `{reason,message}` JSON envelope the LoopbackBoundaryFilter emits, then a status-derived
+	 * fallback - so a 403/415/421 (or a missing/malformed body) always maps to a comprehensible named refusal rather
+	 * than a generic/empty/silent failure on a security refusal.
+	 */
+	function transportRefusal(status, boundaryReason, envelope) {
+		const code = ! isBlankToken(boundaryReason) ? boundaryReason
+			: (envelope && envelope.reason ? envelope.reason : ("http:" + (status || 0)));
+		let message = envelope && envelope.message ? envelope.message : null;
+		if (isBlankToken(message)) message = transportStatusMessage(status);
+		return { code: code, message: message };
+	}
+
+	/** A comprehensible fallback message for the transport statuses the LoopbackBoundary answers with. */
+	function transportStatusMessage(status) {
+		if (status === 403) return "the request was refused by the server boundary (403)";
+		if (status === 415) return "the request body was not accepted as JSON (415)";
+		if (status === 421) return "the request was misdirected - Host did not match (421)";
+		return "the request was refused (" + (status == null ? "no status" : status) + ")";
+	}
+
+	/**
+	 * The visible text for a settled action outcome - a pure (data-in/text-out) mapping over every outcome the typed
+	 * result can carry AND the transport-refusal case, so no terminal state is silent.  `cls` is
+	 * `{outcome, refusalCode, message, replay, transport}`.
+	 */
+	function actionOutcomeMessage(cls) {
+		const replay = cls.replay ? " (replay of a previous attempt)" : "";
+		const detail = cls.message ? ": " + cls.message : "";
+		switch (cls.outcome) {
+			case "success": return "Done" + detail + replay + ".";
+			case "failure": return "Failed" + detail + ".";
+			case "refusal": return (cls.transport ? "Request refused" : "Refused") + " (" + (cls.refusalCode || "unknown") + ")" + detail + ".";
+			case "cancelled": return "Cancelled" + detail + ".";
+			case "cancelled-after-effect": return "Cancelled after a partial effect" + detail + ".";
+			default: return "Outcome unknown - the write may or may not have completed" + detail + ".";
+		}
+	}
+
+	/** Whether an action is presented as a modal dialog (`present=dialog`) - the TODO-416 modal/form path. */
+	function isDialogAction(action) {
+		return !! (action && action.present === "dialog");
+	}
+
 	/**
 	 * The minimum honored polling interval, in milliseconds - mirrors {@code ViewDef.MIN_POLL_INTERVAL_MS}.  The
 	 * server is the authoritative clamp (a `pollIntervalMs` value already arriving in VIEW_META has already been
@@ -288,6 +539,22 @@
 				rowEl.className += (rowEl.className ? " " : "") + "juneau-view-detail-row";
 			}
 		};
+
+		// A declared rowActions list appends ONE synthetic, non-orderable/non-searchable trailing column whose
+		// cell is the per-row action trigger.  initTable(...) appends the matching <th> to <thead> before booting
+		// DataTables so column and header counts stay in step.  The action BEHAVIOR (modal/form/typed result) is a
+		// later wave (TODO-416); this column only surfaces the menu trigger and routes its click to submitRowAction.
+		if (viewDef.rowActions && viewDef.rowActions.length) {
+			opts.columns.push({
+				data: null,
+				orderable: false,
+				searchable: false,
+				className: "juneau-view-actions-cell",
+				defaultContent: "",
+				title: "",
+				render: function () { return actionTriggerMarkup(); }
+			});
+		}
 		return opts;
 	}
 
@@ -580,6 +847,11 @@
 	 * user's action and then redo it once the write's own result repaints it (design doc §9.1 B5) - so a poll
 	 * tick skips its ENTIRE redraw whenever ANY row in this table carries the marker, leaving the table exactly
 	 * as-is (stale, but honestly so) until that write settles.
+	 *
+	 * <p>Scoped to SYNCHRONOUS writes ONLY: it reads `data-juneau-inflight`, NOT the async job marker
+	 * (`data-juneau-job`, set by setRowJobRunning).  A long-running TODO-425 job must NOT freeze the whole table's
+	 * polling for up to the 120s hard timeout - so it uses the distinct marker this function deliberately ignores
+	 * (HIGH-9).
 	 */
 	function hasInFlightRow(table) {
 		return !!table.querySelector("tbody tr[data-juneau-inflight]");
@@ -684,6 +956,739 @@
 		render();
 	}
 
+	/** The per-row action-menu trigger markup (returned by the synthetic actions column's render). */
+	function actionTriggerMarkup() {
+		const icons = window.JuneauViews && window.JuneauViews.icons;
+		const glyph = icons?.resolveIcon ? icons.resolveIcon("more_vert") : null;
+		const inner = glyph != null ? glyph : "\u22EF";   // horizontal ellipsis fallback when no glyph is registered
+		return '<button type="button" class="juneau-view-action-trigger" aria-haspopup="menu" ' +
+			'aria-label="Row actions">' + inner + '</button>';
+	}
+
+	/**
+	 * Reads the auto-embedded CSRF token off the view table's `data-juneau-csrf` attribute (stamped by
+	 * ViewTable from the LoopbackBoundaryFilter request attribute, or set by the host as an override/fallback).
+	 * Returns the raw attribute value (possibly null/blank); isBlankToken(...) is what decides fail-closed, so a
+	 * whitespace value is NOT normalized away here - it must reach the same blank test the server boundary uses.
+	 */
+	function resolveCsrfToken(table) {
+		return table.getAttribute("data-juneau-csrf");
+	}
+
+	/** The per-table CSRF header-name override (`data-juneau-csrf-header`), else the framework default. */
+	function resolveCsrfHeaderName(table) {
+		const override = table.getAttribute("data-juneau-csrf-header");
+		return isBlankToken(override) ? DEFAULT_CSRF_HEADER : override.trim();
+	}
+
+	/** Appends the actions column's header cell to <thead> (kept in step with buildOptions' synthetic column). */
+	function appendActionsHeaderCell(table) {
+		const headRow = table.querySelector("thead tr");
+		if (!headRow) return;
+		const th = document.createElement("th");
+		th.className = "juneau-view-actions-th";
+		th.setAttribute("aria-label", "Actions");
+		headRow.appendChild(th);
+	}
+
+	// ==================================================================================================================
+	// ROW SELECTION + BULK MUTATION (TODO-428) - two INDEPENDENT opt-ins (HIGH-5), detected purely from DOM
+	// attributes ViewTable stamps (SELECT_ATTR / BULK_ATTR) - NEVER from VIEW_META/viewDef.  Enabling selection
+	// alone can never surface a bulk-mutate control: hasBulk(...) is only ever consulted from within the
+	// `hasSelection(table)` branch of initTable, and BULK_ATTR is only ever stamped by ViewTable when a
+	// BulkMutateDef (which itself REQUIRES a WritePermit + a SelectionDef to construct) was supplied.
+	// ==================================================================================================================
+
+	/** Whether `table` declares row selection (SelectionDef was supplied to ViewTable.of(...)). */
+	function hasSelection(table) {
+		return table.getAttribute(SELECT_ATTR) === "1";
+	}
+
+	/** Whether `table` declares bulk mutation (BulkMutateDef was supplied to ViewTable.of(...)). */
+	function hasBulk(table) {
+		return table.getAttribute(BULK_ATTR) === "1";
+	}
+
+	/**
+	 * Stamps the STABLE row id (MED-11) onto a just-created `<tr>`, read from that row's OWN data via the
+	 * `rowIdField` key - never a DOM/table index. A no-op when `rowIdField` is absent (selection not declared) or
+	 * the row has no such key (nothing to stamp; that row is simply unselectable). This is the ONLY place
+	 * `ROW_ID_ATTR` is written; every reader elsewhere (selection wiring, bulk execution, the TODO-416 modal path's
+	 * `submitActionDialog`) treats it as already-authoritative once stamped.
+	 */
+	function stampRowId(rowEl, rowData, rowIdField) {
+		if (!rowIdField) return;
+		const id = rowIdOf(rowData, rowIdField);
+		if (id != null) rowEl.setAttribute(ROW_ID_ATTR, String(id));
+	}
+
+	/** The selection checkbox cell's markup - a bare, unlabeled-by-design checkbox (the row IS its own label). */
+	function selectionCellMarkup(checked) {
+		return '<input type="checkbox" class="juneau-view-select-checkbox" aria-label="Select row"' +
+			(checked ? " checked" : "") + '>';
+	}
+
+	/**
+	 * Builds the synthetic, non-orderable/non-searchable LEADING selection column (mirrors how a declared
+	 * rowActions list appends a synthetic TRAILING column in buildOptions) - `selectionState.selected` is a
+	 * live `Set` of currently-selected stable row ids, so a redraw always paints each row's checkbox from the
+	 * CURRENT selection, never a stale snapshot.
+	 */
+	function buildSelectionColumnDef(selectionState) {
+		return {
+			data: null,
+			orderable: false,
+			searchable: false,
+			className: "juneau-view-select-cell",
+			defaultContent: "",
+			title: "",
+			render: function (data, type, rowData) {
+				if (type && type !== "display") return "";
+				const id = rowIdOf(rowData, selectionState.rowIdField);
+				return selectionCellMarkup(id != null && selectionState.selected.has(String(id)));
+			}
+		};
+	}
+
+	/**
+	 * Wires row selection: per-row checkbox toggle (delegated `change` listener - checkboxes are re-created on
+	 * every draw, the table element is not), the optional select-all header checkbox (only when `SelectionDef`
+	 * declared `selectAll`), and the off-screen-id-drop persistence rule (Q2/MED-11) on every `draw.dt` (sort,
+	 * page, or a TODO-426 poll tick).  Select-all is scoped to the CURRENT draw's rows only (the ones actually on
+	 * screen) - consistent with the drop rule, it can never reach into an off-screen page.
+	 */
+	function initSelection(table, dt, selectionState, ctx) {
+		function currentRowIds() {
+			const ids = [];
+			Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+				ids.push(tr.getAttribute(ROW_ID_ATTR));
+			});
+			return ids;
+		}
+
+		function refresh() {
+			if (ctx && ctx.bulkToolbar) ctx.bulkToolbar.refresh(selectionState.selected.size);
+		}
+
+		table.addEventListener("change", function (e) {
+			const cb = e.target && e.target.closest ? e.target.closest(".juneau-view-select-checkbox") : null;
+			if (!cb) return;
+			const tr = cb.closest("tr");
+			const id = tr ? tr.getAttribute(ROW_ID_ATTR) : null;
+			if (id == null) return;
+			if (cb.checked) selectionState.selected.add(id); else selectionState.selected.delete(id);
+			refresh();
+		});
+
+		if (table.getAttribute(SELECT_ALL_ATTR) === "1") {
+			const th = table.querySelector(".juneau-view-select-th");
+			if (th) {
+				const allCb = document.createElement("input");
+				allCb.type = "checkbox";
+				allCb.className = "juneau-view-select-all-checkbox";
+				allCb.setAttribute("aria-label", "Select all rows on this page");
+				allCb.addEventListener("change", function () {
+					Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+						const id = tr.getAttribute(ROW_ID_ATTR);
+						const cb = tr.querySelector(".juneau-view-select-checkbox");
+						if (cb) cb.checked = allCb.checked;
+						if (allCb.checked) selectionState.selected.add(id); else selectionState.selected.delete(id);
+					});
+					refresh();
+				});
+				th.appendChild(allCb);
+			}
+		}
+
+		// The off-screen-id-drop rule (Q2/MED-11): every redraw prunes the live selection down to ids still
+		// actually on screen, so a poll/sort/page tick can never leave a bulk mutate targeting a row the user can
+		// no longer see.
+		dt.on("draw.dt", function () {
+			const pruned = pruneSelection(Array.from(selectionState.selected), currentRowIds());
+			selectionState.selected = new Set(pruned);
+			refresh();
+		});
+	}
+
+	/**
+	 * Reads and JSON.parses the bulk-actions sidecar (`BULK_SIDECAR_ID_PREFIX + id`) - the independently-versioned
+	 * {@code BulkMutateDef} contract, deliberately never merged into VIEW_META (R2 guard).  Returns `null` on a
+	 * missing or malformed sidecar (the caller treats that as "no usable bulk config" and withholds the toolbar,
+	 * rather than guessing).
+	 */
+	function readBulkDef(id) {
+		const sidecar = document.getElementById(BULK_SIDECAR_ID_PREFIX + id);
+		if (!sidecar) return null;
+		return parseJsonSafe(sidecar.textContent);
+	}
+
+	/**
+	 * Builds the bulk-actions toolbar: a live "N selected" count plus one button per declared bulk action, each
+	 * disabled while the selection is empty (there is nothing for a bulk action to target).  A click drives
+	 * executeBulkAction(...) - the per-target submit path, never an aggregate one.
+	 */
+	function buildBulkToolbar(bulkDef, table, ctx, selectionState) {
+		const bar = document.createElement("div");
+		bar.className = "juneau-view-bulk-toolbar";
+		bar.setAttribute("data-testid", "bulk-toolbar");
+
+		const countEl = document.createElement("span");
+		countEl.className = "juneau-view-bulk-count";
+		bar.appendChild(countEl);
+
+		const buttons = (bulkDef.actions || []).map(function (action) {
+			const btn = document.createElement("button");
+			btn.type = "button";
+			btn.className = "juneau-view-bulk-action-btn";
+			btn.textContent = action.label || action.id;
+			btn.disabled = true;
+			btn.addEventListener("click", function () { executeBulkAction(action, table, ctx, selectionState); });
+			bar.appendChild(btn);
+			return btn;
+		});
+
+		return {
+			el: bar,
+			refresh: function (count) {
+				countEl.textContent = count > 0 ? (count + " selected") : "";
+				buttons.forEach(function (b) { b.disabled = count === 0; });
+			}
+		};
+	}
+
+	/**
+	 * Executes ONE bulk action over the current selection as N INDEPENDENT per-row writes (HIGH-5) - it is a
+	 * plain loop calling the SAME submitRowAction(...) the single-row action-menu uses, once per selected id, each
+	 * carrying that row's stable id as `targetId` in the JSON body (the same `extra` convention the TODO-416 modal
+	 * submit path already uses for `idempotencyKey`/`targetId`).  There is deliberately NO aggregate request and
+	 * NO aggregate result: each row gets its own TODO-417 in-flight marker and its own typed ActionResult,
+	 * rendered independently, so one target's failure/refusal/unknown can never be hidden behind an overall
+	 * "success" - and each clears ITS OWN `data-juneau-inflight` on ITS OWN terminal outcome (MED-4), so a stuck
+	 * target can never halt the whole table's polling.  A selected id whose row is no longer on screen (e.g. it
+	 * left the page between the click and this loop running) is silently skipped - the persistence rule (MED-11)
+	 * never lets an off-screen row become an actionable target.
+	 */
+	function executeBulkAction(action, table, ctx, selectionState) {
+		const ids = Array.from(selectionState.selected);
+		if (!ids.length) return;
+		const byId = {};
+		Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+			byId[tr.getAttribute(ROW_ID_ATTR)] = tr;
+		});
+		ids.forEach(function (id) {
+			const tr = byId[id];
+			if (!tr) return;
+			submitRowAction(action, table, tr, ctx, { targetId: id });
+		});
+	}
+
+	/** Renders a VISIBLE, non-blocking inline error line (anti-silent-degradation) into `container`. */
+	function renderInlineError(container, message) {
+		const el = document.createElement("div");
+		el.className = "juneau-view-error";
+		el.setAttribute("role", "alert");
+		el.textContent = message;
+		container.appendChild(el);
+	}
+
+	/**
+	 * Issues one row action, FAIL-CLOSED.  Resolves the table's token + header name, asks the pure
+	 * buildActionRequest(...) for a request descriptor, and:
+	 *   - on a refusal marker (safe method, or blank/absent/whitespace token) renders a VISIBLE refusal and sends
+	 *     NOTHING - no silent degradation, and never an empty-header request the server would 403;
+	 *   - otherwise marks the row in-flight (TODO-417) and issues the JSON fetch with the CSRF header, then settles
+	 *     the row from the typed ActionResult / transport refusal (TODO-416).
+	 *
+	 * This is the DIRECT submit path (no confirmation dialog); a `present=dialog` action goes through
+	 * openActionDialog(...) instead.  The optional `extra` payload (idempotencyKey + targetId) is carried on the
+	 * dialog path.  The client refusal is defense-against-omission; the landed server-side LoopbackBoundary is the
+	 * real control.
+	 */
+	function submitRowAction(action, table, tr, ctx, extra) {
+		const req = buildActionRequest(action, resolveCsrfToken(table), resolveCsrfHeaderName(table), extra);
+		if (req.refuse) {
+			renderRowActionRefusal(tr, action, req.reason);
+			return;
+		}
+		setRowInFlight(tr, true);
+		fetch(req.url, { method: req.method, headers: req.headers, body: req.body, credentials: "same-origin" })
+			.then(function (resp) {
+				settleActionResponse(resp, action, table, tr, ctx);
+			})
+			.catch(function () {
+				// 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);
+				renderRowActionRefusal(tr, action, "request-failed");
+			});
+	}
+
+	/**
+	 * Whether `table` currently has a row marked in-flight (design doc §9.1 B5) - see hasInFlightRow above; this is
+	 * the setter half.  Marks the `<tr>` in-flight for an OUTSTANDING SYNCHRONOUS WRITE and disables its action
+	 * trigger so a double-click cannot issue a second write (TODO-417).  The marker is scoped to synchronous writes
+	 * ONLY - a long-running async job (TODO-425) uses a distinct affordance that does not inhibit table polling,
+	 * because initPolling skips the WHOLE table's poll while any row carries this marker.
+	 */
+	function setRowInFlight(tr, on) {
+		if (!tr) return;
+		if (on) tr.setAttribute("data-juneau-inflight", "1");
+		else tr.removeAttribute("data-juneau-inflight");
+		const trigger = tr.querySelector ? tr.querySelector(".juneau-view-action-trigger") : null;
+		if (trigger) trigger.disabled = !!on;
+	}
+
+	/**
+	 * Settles a row from an action-submit response - the TODO-416/417 join point.  It ALWAYS clears the in-flight
+	 * marker and re-enables the action FIRST (on success, failure, refusal, unknown, AND a transport refusal), so a
+	 * stuck marker can never freeze the whole table's polling (MED-4); then it renders the outcome:
+	 *   - non-2xx  -> a visible TRANSPORT refusal built from `X-Loopback-Boundary` + the `{reason,message}` envelope,
+	 *                 WITHOUT requiring the typed action-result schema (HIGH-3);
+	 *   - 2xx + typed ActionResult -> success (merge/redraw the row from the authoritative payload), failure,
+	 *                 named refusal, or a non-optimistic unknown - every outcome is rendered, none is silent;
+	 *   - 2xx + no typed body -> a bare success (the pre-416 behavior: redraw for an onSuccess=redraw action).
+	 */
+	function settleActionResponse(resp, action, table, tr, ctx) {
+		setRowInFlight(tr, false);   // EVERY terminal outcome clears the marker first - polling must always resume.
+		if (! resp) { renderActionOutcome(tr, { outcome: "unknown" }); return; }
+
+		if (! resp.ok) {
+			const boundaryReason = (resp.headers && 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));
+				renderActionOutcome(tr, { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message });
+			});
+			return;
+		}
+
+		readBodyText(resp).then(function (text) {
+			// TODO-425: an ASYNC action's start POST returns a job pointer (not a terminal result).  The in-flight
+			// marker was ALREADY cleared above (first line of settle), so table polling has resumed BEFORE the long
+			// job runs - the job then uses the DISTINCT job-running affordance (data-juneau-job), never
+			// data-juneau-inflight, so hasInFlightRow/initPolling never freeze the whole table for the job's
+			// duration (HIGH-9).
+			const started = parseJobStarted(text);
+			if (started) {
+				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);
+				return;
+			}
+			if (result.contractVersion != null && result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION) {
+				renderActionOutcome(tr, { outcome: "unknown",
+					message: "action-result contract mismatch (page='" + result.contractVersion +
+						"', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" });
+				return;
+			}
+			const outcome = normalizeOutcome(result);
+			if (outcome === "success") {
+				applySuccessBehavior(action, table, tr, ctx, result);
+				renderActionOutcome(tr, { outcome: "success", replay: !! result.replay, message: result.message });
+			} else {
+				renderActionOutcome(tr, {
+					outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay
+				});
+			}
+		});
+	}
+
+	/** Reads a fetch Response body as text, defensively (a stubbed/absent body resolves to "" rather than throwing). */
+	function readBodyText(resp) {
+		if (resp && typeof resp.text === "function") {
+			try { return Promise.resolve(resp.text()); } catch (e) { return Promise.resolve(""); }
+		}
+		return Promise.resolve("");
+	}
+
+	/**
+	 * Applies a successful action's onSuccess behavior: `mergeRow` re-renders the row from the result's authoritative
+	 * payload (TODO-417), `redraw` reloads the table, `navigate` is left to the consumer.  A bare success (no typed
+	 * result) with onSuccess=redraw still redraws, preserving the pre-416 direct-submit behavior.
+	 */
+	function applySuccessBehavior(action, table, tr, ctx, result) {
+		if (action.onSuccess === "mergeRow" && result && result.row != null) {
+			mergeRowFromResult(tr, ctx, result.row);
+		} else if (action.onSuccess === "redraw" && ctx && ctx.redraw) {
+			ctx.redraw();
+		}
+	}
+
+	/**
+	 * Re-renders a row from the ActionResult's authoritative row payload (the `MERGE_ROW` case).  Prefers
+	 * DataTables' native row API when present; otherwise fires an optional `ctx.mergeRow(tr, rowData)` hook.  Always
+	 * stamps `data-juneau-row-merged` on the row so the re-render is observable (and so a host can style a
+	 * just-updated row).
+	 */
+	function mergeRowFromResult(tr, ctx, rowData) {
+		let mergedViaDt = false;
+		try {
+			if (ctx && ctx.dataTable && typeof ctx.dataTable.row === "function") {
+				const row = ctx.dataTable.row(tr);
+				if (row && typeof row.data === "function") {
+					row.data(rowData);
+					if (typeof row.draw === "function") row.draw(false);
+					mergedViaDt = true;
+				}
+			}
+		} catch (e) { mergedViaDt = false; }
+		if (! mergedViaDt && ctx && typeof ctx.mergeRow === "function") ctx.mergeRow(tr, rowData);
+		if (tr && tr.setAttribute) tr.setAttribute("data-juneau-row-merged", "1");
+	}
+
+	/**
+	 * Renders a VISIBLE settled-outcome banner into the row's actions cell (anti-silent-degradation): success gets a
+	 * `role=status`, everything else (failure, refusal, transport refusal, cancelled, unknown) gets a `role=alert`.
+	 * Distinct from renderRowActionRefusal (which is the pre-flight fail-closed refusal); this is the post-response
+	 * outcome.
+	 */
+	function renderActionOutcome(tr, cls) {
+		const cell = tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr;
+		let banner = cell.querySelector ? cell.querySelector(".juneau-view-action-outcome") : null;
+		if (! banner) {
+			banner = document.createElement("div");
+			banner.className = "juneau-view-action-outcome";
+			banner.setAttribute("data-testid", "action-outcome");
+			cell.appendChild(banner);
+		}
+		const state = cls.outcome || "unknown";
+		banner.setAttribute("data-state", state);
+		banner.setAttribute("role", state === "success" ? "status" : "alert");
+		banner.textContent = actionOutcomeMessage(cls);
+	}
+
+	// ==================================================================================================================
+	// ASYNC JOBS (TODO-425): a long-running job streamed over SSE, with a DISTINCT running affordance
+	// ==================================================================================================================
+
+	/**
+	 * Marks a row as running a LONG async job (TODO-425) - deliberately a DIFFERENT attribute
+	 * (`data-juneau-job`) from the synchronous in-flight marker (`data-juneau-inflight`).  This is the whole point
+	 * of HIGH-9: hasInFlightRow (and therefore initPolling) freezes the ENTIRE table's polling while ANY row
+	 * carries `data-juneau-inflight`, which is correct for a short synchronous write but catastrophic for a job
+	 * that can run up to the server's 120s hard timeout - it would hide a resurrected incident on a PagerDuty
+	 * table for the whole job.  A job therefore NEVER sets `data-juneau-inflight`; it sets this distinct marker,
+	 * which hasInFlightRow does NOT read, so the table keeps polling for the entire duration of the job.  The
+	 * action trigger is disabled while running so a second job cannot be started on the same row.
+	 */
+	function setRowJobRunning(tr, on) {
+		if (!tr) return;
+		if (on) tr.setAttribute("data-juneau-job", "1");
+		else tr.removeAttribute("data-juneau-job");
+		const trigger = tr.querySelector ? tr.querySelector(".juneau-view-action-trigger") : null;
+		if (trigger) trigger.disabled = !!on;
+	}
+
+	/**
+	 * Renders (and updates) the VISIBLE, live job-progress banner in the row's actions cell, plus a Cancel button
+	 * wired to the job's cancelUrl.  The progress text is painted with `textContent` ONLY (never innerHTML) - the
+	 * streamed content is customer-adjacent and this origin holds the CSRF token, so a typed/escaped path is the
+	 * only safe one (same invariant as the modal fields, BLK-1/MED-9).  Distinct `data-testid`/class from the
+	 * settled-outcome banner so a running job and a terminal outcome are never confused.
+	 */
+	function renderJobProgress(tr, text, started, table) {
+		const cell = tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr;
+		let banner = cell.querySelector ? cell.querySelector(".juneau-view-job-progress") : null;
+		if (! banner) {
+			banner = document.createElement("div");
+			banner.className = "juneau-view-job-progress";
+			banner.setAttribute("data-testid", "job-progress");
+			banner.setAttribute("role", "status");
+			const msg = document.createElement("span");
+			msg.className = "juneau-view-job-progress-msg";
+			banner.appendChild(msg);
+			if (started && ! isBlankToken(started.cancelUrl)) {
+				const cancel = document.createElement("button");
+				cancel.type = "button";
+				cancel.className = "juneau-view-job-cancel";
+				cancel.textContent = "Cancel";
+				cancel.addEventListener("click", function () { cancelJob(started, table, tr); });
+				banner.appendChild(cancel);
+			}
+			cell.appendChild(banner);
+		}
+		const msgEl = banner.querySelector(".juneau-view-job-progress-msg");
+		if (msgEl) msgEl.textContent = (text == null ? "" : String(text));
+	}
+
+	/** Removes the live job-progress banner from a row (called when the job settles to a terminal outcome). */
+	function clearJobProgress(tr) {
+		const cell = tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr;
+		const banner = cell.querySelector ? cell.querySelector(".juneau-view-job-progress") : null;
+		if (banner && banner.parentNode) banner.parentNode.removeChild(banner);
+	}
+
+	/**
+	 * Opens the SSE progress stream for a started async job and drives the row through its lifecycle (TODO-425).
+	 * The stream URL ITSELF is the capability (an unguessable >=128-bit token) - a browser EventSource CANNOT set
+	 * an X-Csrf-Token header, so unguessability is the access control, not a CSRF header (HIGH-4).  `progress`
+	 * events update the live banner; the single terminal `result` event carries the typed ActionResult and settles
+	 * the row (reusing the same normalize/contract-check/render path as a synchronous result, so cancelled /
+	 * cancelled-after-effect render via actionOutcomeMessage without any new UI).  A stream error is itself a
+	 * non-optimistic terminal outcome.  Throughout, the row carries only the DISTINCT `data-juneau-job` marker, so
+	 * the table keeps polling (HIGH-9).
+	 */
+	function startJobStream(started, action, table, tr, ctx) {
+		if (typeof EventSource === "undefined") {
+			// No SSE transport in this browser - a visible, non-optimistic outcome; polling was never frozen.
+			renderActionOutcome(tr, { outcome: "unknown", message: "live progress is unavailable in this browser" });
+			return null;
+		}
+		setRowJobRunning(tr, true);
+		renderJobProgress(tr, "Working\u2026", started, table);
+		const es = new EventSource(started.streamUrl);
+		const st = { settled: false };
+		function finish(result, fallback) {
+			if (st.settled) return;
+			st.settled = true;
+			es.close();
+			setRowJobRunning(tr, false);
+			clearJobProgress(tr);
+			if (result) finishJobFromResult(action, table, tr, ctx, result);
+			else renderActionOutcome(tr, fallback);
+		}
+		es.addEventListener("progress", function (e) {
+			if (! st.settled) renderJobProgress(tr, e.data, started, table);
+		});
+		es.addEventListener("result", function (e) {
+			finish(parseActionResult(e.data), { outcome: "unknown", message: "the job produced no readable result" });
+		});
+		es.addEventListener("error", function () {
+			finish(null, { outcome: "unknown", message: "the progress stream was interrupted" });
+		});
+		if (ctx) ctx._jobSource = es;
+		return es;
+	}
+
+	/**
+	 * Settles a row from an async job's terminal ActionResult (the SSE `result` event) - the async twin of
+	 * settleActionResponse's 2xx typed branch, reusing the SAME contract-version handshake, outcome normalization,
+	 * success behavior and visible-outcome render.  cancelled / cancelled-after-effect are just outcomes here;
+	 * actionOutcomeMessage already renders both.
+	 */
+	function finishJobFromResult(action, table, tr, ctx, result) {
+		if (! result) { renderActionOutcome(tr, { outcome: "unknown" }); return; }
+		if (result.contractVersion != null && result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION) {
+			renderActionOutcome(tr, { outcome: "unknown",
+				message: "action-result contract mismatch (page='" + result.contractVersion +
+					"', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" });
+			return;
+		}
+		const outcome = normalizeOutcome(result);
+		if (outcome === "success") {
+			applySuccessBehavior(action, table, tr, ctx, result);
+			renderActionOutcome(tr, { outcome: "success", replay: !! result.replay, message: result.message });
+		} else {
+			renderActionOutcome(tr, {
+				outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay
+			});
+		}
+	}
+
+	/**
+	 * Requests cancellation of a running job (fail-closed CSRF POST to the job's cancelUrl).  The SERVER is
+	 * authoritative - the terminal cancelled / cancelled-after-effect outcome still arrives over the SSE `result`
+	 * event, so this only asks; a blank token or missing cancelUrl renders a visible refusal and sends nothing.
+	 */
+	function cancelJob(started, table, tr) {
+		const req = buildJobCancelRequest(started && started.cancelUrl, resolveCsrfToken(table), resolveCsrfHeaderName(table));
+		if (req.refuse) {
+			renderRowActionRefusal(tr, { id: "cancel", label: "Cancel" }, req.reason === "missing-token" ? "missing-token" : "request-failed");
+			return;
+		}
+		fetch(req.url, { method: req.method, headers: req.headers, body: req.body, credentials: "same-origin" })
+			.catch(function () { renderRowActionRefusal(tr, { id: "cancel", label: "Cancel" }, "request-failed"); });
+	}
+
+	/**
+	 * Opens a `present=dialog` action's modal overlay (TODO-416).  When the action declares a form-source URL, the
+	 * modal-open confirmation is a READ-ONLY GET that returns the typed ModalDef JSON (confirmation fields + the
+	 * server-minted idempotency key) - it never mutates (HIGH-7); its typed fields are painted with `textContent`
+	 * (never `innerHTML`, never raw markup - BLK-1/MED-9).  With no form URL the dialog is a confirm-only prompt
+	 * from the declared `confirm` text.  The mutation is the SEPARATE non-safe submit the confirm button issues.
+	 */
+	function openActionDialog(action, table, tr, ctx) {
+		if (isBlankToken(action.form)) {
+			showActionDialog({ title: action.confirm || action.label || action.id }, action, table, tr, ctx);
+			return;
+		}
+		fetch(action.form, { method: "GET", credentials: "same-origin", headers: { "Accept": "application/json" } })
+			.then(function (resp) {
+				if (! resp || ! resp.ok) {
+					// A non-2xx on the read-only confirmation fetch is itself a visible transport refusal - the
+					// modal never opens optimistically on a boundary rejection.
+					const boundaryReason = (resp && resp.headers && typeof resp.headers.get === "function")
+						? resp.headers.get("X-Loopback-Boundary") : null;
+					return readBodyText(resp).then(function (text) {
+						const t = transportRefusal(resp ? resp.status : 0, boundaryReason, parseJsonSafe(text));
+						renderActionOutcome(tr, { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message });
+					});
+				}
+				return readBodyText(resp).then(function (text) {
+					showActionDialog(parseJsonSafe(text) || { title: action.confirm || action.label || action.id },
+						action, table, tr, ctx);
+				});
+			})
+			.catch(function () { renderRowActionRefusal(tr, action, "request-failed"); });
+	}
+
+	/**
+	 * Builds a `present=dialog` overlay DOM node (a backdrop + a `role=dialog` box) from a ModalDef, painting the
+	 * title and each typed confirmation field with `textContent` ONLY - never `innerHTML`, never raw markup, never a
+	 * live-data HTML blob (BLK-1/MED-9): live/remote data is attacker-influenceable and this origin holds the CSRF
+	 * token, so a typed/escaped path is the only safe one here.  Returns the pieces so the caller can wire buttons.
+	 */
+	function buildDialogOverlay(modal, action) {
+		const backdrop = document.createElement("div");
+		backdrop.className = "juneau-view-dialog-backdrop";
+		backdrop.setAttribute("data-testid", "dialog-backdrop");
+
+		const dialog = document.createElement("div");
+		dialog.className = "juneau-view-dialog";
+		dialog.setAttribute("role", "dialog");
+		dialog.setAttribute("aria-modal", "true");
+
+		const title = document.createElement("h2");
+		title.className = "juneau-view-dialog-title";
+		title.textContent = (modal && modal.title) || (action && (action.confirm || action.label || action.id)) || "Confirm";
+		dialog.appendChild(title);
+
+		if (modal && modal.fields && modal.fields.length) {
+			const dl = document.createElement("dl");
+			dl.className = "juneau-view-dialog-fields";
+			dl.setAttribute("data-testid", "dialog-fields");
+			modal.fields.forEach(function (f) {
+				const dt = document.createElement("dt");
+				dt.textContent = (f && f.label != null) ? String(f.label) : "";
+				const dd = document.createElement("dd");
+				dd.textContent = (f && f.value != null) ? String(f.value) : "";
+				dl.appendChild(dt);
+				dl.appendChild(dd);
+			});
+			dialog.appendChild(dl);
+		}
+
+		const actions = document.createElement("div");
+		actions.className = "juneau-view-dialog-actions";
+		const cancelBtn = document.createElement("button");
+		cancelBtn.type = "button";
+		cancelBtn.className = "juneau-view-dialog-cancel";
+		cancelBtn.textContent = "Cancel";
+		const confirmBtn = document.createElement("button");
+		confirmBtn.type = "button";
+		confirmBtn.className = "juneau-view-dialog-confirm";
+		confirmBtn.textContent = (action && action.label) || "Confirm";
+		actions.appendChild(cancelBtn);
+		actions.appendChild(confirmBtn);
+		dialog.appendChild(actions);
+
+		backdrop.appendChild(dialog);
+		return { backdrop: backdrop, dialog: dialog, confirmBtn: confirmBtn, cancelBtn: cancelBtn };
+	}
+
+	/** Shows a dialog overlay for an action and wires its confirm (submit) / cancel (dismiss) buttons. */
+	function showActionDialog(modal, action, table, tr, ctx) {
+		const ui = buildDialogOverlay(modal, action);
+		function close() { if (ui.backdrop.parentNode) ui.backdrop.parentNode.removeChild(ui.backdrop); }
+		ui.cancelBtn.addEventListener("click", close);
+		ui.confirmBtn.addEventListener("click", function () {
+			close();
+			submitActionDialog(modal, action, table, tr, ctx);
+		});
+		document.body.appendChild(ui.backdrop);
+		return ui;
+	}
+
+	/**
+	 * Issues the dialog's non-safe submit, carrying the server-minted idempotency key and the row's targetId so the
+	 * server can check the key's `(action, targetId)` binding (HIGH-8) - a double-click / re-submit / browser retry
+	 * therefore all carry the SAME key.  Delegates the fail-closed CSRF submit + in-flight marker + typed-result
+	 * settling to submitRowAction(...).
+	 */
+	function submitActionDialog(modal, action, table, tr, ctx) {
+		const extra = {};
+		const targetId = (tr && tr.getAttribute) ? tr.getAttribute("data-juneau-row-id") : null;
+		if (targetId != null) extra.targetId = targetId;
+		if (modal && modal.idempotencyKey != null) extra.idempotencyKey = modal.idempotencyKey;
+		submitRowAction(action, table, tr, ctx, extra);
+	}
+
+	/** 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;
+		let banner = cell.querySelector ? cell.querySelector(".juneau-view-action-refusal") : null;
+		if (!banner) {
+			banner = document.createElement("div");
+			banner.className = "juneau-view-action-refusal";
+			banner.setAttribute("role", "alert");
+			banner.setAttribute("data-testid", "action-refusal");
+			cell.appendChild(banner);
+		}
+		const name = (action && (action.label || action.id)) || "action";
+		banner.textContent = "Action '" + name + "' not sent: " + actionRefusalMessage(reason) + ".";
+	}
+
+	/**
+	 * Builds the row-action menu element (`<ul role="menu">`) from a view's rowActions.  Each item's activation
+	 * routes to submitRowAction(...); a safe-method or token-less action still renders (so the menu is honest
+	 * about what is declared) but visibly refuses on activation rather than silently doing nothing.
+	 */
+	function buildRowActionMenu(viewDef, table, tr, ctx) {
+		const menu = document.createElement("ul");
+		menu.className = "juneau-view-action-menu";
+		menu.setAttribute("role", "menu");
+		menu.setAttribute("data-testid", "action-menu");
+		(viewDef.rowActions || []).forEach(function (action) {
+			const li = document.createElement("li");
+			li.setAttribute("role", "none");
+			const item = document.createElement("button");
+			item.type = "button";
+			item.className = "juneau-view-action-item";
+			item.setAttribute("role", "menuitem");
+			item.setAttribute("data-action-id", action.id);
+			item.textContent = action.label || action.id;
+			item.addEventListener("click", function () {
+				closeRowActionMenus(table);
+				// A present=dialog action opens the modal (confirmation + optional form) before its submit;
+				// everything else is the direct fail-closed submit.
+				if (isDialogAction(action)) openActionDialog(action, table, tr, ctx);
+				else submitRowAction(action, table, tr, ctx);
+			});
+			li.appendChild(item);
+			menu.appendChild(li);
+		});
+		return menu;
+	}
+
+	/** Removes any open row-action menu under `table` (single-open invariant). */
+	function closeRowActionMenus(table) {
+		Array.prototype.forEach.call(table.querySelectorAll(".juneau-view-action-menu"), function (m) {
+			if (m.parentNode) m.parentNode.removeChild(m);
+		});
+	}
+
+	/**
+	 * Wires the row-action menu via ONE delegated click listener on `table` (rather than one per row): a click on
+	 * a row's `.juneau-view-action-trigger` toggles a menu of that view's rowActions for that row.  Mirrors the
+	 * details-expander's delegated-listener pattern above.
+	 */
+	function initRowActions(table, dt, viewDef, ctx) {
+		table.addEventListener("click", function (e) {
+			const trigger = e.target && e.target.closest ? e.target.closest(".juneau-view-action-trigger") : null;
+			if (!trigger) return;
+			const tr = trigger.closest("tr");
+			if (!tr) return;
+			const existing = tr.querySelector(".juneau-view-action-menu");
+			closeRowActionMenus(table);
+			if (existing) return;   // second click on an open menu's trigger closes it
+			const cell = trigger.closest(".juneau-view-actions-cell") || tr.lastElementChild;
+			if (cell) cell.appendChild(buildRowActionMenu(viewDef, table, tr, ctx));
+		});
+	}
+
 	// NOSONAR javascript:S3776 -- sequential wiring of one view table's DataTables instance, ribbon, paging pill,
 	// column search, details expander, and polling; several of these steps and their exact call order are pinned
 	// verbatim by the wiring canary tests below `functionBody(body, "function initTable(")`, so splitting them
@@ -730,6 +1735,29 @@
 		};
 
 		const opts = buildOptions(viewDef, deps);
+
+		// TODO-428: row selection is detected PURELY from the DOM attribute ViewTable stamped (SELECT_ATTR) -
+		// never from viewDef/VIEW_META (R2 guard).  When present, a synthetic LEADING selection column is
+		// unshifted (mirroring how a declared rowActions list appends a synthetic TRAILING one below) and every
+		// created row is additionally stamped with its stable id.
+		const selectionState = hasSelection(table)
+			? { selected: new Set(), rowIdField: table.getAttribute(ROW_ID_FIELD_ATTR) }
+			: null;
+		if (selectionState) {
+			opts.columns.unshift(buildSelectionColumnDef(selectionState));
+			const priorCreatedRow = opts.createdRow;
+			opts.createdRow = function (rowEl, rowData, index) {
+				if (priorCreatedRow) priorCreatedRow(rowEl, rowData, index);
+				stampRowId(rowEl, rowData, selectionState.rowIdField);
+			};
+		}
+
+		// A declared rowActions list added a synthetic trailing column in buildOptions; append its matching <th>
+		// BEFORE booting DataTables so header and column counts agree (DataTables errors on a mismatch).
+		if (viewDef.rowActions && viewDef.rowActions.length) {
+			appendActionsHeaderCell(table);
+		}
+
 		const dt = $(table).DataTable(opts);
 
 		// A declared `details` field list makes every row expandable.
@@ -751,6 +1779,35 @@
 			redraw: function () { dt.ajax ? dt.ajax.reload() : dt.draw(); }
 		};
 
+		// A declared rowActions list wires the per-row action menu + fail-closed CSRF submit (delegated listener).
+		if (viewDef.rowActions && viewDef.rowActions.length) {
+			initRowActions(table, dt, viewDef, ctx);
+		}
+
+		// TODO-428: selection wiring (checkbox toggle, select-all, off-screen-id-drop on every draw.dt) is wired
+		// whenever selection was declared - completely independent of whether bulk mutation was ALSO declared.
+		// The bulk toolbar itself (a SEPARATE, independent opt-in - hasBulk(...)) is only ever built INSIDE this
+		// same `if (selectionState)` branch, so a selection-only table (e.g. selectable-for-export) has no code
+		// path that can reach it (HIGH-5's compile/DOM-shape separability guarantee).
+		if (selectionState) {
+			initSelection(table, dt, selectionState, ctx);
+			if (hasBulk(table)) {
+				const bulkDef = readBulkDef(id);
+				if (!bulkDef) {
+					ctx._bulkError = "Juneau view '" + id + "': missing or malformed bulk-actions sidecar; bulk mutation withheld.";
+					error(ctx._bulkError);
+				} else if (bulkDef.contractVersion !== JUNEAU_BULK_CONTRACT_VERSION) {
+					ctx._bulkError = "Juneau view '" + id + "': bulk-actions contract version mismatch (page='" +
+						bulkDef.contractVersion + "', runtime='" + JUNEAU_BULK_CONTRACT_VERSION + "'); bulk mutation withheld.";
+					error(ctx._bulkError);
+				} else {
+					// Selection remains fully usable (e.g. for export) even when bulk mutation is withheld above -
+					// only the toolbar this branch builds is gated on a healthy sidecar.
+					ctx.bulkToolbar = buildBulkToolbar(bulkDef, table, ctx, selectionState);
+				}
+			}
+		}
+
 		const bar = NS.ribbon?.build ? NS.ribbon.build(viewDef, ctx) : null;
 
 		const columnSearchRow = buildColumnSearchRow(table, viewDef, dt);
@@ -769,6 +1826,18 @@
 		const wrapper = table.parentNode;
 		const toolbarRow = wrapper ? buildToolbarRow(wrapper, pill, bar) : null;
 
+		// The bulk toolbar (or its withheld-sidecar error) is inserted into the LEFT cluster, after the paging
+		// pill, as a follow-up DOM step - mirroring the staleness-indicator insertion below, which is likewise a
+		// post-hoc addition rather than a buildToolbarRow(...) parameter (keeping that function's own
+		// signature/tests untouched).
+		if (toolbarRow) {
+			const leftCluster = toolbarRow.querySelector(".juneau-view-toolbar-left");
+			if (leftCluster) {
+				if (ctx.bulkToolbar) leftCluster.appendChild(ctx.bulkToolbar.el);
+				else if (ctx._bulkError) renderInlineError(leftCluster, ctx._bulkError);
+			}
+		}
+
 		// A declared pollIntervalMs gets its own per-table staleness chip, inserted at the front of the RIGHT
 		// toolbar cluster (ahead of search/ribbon) without touching buildToolbarRow's own signature/tests.
 		if (viewDef.pollIntervalMs && toolbarRow) {
@@ -830,7 +1899,65 @@
 		// Row-details expander - exposed for manual verification.
 		buildDetailFields: buildDetailFields,
 		buildDetailPanel: buildDetailPanel,
-		initDetailsExpander: initDetailsExpander
+		initDetailsExpander: initDetailsExpander,
+		// Row actions + fail-closed CSRF submit - exposed for manual verification and the fail-closed canary.
+		DEFAULT_CSRF_HEADER: DEFAULT_CSRF_HEADER,
+		isSafeMethod: isSafeMethod,
+		isBlankToken: isBlankToken,
+		buildActionRequest: buildActionRequest,
+		actionRefusalMessage: actionRefusalMessage,
+		resolveCsrfToken: resolveCsrfToken,
+		resolveCsrfHeaderName: resolveCsrfHeaderName,
+		buildRowActionMenu: buildRowActionMenu,
+		submitRowAction: submitRowAction,
+		initRowActions: initRowActions,
+		// Declarative modal + typed action-result + in-flight lifecycle (TODO-416/417) - exposed for the canary
+		// and manual verification.
+		ACTION_RESULT_CONTRACT_VERSION: JUNEAU_ACTION_RESULT_CONTRACT_VERSION,
+		parseActionResult: parseActionResult,
+		normalizeOutcome: normalizeOutcome,
+		transportRefusal: transportRefusal,
+		actionOutcomeMessage: actionOutcomeMessage,
+		isDialogAction: isDialogAction,
+		setRowInFlight: setRowInFlight,
+		settleActionResponse: settleActionResponse,
+		mergeRowFromResult: mergeRowFromResult,
+		renderActionOutcome: renderActionOutcome,
+		openActionDialog: openActionDialog,
+		buildDialogOverlay: buildDialogOverlay,
+		showActionDialog: showActionDialog,
+		submitActionDialog: submitActionDialog,
+		// Async jobs + SSE streaming (TODO-425) - exposed for the canary and manual verification.  The job-running
+		// affordance (setRowJobRunning) is DISTINCT from the synchronous in-flight marker: it never freezes polling.
+		parseJobStarted: parseJobStarted,
+		buildJobCancelRequest: buildJobCancelRequest,
+		setRowJobRunning: setRowJobRunning,
+		renderJobProgress: renderJobProgress,
+		clearJobProgress: clearJobProgress,
+		startJobStream: startJobStream,
+		finishJobFromResult: finishJobFromResult,
+		cancelJob: cancelJob,
+		// Row selection + bulk mutation (TODO-428) - two independent opt-ins - exposed for the canary and manual
+		// verification.  BULK_CONTRACT_VERSION is exposed above (NS.BULK_CONTRACT_VERSION) alongside the other two
+		// independently-versioned contracts.
+		SELECT_ATTR: SELECT_ATTR,
+		ROW_ID_ATTR: ROW_ID_ATTR,
+		ROW_ID_FIELD_ATTR: ROW_ID_FIELD_ATTR,
+		SELECT_ALL_ATTR: SELECT_ALL_ATTR,
+		BULK_ATTR: BULK_ATTR,
+		BULK_SIDECAR_ID_PREFIX: BULK_SIDECAR_ID_PREFIX,
+		rowIdOf: rowIdOf,
+		pruneSelection: pruneSelection,
+		hasSelection: hasSelection,
+		hasBulk: hasBulk,
+		stampRowId: stampRowId,
+		selectionCellMarkup: selectionCellMarkup,
+		buildSelectionColumnDef: buildSelectionColumnDef,
+		initSelection: initSelection,
+		readBulkDef: readBulkDef,
+		buildBulkToolbar: buildBulkToolbar,
+		executeBulkAction: executeBulkAction,
+		renderInlineError: renderInlineError
 	};
 
 	if (document.readyState === "loading") {
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
new file mode 100644
index 0000000000..97d58f4875
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
@@ -0,0 +1,156 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Golden-fixture contract test for the typed {@code ActionResult} wire format (design doc §6.1/§6.2; the write-path
+ * half of {@code TODO-416}).
+ *
+ * <p>
+ * This pins the third independently-versioned wire contract of the toolkit (alongside {@code VIEW_META} and
+ * {@code PAGE_META}): its own {@link ActionResult#CONTRACT_VERSION}, its frozen top-level key order, the reserved
+ * async outcome set, and the omit-when-unset rule for every optional field.  Serialization uses the same canonical
+ * compact JSON marshaller ({@link Json#of(Object)}) the runtime reads.
+ */
+class ActionResult_Contract_Test extends TestBase {
+
+	//------------------------------------------------------------------------------------------------------------------
+	// The frozen ActionResult contract
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_success_serializesToFrozenShape() {
+		var row = new LinkedHashMap<String,Object>();
+		row.put("id", "INC-1");
+		row.put("status", "acknowledged");
+		var json = Json.of(ActionResult.success(row));
+
+		var expected = Json.to("{\"contractVersion\":\"1\",\"outcome\":\"success\",\"row\":{\"id\":\"INC-1\",\"status\":\"acknowledged\"}}", Map.class);
+		var actual = Json.to(json, Map.class);
+		assertEquals(expected, actual, json);
+	}
+
+	@Test void a02_contractVersion_isItsOwnValue_notAliasedToViewDef() {
+		// The whole point of MED-7: this is a SEPARATE contract from VIEW_META, so its version must not track ViewDef's.
+		assertEquals("1", ActionResult.CONTRACT_VERSION);
+		assertNotEquals(ViewDef.CONTRACT_VERSION, ActionResult.CONTRACT_VERSION,
+			"ActionResult.CONTRACT_VERSION must be its own value, never aliased to ViewDef.CONTRACT_VERSION");
+	}
+
+	@Test void a03_topLevelKeyOrder_success() {
+		Map<?,?> actual = Json.to(Json.of(ActionResult.success(Map.of("id", "x"))), Map.class);
+		assertEquals(List.of("contractVersion", "outcome", "row"), new ArrayList<>(actual.keySet()));
+	}
+
+	@Test void a04_topLevelKeyOrder_fullResult_matchesBeanTypeOrder() {
+		// contractVersion,outcome,replay,refusalCode,message,row (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()));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Outcome discriminator + reserved async terminal states (MED-8)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_syncOutcomeWireTokens() {
+		assertEquals("success", ActionResult.Outcome.SUCCESS.wire());
+		assertEquals("failure", ActionResult.Outcome.FAILURE.wire());
+		assertEquals("refusal", ActionResult.Outcome.REFUSAL.wire());
+		assertEquals("unknown", ActionResult.Outcome.UNKNOWN.wire());
+	}
+
+	@Test void b02_reservedAsyncOutcomes_areFrozenNow() {
+		// Reserved from day one so TODO-425 (async) cannot force a second result-contract bump.
+		assertEquals("cancelled", ActionResult.Outcome.CANCELLED.wire());
+		assertEquals("cancelled-after-effect", ActionResult.Outcome.CANCELLED_AFTER_EFFECT.wire());
+		assertEquals(
+			List.of("SUCCESS", "FAILURE", "REFUSAL", "UNKNOWN", "CANCELLED", "CANCELLED_AFTER_EFFECT"),
+			Arrays.stream(ActionResult.Outcome.values()).map(Enum::name).toList());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Refusals are an opaque, namespaced code (HIGH-2)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_refusal_carriesOpaqueCode_noRowNoReplay() {
+		var json = Json.of(ActionResult.refusal("write-guard:not-armed"));
+		var actual = Json.to(json, Map.class);
+		assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"refusal\",\"refusalCode\":\"write-guard:not-armed\"}", Map.class), actual, json);
+	}
+
+	@Test void c02_refusal_rendersAnyNamespace() {
+		// The runtime renders ANY code visibly; the type accepts framework codes and consumer app:/write-guard: codes alike.
+		assertTrue(Json.of(ActionResult.refusal("CSRF_TOKEN_MISSING")).contains("\"refusalCode\":\"CSRF_TOKEN_MISSING\""));
+		assertTrue(Json.of(ActionResult.refusal("app:custom-thing")).contains("\"refusalCode\":\"app:custom-thing\""));
+	}
+
+	@Test void c03_refusal_blankCodeThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> ActionResult.refusal("  "));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+		assertThrows(IllegalArgumentException.class, () -> ActionResult.refusal(null));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Omit-when-unset for every optional field
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_failure_omitsRowReplayRefusalMessage() {
+		var json = Json.of(ActionResult.failure());
+		assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"failure\"}", Map.class), Json.to(json, Map.class), json);
+		for (var k : List.of("row", "replay", "refusalCode", "message"))
+			assertFalse(json.contains("\"" + k + "\""), () -> "unset field leaked: " + k + "\n" + json);
+	}
+
+	@Test void d02_unknown_isHonestTerminalState() {
+		var json = Json.of(ActionResult.unknown());
+		assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"unknown\"}", Map.class), Json.to(json, Map.class), json);
+	}
+
+	@Test void d03_replayFalseOrNull_isOmitted() {
+		assertFalse(Json.of(ActionResult.success(Map.of("id", "x")).replay(false)).contains("\"replay\""));
+		assertFalse(Json.of(ActionResult.success(Map.of("id", "x")).replay(null)).contains("\"replay\""));
+	}
+
+	@Test void d04_replayTrue_isEmitted() {
+		assertTrue(Json.of(ActionResult.success(Map.of("id", "x")).replay(true)).contains("\"replay\":true"));
+	}
+
+	@Test void d05_successWithNullRow_omitsRow() {
+		// A redraw/navigate success carries no row.
+		var json = Json.of(ActionResult.success(null));
+		assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"success\"}", Map.class), Json.to(json, Map.class), json);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Fluent setters
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_fluentSetters() {
+		var r = ActionResult.failure().message("boom").refusalCode("app:x").row(Map.of("id", "1"));
+		assertEquals("boom", r.message);
+		assertEquals("app:x", r.refusalCode);
+		assertEquals(Map.of("id", "1"), r.row);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
new file mode 100644
index 0000000000..78000040be
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
@@ -0,0 +1,193 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.stream.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit test for the in-memory {@link AsyncJobRegistry} (design doc §6.3; {@code TODO-425}, Q2).
+ *
+ * <p>
+ * Pins the load-bearing server-side controls: the unguessable {@value AsyncJobRegistry#CAPABILITY_BITS}-bit
+ * capability id (HIGH-4), the concurrent-job cap, the enumeration-resistant lookup, and the deterministic
+ * hard-timeout sweep + retention reaping (driven by an injected clock rather than wall time).
+ */
+@SuppressWarnings({
+	"resource" // AsyncJobRegistry is AutoCloseable; test-fixture lifecycle is managed by the test, not a real leak (mixed-module resource analysis on test code).
+})
+class AsyncJobRegistry_Test extends TestBase {
+
+	/** A test clock whose instant is advanced explicitly, so timeout sweeps are deterministic (no wall-clock waits). */
+	private static final class TestClock extends Clock {
+		private Instant now;
+		TestClock(Instant now) { this.now = now; }
+		void set(Instant v) { now = v; }
+		@Override public Instant instant() { return now; }
+		@Override public ZoneId getZone() { return ZoneOffset.UTC; }
+		@Override public Clock withZone(ZoneId z) { return this; }
+	}
+
+	private static final Instant T0 = Instant.parse("2026-08-19T00:00:00Z");
+
+	/** A registry with no background scheduler (timeouts are enforced only by explicit sweeps) on the given clock. */
+	private static AsyncJobRegistry registry(TestClock clock) {
+		return new AsyncJobRegistry(clock, Duration.ofSeconds(120), AsyncJobRegistry.MAX_OUTPUT_BYTES,
+			AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, null);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) The capability id is unguessable: 256-bit SecureRandom, hex, unique (HIGH-4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_capabilityIdIs256BitHex() {
+		var id = registry(new TestClock(T0)).create().id();
+		assertEquals(64, id.length(), id);                 // 32 bytes -> 64 lowercase hex chars
+		assertTrue(id.matches("[0-9a-f]{64}"), id);
+		assertEquals(256, AsyncJobRegistry.CAPABILITY_BITS);
+	}
+
+	@Test void a02_capabilityIdsAreUnique() {
+		var r = registry(new TestClock(T0));
+		// Settle each job as it is minted so the concurrency cap never blocks the sample (a terminal job frees its
+		// running slot); we are pinning id uniqueness, not the cap (that is c01).
+		var seen = Stream.generate(() -> {
+				var job = r.create();
+				job.complete(ActionResult.success(null));
+				return job.id();
+			}).limit(50).collect(Collectors.toSet());
+		assertEquals(50, seen.size(), "minted capability ids must be unique (SecureRandom)");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Enumeration-resistant lookup: an unknown id is empty, indistinguishable from any other (HIGH-4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_getUnknownIsEmpty() {
+		var r = registry(new TestClock(T0));
+		var job = r.create();
+		assertTrue(r.get(job.id()).isPresent());
+		assertTrue(r.get("deadbeef").isEmpty());
+		assertTrue(r.get("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef").isEmpty());
+		assertTrue(r.get(null).isEmpty());
+		assertTrue(r.get("  ").isEmpty());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) Concurrent-job cap: the 9th is refused; a freed slot re-opens it (MED-6)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_concurrentJobCapRefusesNinth() {
+		var r = registry(new TestClock(T0));
+		var jobs = new ArrayList<AsyncJob>();
+		for (var i = 0; i < AsyncJobRegistry.MAX_CONCURRENT_JOBS; i++)
+			jobs.add(r.tryCreate().orElseThrow());
+		assertEquals(8, AsyncJobRegistry.MAX_CONCURRENT_JOBS);
+		assertEquals(8, r.runningCount());
+		assertTrue(r.tryCreate().isEmpty(), "9th concurrent job must be refused");
+		// create() (the throwing form) signals the same cap.
+		assertThrows(IllegalStateException.class, r::create);
+		// Settling one frees a slot (terminal jobs do not count as concurrent).
+		jobs.get(0).complete(ActionResult.success(null));
+		assertEquals(7, r.runningCount());
+		assertTrue(r.tryCreate().isPresent(), "a settled job frees a concurrency slot");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Hard-timeout sweep (deterministic via the injected clock) - a data-egress bound (HIGH-4/MED-6/Task-11)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_sweepTimesOutExpiredJobs() {
+		var clock = new TestClock(T0);
+		var r = registry(clock);
+		var job = r.create();
+		clock.set(T0.plusSeconds(121));
+		r.sweepTimeouts();
+		assertTrue(job.isTerminal());
+		assertEquals("unknown", job.result().outcome);
+	}
+
+	@Test void d02_tryCreateSweepsSoAnExpiredJobFreesItsSlot() {
+		var clock = new TestClock(T0);
+		var r = registry(clock);
+		for (var i = 0; i < AsyncJobRegistry.MAX_CONCURRENT_JOBS; i++)
+			r.tryCreate().orElseThrow();
+		assertTrue(r.tryCreate().isEmpty());               // full
+		clock.set(T0.plusSeconds(121));                    // every job is now past its deadline
+		assertTrue(r.tryCreate().isPresent(), "tryCreate must sweep expired jobs and reopen a slot");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) Retention reaping: a terminal job is kept for re-attach, then reaped
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_terminalJobRetainedThenReaped() {
+		var clock = new TestClock(T0);
+		var r = registry(clock);
+		var job = r.create();
+		job.complete(ActionResult.success(null));
+		// Within retention: still present (a page reload can re-attach and read the result).
+		clock.set(T0.plusSeconds(30));
+		r.sweepTimeouts();
+		assertTrue(r.get(job.id()).isPresent());
+		// Past retention: reaped from the heap.
+		clock.set(T0.plus(AsyncJobRegistry.RETENTION).plusSeconds(1));
+		r.sweepTimeouts();
+		assertTrue(r.get(job.id()).isEmpty());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// f) Cancel by id (Q4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_cancelById() {
+		var r = registry(new TestClock(T0));
+		var job = r.create();
+		var cancelled = r.cancel(job.id());
+		assertTrue(cancelled.isPresent());
+		assertEquals("cancelled", cancelled.get().result().outcome);
+		assertTrue(r.cancel("no-such-id").isEmpty());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// g) A registry-minted job carries the configured subscriber cap (MED-6)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void g01_mintedJobCarriesSubscriberCap() {
+		var job = registry(new TestClock(T0)).create();
+		assertEquals(AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, job.maxSubscribers());
+		assertEquals(AsyncJobRegistry.MAX_OUTPUT_BYTES, job.maxOutputBytes());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// h) The default (production) registry mints a private daemon scheduler that closes cleanly
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void h01_defaultRegistryCreatesAndCloses() {
+		try (var r = new AsyncJobRegistry()) {
+			var job = r.create();
+			assertNotNull(job.id());
+			assertEquals(120, AsyncJobRegistry.HARD_TIMEOUT.getSeconds());
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_BrowserTest.java
new file mode 100644
index 0000000000..69acc98055
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_BrowserTest.java
@@ -0,0 +1,223 @@
+/*
+ * 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 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.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The async-job + SSE-streaming half of the module's <b>JavaScript-execution harness</b>: runs the REAL served
+ * {@code juneau-views.js} in a real headless browser and asserts the TODO-425 behavior as a user would experience it
+ * &mdash; an async row action's start POST returns a job pointer, the row picks up the DISTINCT
+ * {@code data-juneau-job} affordance (never {@code data-juneau-inflight}, so the table KEEPS polling), streamed
+ * progress renders in a visible {@code textContent} banner, the single terminal {@code result} event settles the row
+ * to success / cancelled / cancelled-after-effect, a stream error settles it to a non-optimistic unknown, and Cancel
+ * issues a fail-closed CSRF POST to the job's cancel URL.
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_AsyncJobs_Test} proves the shipped script <i>contains</i> the async/streaming logic; it cannot
+ * prove that at runtime a running job leaves table polling live (the load-bearing HIGH-9 fact), that progress and the
+ * terminal outcome actually paint, or that a stream error degrades to a visible unknown.  This canary drives the real
+ * runtime in Chromium against a controllable fake {@code EventSource}, so those user-visible facts are measured
+ * rather than inferred.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  It reuses that profile's provisioned Node + Playwright browser and derives its own prober
+ * ({@code async-job.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is needed.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ModalResult_BrowserTest} &mdash; the sibling declarative-modal/typed-result canary.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=AsyncJob_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class AsyncJob_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("async-job.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.VIEWS_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("async-job.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("async-job-stdout.json");
+		var stderr = dir.resolve("async-job-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@SuppressWarnings("unchecked")
+	private static Map<String,Object> sub(String key) {
+		return (Map<String,Object>) report.get(key);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) The runtime loaded, at the typed-action-result contract version, with no console errors
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_runtimeLoaded() {
+		assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+		assertEquals(ActionResult.CONTRACT_VERSION, report.get("actionResultContractVersion"), () -> report.toString());
+		assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) HIGH-9: a running job uses the DISTINCT marker and does NOT freeze the table's polling
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_runningJobUsesDistinctMarker_andDoesNotFreezePolling() {
+		var r = sub("running");
+		assertEquals(Boolean.TRUE, r.get("hasJobMarker"), () -> "row did not pick up data-juneau-job: " + report);
+		// The whole point of HIGH-9: a job must NOT set the synchronous in-flight marker that freezes polling.
+		assertEquals(Boolean.FALSE, r.get("hasInflightMarker"), () -> "job wrongly set data-juneau-inflight: " + report);
+		assertEquals(Boolean.FALSE, r.get("pollingFrozen"), () -> "polling was frozen during the job (HIGH-9 violation): " + report);
+		assertEquals(Boolean.TRUE, r.get("progressVisible"), () -> "job progress banner not visible: " + report);
+		assertEquals("Working\u2026", r.get("progressText"), () -> report.toString());
+		assertEquals(Boolean.TRUE, r.get("triggerDisabled"), () -> "trigger not disabled while job runs: " + report);
+		assertEquals(Boolean.TRUE, r.get("esOpened"), () -> "no EventSource opened: " + report);
+		assertEquals("/juneau-jobs/cap/stream", r.get("esUrl"), () -> "EventSource opened on the wrong (capability) URL: " + report);
+	}
+
+	@Test void b02_progressEventUpdatesBanner_andPollingStaysLive() {
+		var p = sub("progress");
+		assertEquals("step 2 of 3", p.get("text"), () -> "streamed progress did not render: " + report);
+		assertEquals(Boolean.FALSE, p.get("pollingFrozenDuringProgress"), () -> "polling froze during progress: " + report);
+		assertEquals(Boolean.FALSE, p.get("outcomeYet"), () -> "an outcome rendered before the terminal event: " + report);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) The terminal `result` event settles the row (success): banner cleared, marker removed, trigger re-enabled
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_terminalResultSettlesRowToSuccess() {
+		var s = sub("settledSuccess");
+		assertEquals(Boolean.TRUE, s.get("esClosed"), () -> "EventSource not closed on settle: " + report);
+		assertEquals(Boolean.TRUE, s.get("jobMarkerCleared"), () -> "data-juneau-job not cleared on settle: " + report);
+		assertEquals(Boolean.TRUE, s.get("progressCleared"), () -> "progress banner not cleared on settle: " + report);
+		assertEquals(Boolean.TRUE, s.get("triggerReEnabled"), () -> "trigger not re-enabled on settle: " + report);
+		assertEquals(Boolean.FALSE, s.get("pollingFrozen"), () -> "polling frozen after settle: " + report);
+		assertNotNull(s.get("mergedRow"), () -> "success did not re-render from the authoritative result row: " + report);
+		@SuppressWarnings("unchecked")
+		var outcome = (Map<String,Object>) s.get("outcome");
+		assertEquals(Boolean.TRUE, outcome.get("visible"), () -> "success banner not visible: " + report);
+		assertEquals("success", outcome.get("state"), () -> report.toString());
+		assertEquals("status", outcome.get("role"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Cancel issues a fail-closed CSRF POST; the SERVER-authoritative terminal outcome arrives over SSE (Q4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_cancelIssuesFailClosedCsrfPost() {
+		var cancel = sub("cancel");
+		assertEquals(Boolean.TRUE, cancel.get("cancelBtnVisible"), () -> "cancel button not visible: " + report);
+		assertEquals(Boolean.TRUE, cancel.get("postIssued"), () -> "cancel did not issue a POST: " + report);
+		assertEquals("/juneau-jobs/cap/cancel", cancel.get("url"), () -> "cancel POST hit the wrong URL: " + report);
+		assertEquals("POST", cancel.get("method"), () -> "cancel must be a non-safe POST: " + report);
+		assertEquals("tok-xyz", cancel.get("csrfHeader"), () -> "cancel POST did not carry the CSRF token: " + report);
+	}
+
+	@Test void d02_cancelledOutcomeArrivesOverStream() {
+		var cancel = sub("cancel");
+		@SuppressWarnings("unchecked")
+		var outcome = (Map<String,Object>) cancel.get("outcome");
+		assertEquals("cancelled", outcome.get("state"), () -> report.toString());
+		assertEquals(Boolean.TRUE, outcome.get("visible"), () -> "cancelled banner not visible: " + report);
+		assertTrue(String.valueOf(outcome.get("text")).contains("Cancelled"), () -> report.toString());
+		assertEquals(Boolean.TRUE, cancel.get("jobMarkerCleared"), () -> "job marker not cleared on cancel: " + report);
+	}
+
+	@Test void d03_cancelledAfterEffectIsADistinctOutcome() {
+		// Q4: cancelled-after-effect must NOT be collapsed into plain cancelled - it is its own rendered outcome.
+		var o = sub("cancelledAfterEffect");
+		assertEquals("cancelled-after-effect", o.get("state"), () -> report.toString());
+		assertEquals(Boolean.TRUE, o.get("visible"), () -> "cancelled-after-effect banner not visible: " + report);
+		assertTrue(String.valueOf(o.get("text")).contains("partial effect"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) A stream error is itself a non-optimistic terminal outcome; polling was never frozen
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_streamErrorRendersVisibleUnknown() {
+		var s = sub("streamError");
+		@SuppressWarnings("unchecked")
+		var outcome = (Map<String,Object>) s.get("outcome");
+		assertEquals("unknown", outcome.get("state"), () -> report.toString());
+		assertEquals(Boolean.TRUE, outcome.get("visible"), () -> "stream-error unknown banner not visible: " + report);
+		assertEquals(Boolean.TRUE, s.get("jobMarkerCleared"), () -> "job marker not cleared on stream error: " + report);
+		assertEquals(Boolean.FALSE, s.get("pollingFrozen"), () -> "polling frozen after stream error: " + report);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_Test.java
new file mode 100644
index 0000000000..1d656527ba
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_Test.java
@@ -0,0 +1,205 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit test for the in-memory one-shot {@link AsyncJob} (design doc §6.3; {@code TODO-425}).
+ *
+ * <p>
+ * Covers the server-side hard limits that are BOTH heap bounds and Task-11 data-egress disclosure bounds (MED-6):
+ * the per-job output cap and the hard timeout; the reserved async terminal outcomes (cancelled vs
+ * cancelled-after-effect, Q4); the subscriber cap; and the progress buffering / await used by the SSE stream.
+ */
+class AsyncJob_Test extends TestBase {
+
+	private static AsyncJob job() {
+		return job(AsyncJobRegistry.MAX_OUTPUT_BYTES, AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, Duration.ofSeconds(120));
+	}
+
+	private static AsyncJob job(long maxOutputBytes, int maxSubscribers, Duration timeout) {
+		return new AsyncJob("cap-id", Instant.parse("2026-08-19T00:00:00Z"), timeout, maxOutputBytes, maxSubscribers);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) Progress buffering + re-attach snapshot
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_progressBuffersEvents() {
+		var j = job();
+		assertTrue(j.progress("one"));
+		assertTrue(j.progress("two"));
+		assertEquals(2, j.eventCount());
+		assertEquals(java.util.List.of("one", "two"), j.eventsFrom(0));
+		assertEquals(java.util.List.of("two"), j.eventsFrom(1));
+		assertEquals(java.util.List.of(), j.eventsFrom(5));
+	}
+
+	@Test void a02_idAccessor() {
+		assertEquals("cap-id", job().id());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Terminal settling (first settle wins) + reserved async outcomes (Q4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_completeSettlesOnce() {
+		var j = job();
+		assertTrue(j.complete(ActionResult.success(null)));
+		assertTrue(j.isTerminal());
+		assertEquals("success", j.result().outcome);
+		// A second settle is a no-op; the first outcome stands.
+		assertFalse(j.complete(ActionResult.failure()));
+		assertEquals("success", j.result().outcome);
+	}
+
+	@Test void b02_cancelBeforeEffectIsCancelled() {
+		var j = job();
+		assertTrue(j.cancel());
+		assertEquals("cancelled", j.result().outcome);
+	}
+
+	@Test void b03_cancelAfterEffectIsCancelledAfterEffect() {
+		// Q4: "cancelled" and "cancelled-after-effect" are DIFFERENT outcomes and must not be collapsed.
+		var j = job();
+		j.markEffectStarted();
+		assertTrue(j.effectStarted());
+		assertTrue(j.cancel());
+		assertEquals("cancelled-after-effect", j.result().outcome);
+	}
+
+	@Test void b04_cancelAfterTerminalIsNoOp() {
+		var j = job();
+		j.complete(ActionResult.success(null));
+		assertFalse(j.cancel());
+		assertEquals("success", j.result().outcome);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) Output cap - a data-egress disclosure bound, enforced server-side (MED-6)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_outputCapTerminatesJobAndDropsExcess() {
+		var j = job(10, 2, Duration.ofSeconds(120));
+		assertTrue(j.progress("12345"));           // 5 bytes -> ok
+		assertFalse(j.progress("123456"));         // +6 -> 11 > 10 -> terminate, drop this event
+		assertTrue(j.isTerminal());
+		assertEquals("failure", j.result().outcome);
+		assertTrue(j.result().message.contains("output-limit"), j.result().message);
+		assertEquals(1, j.eventCount(), "the over-cap event must be dropped, not streamed");
+		// Once terminal, further progress is a no-op.
+		assertFalse(j.progress("x"));
+		assertEquals(1, j.eventCount());
+	}
+
+	@Test void c02_outputBytesTracked() {
+		var j = job();
+		j.progress("abc");   // 3 bytes
+		j.progress("de");    // 2 bytes
+		assertEquals(5, j.outputBytes());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Hard timeout - a data-egress disclosure bound, enforced server-side (HIGH-4/MED-6/Task-11)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_timeoutBeforeDeadlineIsNoOp() {
+		var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+		assertFalse(j.enforceTimeout(j.createdAt().plusSeconds(119)));
+		assertFalse(j.isTerminal());
+	}
+
+	@Test void d02_timeoutPastDeadlineSettlesUnknown() {
+		var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+		assertTrue(j.enforceTimeout(j.createdAt().plusSeconds(121)));
+		assertTrue(j.isTerminal());
+		assertEquals("unknown", j.result().outcome);
+		assertTrue(j.result().message.contains("hard-timeout"), j.result().message);
+	}
+
+	@Test void d03_deadlineIsCreatedAtPlusTimeout() {
+		var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+		assertEquals(j.createdAt().plusSeconds(120), j.deadline());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) awaitUpdate: drains a terminal job in one pass; blocks then wakes for a live event
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_awaitUpdateReturnsBufferedEventsAndResultForTerminalJob() {
+		var j = job();
+		j.progress("p1");
+		j.progress("p2");
+		j.complete(ActionResult.success(null));
+		var u = j.awaitUpdate(0, Duration.ofMillis(10));
+		assertEquals(java.util.List.of("p1", "p2"), u.events());
+		assertNotNull(u.result());
+		assertEquals("success", u.result().outcome);
+	}
+
+	@Test void e02_awaitUpdateBlocksThenWakesOnLiveEvent() throws Exception {
+		var j = job();
+		var pool = Executors.newSingleThreadScheduledExecutor();
+		try {
+			pool.schedule(() -> j.progress("live"), 50, TimeUnit.MILLISECONDS);
+			var u = j.awaitUpdate(0, Duration.ofSeconds(2));   // blocks until the scheduled progress arrives
+			assertEquals(java.util.List.of("live"), u.events());
+			assertNull(u.result());
+		} finally {
+			pool.shutdownNow();
+		}
+	}
+
+	@Test void e03_awaitUpdateTimesOutEmptyWhileRunning() {
+		var j = job();
+		var u = j.awaitUpdate(0, Duration.ofMillis(20));
+		assertEquals(java.util.List.of(), u.events());
+		assertNull(u.result());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// f) Subscriber cap (MED-6)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_subscriberCapEnforced() {
+		var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+		assertTrue(j.acquireSubscriber());
+		assertTrue(j.acquireSubscriber());
+		assertFalse(j.acquireSubscriber(), "3rd subscriber must be refused (cap=2)");
+		assertEquals(2, j.subscriberCount());
+		j.releaseSubscriber();
+		assertTrue(j.acquireSubscriber(), "a freed slot can be re-acquired");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// g) The id is a secret - toString must not leak it
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void g01_toStringRedactsId() {
+		var j = new AsyncJob("super-secret-capability", Instant.now(), Duration.ofSeconds(120), 1024, 2);
+		var s = j.toString();
+		assertFalse(s.contains("super-secret-capability"), s);
+		assertTrue(s.contains("<redacted>"), s);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobsMixin_Serving_Test.java
new file mode 100644
index 0000000000..35e8ee5a37
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobsMixin_Serving_Test.java
@@ -0,0 +1,175 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Serving test for {@link AsyncJobsMixin} &mdash; the SSE progress stream and cancel endpoints (design doc §6.3;
+ * {@code TODO-425}).
+ *
+ * <p>
+ * Drives the two endpoints through a {@link MockRestClient}: the stream replays a completed job's buffered progress
+ * plus its terminal {@code result} event (the page-reload re-attach path, Q3); an unknown capability id is a
+ * {@code 404} (enumeration-resistant, HIGH-4); a job already at its subscriber cap is a {@code 429} (MED-6); and
+ * cancel returns the terminal {@code cancelled} / {@code cancelled-after-effect} outcome (Q4).
+ *
+ * <p>
+ * Each SSE assertion completes the job <b>before</b> subscribing, so the stream drains in one deterministic pass
+ * with no wall-clock waits (a still-running job would legitimately hold the stream open until its hard timeout).
+ */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class AsyncJobsMixin_Serving_Test extends TestBase {
+
+	@Rest
+	public static class R extends BasicRestServlet implements AsyncJobsMixin {
+		private static final long serialVersionUID = 1L;
+
+		// No background scheduler: the serving tests do not exercise wall-clock timeout, so nothing runs off-thread.
+		static final AsyncJobRegistry REGISTRY = new AsyncJobRegistry(Clock.systemUTC(), Duration.ofSeconds(120),
+			AsyncJobRegistry.MAX_OUTPUT_BYTES, AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, null);
+
+		static volatile AsyncJob lastJob;
+
+		@Override public AsyncJobRegistry asyncJobRegistry() { return REGISTRY; }
+
+		/** Test-only: starts a job (as a consumer's async row-action POST would) and returns its AsyncJobRef pointer. */
+		@RestPost(path="/start") public AsyncJobRef start() {
+			var job = REGISTRY.create();
+			lastJob = job;
+			return AsyncJobRef.of(job);
+		}
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(R.class);
+
+	private static String startJob() throws Exception {
+		var body = c.post("/start", "").header("Accept", "application/json").run().assertStatus(200).getContent().asString();
+		return String.valueOf(Json.to(body, Map.class).get("jobId"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) The job-started envelope is an AsyncJobRef with capability URLs embedding the job id (no RowAction field)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_startReturnsAsyncJobRefWithCapabilityUrls() throws Exception {
+		var body = c.post("/start", "").header("Accept", "application/json").run().assertStatus(200).getContent().asString();
+		var ref = Json.to(body, Map.class);
+		var jobId = String.valueOf(ref.get("jobId"));
+		assertTrue(jobId.matches("[0-9a-f]{64}"), jobId);
+		assertEquals("servlet:" + AsyncJobsMixin.streamPath(jobId), ref.get("streamUrl"));
+		assertEquals("servlet:" + AsyncJobsMixin.cancelPath(jobId), ref.get("cancelUrl"));
+		// The pointer carries NO ActionResult outcome discriminator - the two 2xx shapes are disjoint.
+		assertNull(ref.get("outcome"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) The stream replays buffered progress + one terminal result event (re-attach, Q3)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_streamReplaysProgressThenResult() throws Exception {
+		var jobId = startJob();
+		var job = R.lastJob;
+		job.progress("step 1 of 2");
+		job.progress("step 2 of 2");
+		job.complete(ActionResult.success(Map.of("id", "INC-1", "status", "acknowledged")));
+
+		var resp = c.get(AsyncJobsMixin.streamPath(jobId)).header("Accept", "text/event-stream").run().assertStatus(200);
+		resp.assertHeader("Content-Type").isContains("text/event-stream");
+		var body = resp.getContent().asString();
+		assertTrue(body.contains("event: progress"), body);
+		assertTrue(body.contains("data: step 1 of 2"), body);
+		assertTrue(body.contains("data: step 2 of 2"), body);
+		assertTrue(body.contains("event: result"), body);
+		assertTrue(body.contains("\"outcome\":\"success\""), body);
+		assertTrue(body.contains("INC-1"), body);
+	}
+
+	@Test void b02_streamOfCancelledJobCarriesCancelledResult() throws Exception {
+		var jobId = startJob();
+		R.lastJob.progress("working");
+		R.lastJob.cancel();
+		var body = c.get(AsyncJobsMixin.streamPath(jobId)).header("Accept", "text/event-stream").run()
+			.assertStatus(200).getContent().asString();
+		assertTrue(body.contains("event: result"), body);
+		assertTrue(body.contains("\"outcome\":\"cancelled\""), body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) Capability gating: an unknown id is 404 (enumeration-resistant, HIGH-4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_unknownJobIdStreamIs404() throws Exception {
+		c.get(AsyncJobsMixin.streamPath("00000000000000000000000000000000")).header("Accept", "text/event-stream")
+			.run().assertStatus(404);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Subscriber cap: a job already at its cap answers 429 (MED-6)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_subscriberCapReturns429() throws Exception {
+		var jobId = startJob();
+		var job = R.lastJob;
+		// Pre-occupy both subscriber slots so the endpoint's own acquire fails; complete so the stream would drain.
+		assertTrue(job.acquireSubscriber());
+		assertTrue(job.acquireSubscriber());
+		job.complete(ActionResult.success(null));
+		c.get(AsyncJobsMixin.streamPath(jobId)).header("Accept", "text/event-stream").run().assertStatus(429);
+		job.releaseSubscriber();
+		job.releaseSubscriber();
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) Cancel returns the terminal outcome, distinguishing cancelled from cancelled-after-effect (Q4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_cancelReturnsCancelled() throws Exception {
+		var jobId = startJob();
+		var body = c.post(AsyncJobsMixin.cancelPath(jobId), "").header("Accept", "application/json").run()
+			.assertStatus(200).getContent().asString();
+		assertTrue(body.contains("\"outcome\":\"cancelled\""), body);
+		assertFalse(body.contains("cancelled-after-effect"), body);
+	}
+
+	@Test void e02_cancelAfterEffectReturnsCancelledAfterEffect() throws Exception {
+		var jobId = startJob();
+		R.lastJob.markEffectStarted();
+		var body = c.post(AsyncJobsMixin.cancelPath(jobId), "").header("Accept", "application/json").run()
+			.assertStatus(200).getContent().asString();
+		assertTrue(body.contains("\"outcome\":\"cancelled-after-effect\""), body);
+	}
+
+	@Test void e03_cancelUnknownJobIs404AndNamedRefusal() throws Exception {
+		var body = c.post(AsyncJobsMixin.cancelPath("00000000000000000000000000000000"), "")
+			.header("Accept", "application/json").run().assertStatus(404).getContent().asString();
+		assertTrue(body.contains("\"outcome\":\"refusal\""), body);
+		assertTrue(body.contains("app:unknown-job"), body);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/IdempotencyKey_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/IdempotencyKey_Test.java
new file mode 100644
index 0000000000..456d4ce906
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/IdempotencyKey_Test.java
@@ -0,0 +1,116 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.util.stream.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Test for the server-minted, {@code (action, targetId)}-bound {@link IdempotencyKey} (design doc §6.2; the
+ * idempotency half of {@code TODO-416}, HIGH-8).
+ *
+ * <p>
+ * The security property is the <b>binding</b>, not the width: a key submitted against a different action or target
+ * than it was minted for must be a refusal, never a replayed success.
+ */
+class IdempotencyKey_Test extends TestBase {
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Minting: unforgeable width + binding
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_mint_isBoundToActionAndTarget() {
+		var k = IdempotencyKey.mint("ack", "INC-1");
+		assertEquals("ack", k.action());
+		assertEquals("INC-1", k.targetId());
+		assertNotNull(k.value());
+		assertFalse(k.value().isBlank());
+	}
+
+	@Test void a02_mint_valueIs256BitsHex() {
+		// 32 bytes -> 64 lowercase hex chars.
+		var v = IdempotencyKey.mint("ack", "INC-1").value();
+		assertEquals(64, v.length(), v);
+		assertTrue(v.matches("[0-9a-f]{64}"), v);
+	}
+
+	@Test void a03_mint_valuesAreUnique() {
+		var seen = Stream.generate(() -> IdempotencyKey.mint("ack", "INC-1").value()).limit(50).collect(Collectors.toSet());
+		assertEquals(50, seen.size(), "minted key values must be unique (SecureRandom)");
+	}
+
+	@Test void a04_mint_blankArgsThrow() {
+		assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.mint(null, "x"));
+		assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.mint("a", "  "));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Binding check (HIGH-8): mismatch is a refusal, never a replayed success
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_matches_onlyForSameActionAndTarget() {
+		var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+		assertTrue(k.matches("ack", "INC-1"));
+	}
+
+	@Test void b02_matches_falseForDifferentTarget() {
+		// The A-replayed-under-B attack: same key/action, different target -> must NOT match.
+		var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+		assertFalse(k.matches("ack", "INC-2"));
+	}
+
+	@Test void b03_matches_falseForDifferentAction() {
+		var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+		assertFalse(k.matches("resolve", "INC-1"));
+	}
+
+	@Test void b04_matches_falseForNulls() {
+		var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+		assertFalse(k.matches(null, "INC-1"));
+		assertFalse(k.matches("ack", null));
+	}
+
+	@Test void b05_of_rehydratesRecordedBinding() {
+		var k = IdempotencyKey.of("v", "ack", "INC-1");
+		assertEquals("v", k.value());
+		assertEquals("ack", k.action());
+		assertEquals("INC-1", k.targetId());
+	}
+
+	@Test void b06_of_blankArgsThrow() {
+		assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.of("  ", "a", "t"));
+		assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.of("v", null, "t"));
+		assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.of("v", "a", "  "));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// The value is a secret - toString must not leak it
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_toString_redactsValue() {
+		var k = IdempotencyKey.of("super-secret-value", "ack", "INC-1");
+		var s = k.toString();
+		assertFalse(s.contains("super-secret-value"), s);
+		assertTrue(s.contains("<redacted>"), s);
+		assertTrue(s.contains("ack"), s);
+		assertTrue(s.contains("INC-1"), s);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
new file mode 100644
index 0000000000..f7a54274de
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
@@ -0,0 +1,116 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Contract test for the declarative modal ({@link ModalDef}) + form ({@link FormDef}) payload the modal-open
+ * confirmation fetch returns (design doc §6.2; the modal/form half of {@code TODO-416}).
+ *
+ * <p>
+ * The confirmation body is typed structured fields painted client-side with {@code textContent} (never
+ * {@code innerHTML}); this test pins that field shape and the FreeMarker-first form source, plus the omit-when-unset
+ * rule for the optional form and idempotency key.
+ */
+class ModalDef_FormDef_Test extends TestBase {
+
+	//------------------------------------------------------------------------------------------------------------------
+	// ModalDef wire shape
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_fullModal_serializesToFrozenShape() {
+		var modal = ModalDef.create("Acknowledge this incident?")
+			.field("Incident", "INC-42")
+			.field("Title", "API latency")
+			.field("Service", "gateway")
+			.field("Current status", "triggered")
+			.form(FormDef.ofTemplate("servlet:/incidents/ack-form.ftl"))
+			.idempotencyKey("deadbeef");
+		var json = Json.of(modal);
+		var expected = Json.to("""
+			{"title":"Acknowledge this incident?",
+			 "fields":[{"label":"Incident","value":"INC-42"},{"label":"Title","value":"API latency"},
+			           {"label":"Service","value":"gateway"},{"label":"Current status","value":"triggered"}],
+			 "form":{"template":"servlet:/incidents/ack-form.ftl"},
+			 "idempotencyKey":"deadbeef"}
+			""", Map.class);
+		assertEquals(expected, Json.to(json, Map.class), json);
+	}
+
+	@Test void a02_topLevelKeyOrder() {
+		var modal = ModalDef.create("t").field("A", "1").form(FormDef.ofTemplate("u")).idempotencyKey("k");
+		Map<?,?> actual = Json.to(Json.of(modal), Map.class);
+		assertEquals(List.of("title", "fields", "form", "idempotencyKey"), new ArrayList<>(actual.keySet()));
+	}
+
+	@Test void a03_confirmOnlyModal_omitsFieldsFormAndKey() {
+		// A bare confirm-only modal (no fields, no form, no key) - every optional field omitted, not null.
+		var json = Json.of(ModalDef.create("Really delete?"));
+		assertEquals(Json.to("{\"title\":\"Really delete?\"}", Map.class), Json.to(json, Map.class), json);
+		for (var k : List.of("fields", "form", "idempotencyKey"))
+			assertFalse(json.contains("\"" + k + "\""), () -> "unset field leaked: " + k + "\n" + json);
+	}
+
+	@Test void a04_fieldNullValue_serializesLabelOnly() {
+		// A field value may be null (rendered as an empty value client-side); the label is required.
+		var json = Json.of(ModalDef.create("t").field("Note", null));
+		assertTrue(json.contains("\"label\":\"Note\""), json);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Validation guards
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_createBlankTitleThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> ModalDef.create("  "));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.create(null));
+	}
+
+	@Test void b02_fieldBlankLabelThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.of("  ", "v"));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+		assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.of(null, "v"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// FormDef (FreeMarker-first)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_formDef_serializesTemplateOnly() {
+		var json = Json.of(FormDef.ofTemplate("servlet:/x/form.ftl"));
+		assertEquals(Json.to("{\"template\":\"servlet:/x/form.ftl\"}", Map.class), Json.to(json, Map.class), json);
+	}
+
+	@Test void c02_formDef_reservedBeanTypeIsNotOnTheWireYet() {
+		// The bean->form generator is a deferred follow-on; no "beanType" key leaks in the MVP.
+		assertFalse(Json.of(FormDef.ofTemplate("u")).contains("beanType"));
+	}
+
+	@Test void c03_formDef_blankTemplateThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> FormDef.ofTemplate("  "));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+		assertThrows(IllegalArgumentException.class, () -> FormDef.ofTemplate(null));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
new file mode 100644
index 0000000000..cc497eaf4a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
@@ -0,0 +1,231 @@
+/*
+ * 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 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.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The declarative-modal + typed-action-result + in-flight-row half of the module's <b>JavaScript-execution
+ * harness</b>: runs the REAL served {@code juneau-views.js} in a real headless browser and asserts the TODO-416/417
+ * behavior as a user would experience it &mdash; a {@code present=dialog} action opens an overlay whose typed
+ * confirmation fields are painted with {@code textContent} (so an HTML-shaped value never becomes an element), every
+ * settled outcome (success/failure/refusal/unknown) and a non-2xx transport refusal render a VISIBLE non-optimistic
+ * banner, the in-flight marker is set while the sync write is outstanding and CLEARED on every terminal outcome
+ * (including UNKNOWN), and polling is no longer frozen once the marker clears.
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_ModalResult_Test} proves the shipped script <i>contains</i> the modal/result/in-flight logic; it
+ * cannot prove the overlay renders, the HTML-shaped field value stays inert text, or the marker actually clears on
+ * an UNKNOWN outcome at runtime.  This canary drives the real runtime in Chromium against fabricated responses, so
+ * those user-visible facts are measured rather than inferred.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  It reuses that profile's provisioned Node + Playwright browser and derives its own prober
+ * ({@code modal-result.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is needed.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link RowActionCsrf_BrowserTest} &mdash; the sibling fail-closed-CSRF canary.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=ModalResult_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ModalResult_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("modal-result.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.VIEWS_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("modal-result.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("modal-result-stdout.json");
+		var stderr = dir.resolve("modal-result-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@SuppressWarnings("unchecked")
+	private static Map<String,Object> sub(String key) {
+		return (Map<String,Object>) report.get(key);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) The runtime loaded, at the typed-action-result contract version (its OWN version, not the view's)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_runtimeLoadedAtActionResultContractVersion() {
+		assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+		assertEquals(ActionResult.CONTRACT_VERSION, report.get("actionResultContractVersion"), () -> report.toString());
+		assertEquals("1", ActionResult.CONTRACT_VERSION);
+		assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Every 2xx typed outcome renders a VISIBLE banner, and the in-flight marker is cleared (polling resumes)
+	//------------------------------------------------------------------------------------------------------------------
+
+	private void assertTerminal(String key, String expectedState, String expectedRole) {
+		var o = sub(key);
+		assertEquals(Boolean.TRUE, o.get("wasInflightBefore"), () -> key + ": marker should have been set before settle: " + o);
+		assertEquals(Boolean.TRUE, o.get("visible"), () -> key + ": outcome banner not visible: " + o);
+		assertEquals(expectedState, o.get("state"), () -> key + ": wrong state: " + o);
+		assertEquals(expectedRole, o.get("role"), () -> key + ": wrong role: " + o);
+		// The 417 hard requirement: the marker is cleared on EVERY terminal outcome, so polling is not frozen.
+		assertEquals(Boolean.FALSE, o.get("inflight"), () -> key + ": in-flight marker not cleared: " + o);
+		assertEquals(Boolean.FALSE, o.get("pollingFrozen"), () -> key + ": polling still frozen: " + o);
+		assertEquals(Boolean.FALSE, o.get("triggerDisabled"), () -> key + ": trigger not re-enabled: " + o);
+	}
+
+	@Test void b01_successRendersStatusAndClearsMarker() {
+		assertTerminal("success", "success", "status");
+	}
+
+	@Test void b02_failureRendersAlertAndClearsMarker() {
+		assertTerminal("failure", "failure", "alert");
+		assertTrue(String.valueOf(sub("failure").get("text")).contains("nope"), () -> report.toString());
+	}
+
+	@Test void b03_refusalRendersNamedCodeAndClearsMarker() {
+		assertTerminal("refusal", "refusal", "alert");
+		assertTrue(String.valueOf(sub("refusal").get("text")).contains("write-guard:not-armed"), () -> report.toString());
+	}
+
+	@Test void b04_unknownRendersVisibleAndClearsMarker() {
+		// The load-bearing 417 case: an UNKNOWN outcome MUST clear the marker or the whole table's polling freezes.
+		assertTerminal("unknown", "unknown", "alert");
+	}
+
+	@Test void b05_contractMismatchRendersUnknownNotOptimisticSuccess() {
+		assertTerminal("contractMismatch", "unknown", "alert");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) A non-2xx is a VISIBLE transport refusal read from the boundary envelope - not "HTTP 200 + schema" (HIGH-3)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_http403RendersTransportRefusalFromEnvelope() {
+		assertTerminal("transport403", "refusal", "alert");
+		var text = String.valueOf(sub("transport403").get("text"));
+		assertTrue(text.contains("CSRF_TOKEN_MISSING"), () -> "boundary reason not surfaced: " + report);
+		assertTrue(text.contains("missing token"), () -> "envelope message not surfaced: " + report);
+	}
+
+	@Test void c02_http421WithNoBodyStillRendersVisibleRefusal() {
+		// No X-Loopback-Boundary header, empty body -> still a visible, comprehensible non-optimistic refusal.
+		assertTerminal("transport421", "refusal", "alert");
+		assertTrue(String.valueOf(sub("transport421").get("text")).contains("421"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Re-render from the authoritative result payload (MERGE_ROW)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_successMergesAuthoritativeRow() {
+		assertNotNull(report.get("mergedRow"), () -> "success did not re-render from the result row: " + report);
+		assertEquals("1", report.get("mergeMarkedRow"), () -> report.toString());
+		assertNotNull(report.get("mergeCtxRow"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) The modal overlay + typed-field confirmation via textContent (XSS-safe), then a bound submit
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_modalOverlayOpensWithTypedFields() {
+		var m = sub("modal");
+		assertEquals(Boolean.TRUE, m.get("backdropVisible"), () -> "modal overlay not visible: " + report);
+		assertEquals("Acknowledge this incident?", m.get("title"), () -> report.toString());
+		assertEquals(2L, ((Number) m.get("fieldCount")).longValue(), () -> report.toString());
+	}
+
+	@Test void e02_htmlShapedFieldValueStaysInertText() {
+		// BLK-1/MED-9: the field value is painted with textContent, so an <img onerror> becomes literal text and
+		// NO <img> element is created anywhere in the dialog.
+		var m = sub("modal");
+		assertEquals("<img src=x onerror=alert(1)>", m.get("evilFieldText"), () -> report.toString());
+		assertEquals(0L, ((Number) m.get("injectedImgCount")).longValue(),
+			() -> "an HTML-shaped field value was parsed into an element - textContent invariant broken: " + report);
+	}
+
+	@Test void e03_confirmSubmitCarriesIdempotencyKeyAndTargetId() {
+		var m = sub("modal");
+		assertEquals(Boolean.TRUE, m.get("submitIssued"), () -> "confirm did not submit: " + report);
+		var body = String.valueOf(m.get("submitBody"));
+		assertTrue(body.contains("key-abc"), () -> "idempotency key not carried on submit: " + report);
+		assertTrue(body.contains("INC-1"), () -> "target id not carried on submit: " + report);
+		assertTrue(body.contains("ack"), () -> "action id not carried on submit: " + report);
+		assertEquals(Boolean.TRUE, m.get("backdropClosedAfterConfirm"), () -> "modal did not close on confirm: " + report);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
index a0d9e1d34d..7a8c7e10b0 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
@@ -113,7 +113,7 @@ class PageDef_Contract_Test extends TestBase {
 			.tabs(Tab.create("releases", "Releases").view(view("releases")))
 			.build();
 		var json = Json.of(page);
-		assertTrue(json.contains("\"contractVersion\":\"2\""), json);
+		assertTrue(json.contains("\"contractVersion\":\"3\""), json);
 		assertTrue(json.contains("\"id\":\"admin\""), json);
 		assertTrue(json.contains("\"title\":\"Admin\""), json);
 		assertTrue(json.contains("\"tabs\":["), json);
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTableContent_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTableContent_Test.java
new file mode 100644
index 0000000000..ddc9d2f2b5
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTableContent_Test.java
@@ -0,0 +1,284 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * TODO-420: emission tests for {@link Tab#content}/{@link Subtab#content} (raw, non-table panel bodies).
+ *
+ * <p>
+ * Covers the {@code PageTable.buildTabPanel} matrix ({@code Tab = {view} | {subtabs} | {content} |
+ * {content+subtabs}}, {@code Subtab = {view} | {content}}), the verbatim/unescaped {@code rawText(...)} emission
+ * contract (deliberately the opposite of every label/title, which stay HTML-escaped), and &mdash; the item's MED-1
+ * finding &mdash; that {@code content} is structurally unreachable from the PAGE_META sidecar because
+ * {@code PageTable.buildMeta} is a <b>projection</b> that never reads it, not because of a reserve-and-omit
+ * {@code @BeanType} trick (which is what actually closes it off {@code Json.of(...)}-wise, asserted separately
+ * below).
+ */
+class PageTableContent_Test extends TestBase {
+
+	public static class Release {
+		public String name;
+	}
+
+	private static ViewDef view(String id) {
+		return ViewDef.create(id)
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/" + id + "/data")
+			.columns(Column.of("name").title("Name"))
+			.build();
+	}
+
+	/** Extracts the raw text between a sidecar's opening and closing {@code <script>} tags by element id. */
+	private static String sidecarBody(String html, String sidecarId) {
+		var open = html.indexOf("id=\"" + sidecarId + "\"");
+		assertTrue(open >= 0, () -> "sidecar '" + sidecarId + "' not found:\n" + html);
+		var contentStart = html.indexOf('>', open) + 1;
+		var contentEnd = html.indexOf("</script>", contentStart);
+		return html.substring(contentStart, contentEnd);
+	}
+
+	/** The single {@code <div ...>} start tag containing all of {@code required}; fails when not exactly one matches. */
+	private static String theDivTag(String html, String...required) {
+		var divs = new ArrayList<String>();
+		var i = 0;
+		while ((i = html.indexOf("<div", i)) >= 0) {
+			var end = html.indexOf('>', i);
+			divs.add(html.substring(i, end + 1));
+			i = end + 1;
+		}
+		var matches = divs.stream().filter(x -> Arrays.stream(required).allMatch(x::contains)).toList();
+		assertEquals(1, matches.size(),
+			() -> "expected exactly one <div> tag containing " + Arrays.toString(required) + ", found " + matches + " in:\n" + html);
+		return matches.get(0);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Leaf Tab = {content}
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_leafTabContent_emittedVerbatimInsidePanel() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("overview", "Overview").content("<p>Hello, <b>world</b>.</p>"))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		assertTrue(html.contains("<p>Hello, <b>world</b>.</p>"),
+			() -> "raw content must appear byte-for-byte verbatim (no escaping):\n" + html);
+	}
+
+	@Test void a02_leafTabContentPanel_carriesOnlyPanelTabAttr() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("overview", "Overview").content("<p>Body.</p>"))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		var tag = theDivTag(html, PageTable.PANEL_CLASS, PageTable.PANEL_TAB_ATTR + "=\"overview\"");
+		assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR), tag);
+	}
+
+	@Test void a03_leafTabContent_noDataJuneauViewMarker() {
+		// A content-only tab references no ViewDef, so it must not emit a data-juneau-view table/marker at all.
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("overview", "Overview").content("<p>Body.</p>"))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		assertFalse(html.contains("data-juneau-view"), html);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Preamble: Tab = {content+subtabs}
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_tabPreambleContent_rendersAboveSubtabBar_inSameOuterPanel() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog")
+				.content("<p>Preamble prose.</p>")
+				.subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		var preambleIdx = html.indexOf("<p>Preamble prose.</p>");
+		var subtabBarIdx = html.indexOf(PageTable.SUBTAB_BAR_CLASS);
+		assertTrue(preambleIdx >= 0, html);
+		assertTrue(subtabBarIdx > preambleIdx,
+			() -> "preamble content must render ABOVE the sub-tab bar:\n" + html);
+	}
+
+	@Test void b02_tabPreambleContent_liveInsideTheSubtabAgnosticOuterPanel() {
+		// The outer panel wrapping the preamble + sub-tab bar must stay sub-tab-agnostic (no data-panel-subtab) -
+		// this is the existing PageTable_SubtabPanelContract_Test invariant, re-verified with a preamble present
+		// so the new code path can't have silently reintroduced it.
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog")
+				.content("<p>Preamble.</p>")
+				.subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		var tag = theDivTag(html, PageTable.PANEL_CLASS + "\"", PageTable.PANEL_TAB_ATTR + "=\"catalog\"");
+		assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR),
+			() -> "preamble must not force data-panel-subtab onto the outer panel: " + tag);
+	}
+
+	@Test void b03_tabWithoutPreamble_emitsNoExtraContentBeforeSubtabBar() {
+		// Regression: a tab with subtabs and NO content must render identically to before this item (no stray
+		// empty raw-text node ahead of the sub-tab bar).
+		var withoutPreamble = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		var withPreamble = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog")
+				.subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		assertEquals(Html.of(PageTable.of(withoutPreamble)), Html.of(PageTable.of(withPreamble)));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Subtab = {content}
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_subtabContent_emittedVerbatimInsideItsSubpanel() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("about", "About").content("<p>About text.</p>")))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		assertTrue(html.contains("<p>About text.</p>"), html);
+	}
+
+	@Test void c02_subtabContentSubpanel_carriesBothTabAndSubtabAttrs() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("about", "About").content("<p>x</p>")))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		var tag = theDivTag(html, PageTable.SUBPANEL_CLASS, PageTable.PANEL_SUBTAB_ATTR + "=\"about\"");
+		assertTrue(tag.contains(PageTable.PANEL_TAB_ATTR + "=\"catalog\""), tag);
+	}
+
+	@Test void c03_mixedViewAndContentSubtabsUnderOneTab_eachGetsItsOwnBody() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(
+				Subtab.create("data", "Data").view(view("data")),
+				Subtab.create("about", "About").content("<p>About.</p>")))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		assertTrue(html.contains("data-juneau-view=\"data\""), html);
+		assertTrue(html.contains("<p>About.</p>"), html);
+		// The content subtab must NOT also carry a data-juneau-view marker.
+		var aboutTag = theDivTag(html, PageTable.SUBPANEL_CLASS, PageTable.PANEL_SUBTAB_ATTR + "=\"about\"");
+		var aboutStart = html.indexOf(aboutTag);
+		var aboutEnd = html.indexOf(PageTable.SUBPANEL_CLASS, aboutStart + aboutTag.length());
+		var aboutSection = aboutEnd > 0 ? html.substring(aboutStart, aboutEnd) : html.substring(aboutStart);
+		assertFalse(aboutSection.contains("data-juneau-view"), aboutSection);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Ownership contract: verbatim / unescaped (opposite of every label/title)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_contentIsNeverHtmlEntityEscaped_unlikeLabels() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("t", "<script>alert('label')</script>")
+				.content("<script>alert('content')</script>"))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		// The tab LABEL is escaped (existing contract, PageTable_Emit_Test b01) ...
+		assertFalse(html.contains("<script>alert('label')</script>"), html);
+		// ... but panel CONTENT is emitted verbatim - this is the template-engine ownership contract, not a bug.
+		assertTrue(html.contains("<script>alert('content')</script>"),
+			() -> "content must be emitted verbatim per the documented ownership contract:\n" + html);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// MED-1: PAGE_META is a projection - content is structurally unreachable, not merely omitted
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_contentBearingTab_producesIdenticalPageMetaToContentlessTab() {
+		// The core MED-1 assertion: buildMeta() copies id/label/subtabs only, so adding content to a Tab cannot
+		// change one single byte of PAGE_META. Proven by diffing the sidecar of two otherwise-identical pages.
+		var withoutContent = PageDef.create("admin")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		var withContent = PageDef.create("admin")
+			.tabs(Tab.create("catalog", "Catalog")
+				.content("<p>Ignored by PAGE_META.</p>")
+				.subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		var metaWithout = sidecarBody(Html.of(PageTable.of(withoutContent)), "juneau-page:admin");
+		var metaWith = sidecarBody(Html.of(PageTable.of(withContent)), "juneau-page:admin");
+		assertEquals(metaWithout, metaWith,
+			() -> "PAGE_META must be byte-identical regardless of Tab#content - buildMeta() is a projection that "
+				+ "never reads it, so it cannot leak in:\nwithout=" + metaWithout + "\nwith=" + metaWith);
+	}
+
+	@Test void e02_contentBearingSubtab_producesIdenticalPageMetaToViewBackedSubtab() {
+		// Same projection fact, exercised via a Subtab#content instead of Subtab#view - SubtabMeta pins id/label
+		// only, so swapping view<->content changes nothing in PAGE_META either.
+		var withView = PageDef.create("admin")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		var withContent = PageDef.create("admin")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>x</p>")))
+			.build();
+		var metaWithView = sidecarBody(Html.of(PageTable.of(withView)), "juneau-page:admin");
+		var metaWithContent = sidecarBody(Html.of(PageTable.of(withContent)), "juneau-page:admin");
+		assertEquals(metaWithView, metaWithContent);
+	}
+
+	@Test void e03_pageMetaNeverContainsTheLiteralStringContent_evenWhenPresentOnEveryTabAndSubtab() {
+		// Belt-and-suspenders: even with "content" set everywhere in the tree, the word never appears as a
+		// PAGE_META key. (Distinct from Json.of(Tab) reserve-and-omit, checked separately in f01/f02 below - this
+		// one is scoped to the actual PAGE_META sidecar the runtime consumes.)
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("catalog", "Catalog")
+				.content("<p>preamble</p>")
+				.subtabs(Subtab.create("s", "S").content("<p>sub</p>")))
+			.build();
+		var meta = sidecarBody(Html.of(PageTable.of(page)), "juneau-page:admin");
+		assertFalse(meta.contains("content"), () -> "PAGE_META must never carry a 'content' key:\n" + meta);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Reserve-and-omit at the bean level: a direct Json.of(...) of the PageDef tree also omits content
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_directJsonOfPageDef_omitsTabContent() {
+		// Unlike PAGE_META (a projection - e01-e03), PageDef itself IS directly JSON-serializable
+		// (PageDef_Contract_Test exercises this same Json.of(page) path). Tab#content is deliberately absent from
+		// Tab's own @BeanType(properties=...) list, so it is reserved-and-omitted from THIS path too, at no
+		// CONTRACT_VERSION cost (the wire shape is byte-for-byte unchanged).
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("t", "T").content("<p>trusted prose</p>"))
+			.build();
+		var json = Json.of(page);
+		assertFalse(json.contains("content"), () -> "Tab#content must be reserved-and-omitted from Json.of(PageDef): " + json);
+		assertTrue(json.contains("\"contractVersion\":\"" + PageDef.CONTRACT_VERSION + "\""), json);
+	}
+
+	@Test void f02_directJsonOfPageDef_omitsSubtabContent() {
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>trusted prose</p>")))
+			.build();
+		var json = Json.of(page);
+		assertFalse(json.contains("content"), () -> "Subtab#content must be reserved-and-omitted from Json.of(PageDef): " + json);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java
new file mode 100644
index 0000000000..6cd1163be2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java
@@ -0,0 +1,304 @@
+/*
+ * 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 java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.regex.*;
+
+/**
+ * Test-only, <b>private</b> source scanner enforcing the TODO-420 BLK-1 ownership contract &mdash; "template
+ * engine, trusted / first-party content only" &mdash; on {@link Tab#content}/{@link Subtab#content}.
+ *
+ * <p>
+ * Not shipped and deliberately not public API, mirroring the discipline of the {@code ScriptJsonSinkScanner}
+ * (juneau-commons) and {@code ChromeCssScanner} (juneau-rest-server-console-ui) test-only guards this class is
+ * modeled on. It scans this module's own Java sources (both {@code src/main/java} and {@code src/test/java}, since
+ * unlike the {@code <script>}-JSON sink this is a brand-new, forward-looking guardrail with no pre-existing
+ * "known-good" callers to anchor a main-source-only scan to &mdash; see the accompanying test's anti-vacuous
+ * checks) and fails the build if any call to the {@link Tab#content(String)}/{@link Subtab#content(String)}
+ * fluent-setter passes an argument that is not a compile-time string literal (or a {@code +}-concatenation of
+ * literals).
+ *
+ * <h5 class='section'>Why "is the argument a literal" is the enforcement mechanism</h5>
+ * <p>
+ * A build-time scanner cannot do taint analysis, so it cannot directly ask "does this value ever carry
+ * live/remote/attacker-influenceable data". A compile-time string literal provably cannot: it is baked into the
+ * {@code .class} file, reviewed the same way any other source line is, and by construction carries no
+ * request/response/remote-call data. Conversely, <i>any</i> non-literal argument &mdash; an identifier, a field
+ * read, a method call, string concatenation involving a non-literal &mdash; is exactly the shape a write-path
+ * confirmation/detail emitter would use to build a body from live data (an incident title, a Slack thread, a
+ * request parameter). Requiring literal-only arguments is therefore the strongest checkable proxy for the
+ * "trusted / first-party content only" contract documented on {@link Tab#content}/{@link Subtab#content}: it lets
+ * through exactly the FG-2 docs-prose use case (hand-authored markup baked into the source) and flags everything
+ * a live-data confirmation body would look like.
+ *
+ * <h5 class='section'>What counts as a sink</h5>
+ * <p>
+ * A sink is any source-level call whose method name is exactly {@code content} (i.e. {@code .content(...)}) &mdash;
+ * scoped to this module's own tree, where (verified before this scanner was written) the only such calls are the
+ * {@link Tab}/{@link Subtab} fluent setters this item adds; nothing else in this module declares or calls a
+ * {@code content(...)} method. This scanner does not attempt receiver-type resolution and does not scan outside
+ * this module (other Juneau modules declare unrelated {@code content(...)} methods, e.g.
+ * {@code RestRequest.content(Object)}, which are out of scope by construction because they are never on this
+ * module's classpath of scanned files).
+ *
+ * <h5 class='section'>Why it must also assert it is not vacuous</h5>
+ * <p>
+ * A scanner that silently stops matching reads as a passing test, which is worse than no scanner. Comments are
+ * therefore stripped before analysis (a mention of {@code .content(} inside a comment must not be flagged, and
+ * must not be mistaken for a real call either), and the accompanying test asserts the live scan of this module's
+ * own tree still finds real, known-good literal sinks (the ones this item's own test fixtures add) and that
+ * mutating a known-good literal call into a non-literal one turns it into a violation.
+ */
+final class RawContentSinkScanner {
+
+	private RawContentSinkScanner() {}
+
+	/** One {@code .content(...)} call site found in a source. */
+	record Sink(String file, boolean literalOnly, String detail) {}
+
+	/** The outcome of a scan: every sink found, and the subset that are violations. */
+	record Result(List<Sink> sinks, List<String> violations) {}
+
+	/** Matches a {@code .content(} call (the {@code Tab}/{@code Subtab} raw-content fluent setter). */
+	private static final Pattern CONTENT_CALL = Pattern.compile("\\.content\\s*\\(");
+
+	/** A single string literal or text-block literal (content kept verbatim by {@link #stripComments}). */
+	private static final Pattern LITERAL_TOKEN =
+		Pattern.compile("\\s*(?:\"\"\"[\\s\\S]*?\"\"\"|\"(?:[^\"\\\\]|\\\\.)*\"|null)\\s*");
+
+	/**
+	 * Excluded, by simple filename, from {@link #scanTree(Path)}: this is the accompanying security-test file,
+	 * which deliberately embeds synthetic RED (and GREEN) fixtures as source-text string/text-block literals
+	 * (e.g. a fixture literally containing {@code "Tab.create(...).content(someIdentifier)"} as example text).
+	 * Because {@link #stripComments} keeps string content verbatim (needed so real call arguments stay
+	 * inspectable - see this class's javadoc), tree-scanning that file would find its RED-fixture example text and
+	 * flag it as a false-positive violation purely because the file holding the example is itself in the scanned
+	 * tree. This is the only file in this module whose job is to hold such text as <i>data</i> rather than as a
+	 * real call, so excluding it by name is narrow and safe; every other test file in this tree using
+	 * {@code .content(...)} is a real call this scanner is meant to see (and lean on for anti-vacuousness).
+	 */
+	private static final String EXCLUDED_FIXTURE_FILE = "RawContentSink_SecurityScan_Test.java";
+
+	/**
+	 * Scans a single Java source string.
+	 *
+	 * @param file A label for the source (used in violation messages).
+	 * @param source The Java source text.
+	 * @return The sinks found and any violations.
+	 */
+	static Result scan(String file, String source) {
+		var sinks = new ArrayList<Sink>();
+		var violations = new ArrayList<String>();
+		var code = stripComments(source);
+
+		var m = CONTENT_CALL.matcher(code);
+		while (m.find()) {
+			var openParen = m.end() - 1;
+			var arg = balancedArg(code, openParen).trim();
+			var literalOnly = isLiteralOnly(arg);
+			sinks.add(new Sink(file, literalOnly, "content(" + arg + ")"));
+			if (!literalOnly)
+				violations.add(file + ": content(...) argument is not a compile-time literal - flagged as a "
+					+ "possible live-data write-path confirmation/detail body reaching the raw-markup sink: "
+					+ "content(" + arg + ")");
+		}
+		return new Result(sinks, violations);
+	}
+
+	/** Convenience overload for ad-hoc source strings. */
+	static Result scan(String source) {
+		return scan("(source)", source);
+	}
+
+	/**
+	 * Walks the specified module root and scans every {@code src/main/java} and {@code src/test/java} Java
+	 * source under it (both trees, per this class's javadoc: this guardrail has no pre-existing main-source-only
+	 * "known-good" anchor to lean on the way {@code ScriptJsonSinkScanner} does).
+	 *
+	 * @param moduleRoot The module root (e.g. {@code juneau-rest/juneau-rest-server-views}).
+	 * @return The aggregated sinks and violations across the tree.
+	 * @throws IOException If the tree cannot be walked.
+	 */
+	static Result scanTree(Path moduleRoot) throws IOException {
+		var sinks = new ArrayList<Sink>();
+		var violations = new ArrayList<String>();
+		var target = File.separator + "target" + File.separator;
+		for (var sourceDir : List.of("main", "test")) {
+			var srcRoot = moduleRoot.resolve("src").resolve(sourceDir).resolve("java");
+			if (!Files.isDirectory(srcRoot))
+				continue;
+			try (var stream = Files.walk(srcRoot)) {
+				var files = stream
+					.filter(p -> p.toString().endsWith(".java"))
+					.filter(p -> !p.toString().contains(target))
+					.filter(p -> !p.getFileName().toString().equals(EXCLUDED_FIXTURE_FILE))
+					.sorted()
+					.toList();
+				for (var f : files) {
+					var r = scan(moduleRoot.relativize(f).toString(), Files.readString(f));
+					sinks.addAll(r.sinks());
+					violations.addAll(r.violations());
+				}
+			}
+		}
+		return new Result(sinks, violations);
+	}
+
+	/**
+	 * Locates the {@code juneau-rest-server-views} module root by walking up from the working directory until a
+	 * directory holding both {@code juneau-core} and {@code juneau-rest} is found, then resolving into it.
+	 *
+	 * @return The module root, or {@code null} if not found.
+	 */
+	static Path locateModuleRoot() {
+		for (var d = Path.of("").toAbsolutePath(); d != null; d = d.getParent()) {
+			if (Files.isDirectory(d.resolve("juneau-core")) && Files.isDirectory(d.resolve("juneau-rest"))) {
+				var module = d.resolve("juneau-rest").resolve("juneau-rest-server-views");
+				return Files.isDirectory(module) ? module : null;
+			}
+		}
+		return null;
+	}
+
+	/** True if {@code arg} is composed entirely of string/text-block literals (and/or {@code null}), optionally {@code +}-joined. */
+	private static boolean isLiteralOnly(String arg) {
+		var s = arg;
+		if (s.isBlank())
+			return true; // a no-arg / blank call carries nothing - trivially not a data leak
+		var i = 0;
+		while (i < s.length()) {
+			var m = LITERAL_TOKEN.matcher(s.substring(i));
+			if (!m.lookingAt())
+				return false;
+			i += m.end();
+			if (i >= s.length())
+				return true;
+			if (s.charAt(i) != '+')
+				return false;
+			i++;
+		}
+		return true;
+	}
+
+	/** Returns the (comma-and-nesting-preserving) argument text between the balanced parens starting at {@code openParen}. */
+	private static String balancedArg(String s, int openParen) {
+		var depth = 0;
+		var sb = new StringBuilder();
+		for (var i = openParen; i < s.length(); i++) {
+			var c = s.charAt(i);
+			if (c == '(') {
+				depth++;
+				if (depth == 1)
+					continue;
+			} else if (c == ')') {
+				depth--;
+				if (depth == 0)
+					break;
+			}
+			sb.append(c);
+		}
+		return sb.toString();
+	}
+
+	/**
+	 * Blanks out comment content (keeping string/char literals fully intact, including their delimiters and
+	 * inner text) so that (a) a {@code .content(} mention inside a comment cannot be mistaken for a real call, and
+	 * (b) the real literal text of a string argument stays inspectable by {@link #isLiteralOnly(String)}. This is
+	 * the one deliberate divergence from {@code ScriptJsonSinkScanner}'s stripper, which blanks string content too
+	 * - that scanner never needs to look inside a literal, this one always does.
+	 */
+	private static String stripComments(String s) {
+		var out = new StringBuilder(s.length());
+		var n = s.length();
+		var i = 0;
+		while (i < n) {
+			var c = s.charAt(i);
+			if (c == '/' && i + 1 < n && s.charAt(i + 1) == '/') {
+				while (i < n && s.charAt(i) != '\n')
+					i++;
+				continue;
+			}
+			if (c == '/' && i + 1 < n && s.charAt(i + 1) == '*') {
+				i += 2;
+				while (i + 1 < n && !(s.charAt(i) == '*' && s.charAt(i + 1) == '/')) {
+					if (s.charAt(i) == '\n')
+						out.append('\n');
+					i++;
+				}
+				i = Math.min(n, i + 2);
+				continue;
+			}
+			if (c == '"' && i + 2 < n && s.charAt(i + 1) == '"' && s.charAt(i + 2) == '"') {
+				out.append("\"\"\"");
+				i += 3;
+				while (i + 2 < n && !(s.charAt(i) == '"' && s.charAt(i + 1) == '"' && s.charAt(i + 2) == '"')) {
+					out.append(s.charAt(i));
+					i++;
+				}
+				if (i + 2 < n) {
+					out.append("\"\"\"");
+					i += 3;
+				} else {
+					i = n;
+				}
+				continue;
+			}
+			if (c == '"') {
+				out.append('"');
+				i++;
+				while (i < n && s.charAt(i) != '"') {
+					if (s.charAt(i) == '\\' && i + 1 < n) {
+						out.append(s.charAt(i)).append(s.charAt(i + 1));
+						i += 2;
+						continue;
+					}
+					out.append(s.charAt(i));
+					i++;
+				}
+				if (i < n) {
+					out.append('"');
+					i++;
+				}
+				continue;
+			}
+			if (c == '\'') {
+				out.append('\'');
+				i++;
+				while (i < n && s.charAt(i) != '\'') {
+					if (s.charAt(i) == '\\' && i + 1 < n) {
+						out.append(s.charAt(i)).append(s.charAt(i + 1));
+						i += 2;
+						continue;
+					}
+					out.append(s.charAt(i));
+					i++;
+				}
+				if (i < n) {
+					out.append('\'');
+					i++;
+				}
+				continue;
+			}
+			out.append(c);
+			i++;
+		}
+		return out.toString();
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
new file mode 100644
index 0000000000..867f18a405
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
@@ -0,0 +1,243 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * TODO-420 BLK-1 gate: no source in this module may pour a non-literal (i.e. plausibly live-data-derived) value
+ * into {@link Tab#content}/{@link Subtab#content} &mdash; the raw-markup panel-body sink.
+ *
+ * <p>
+ * This is the "guardrail with teeth" the item's cross-family adversarial review required before the raw-markup
+ * shape could go READY: {@link Tab#content}/{@link Subtab#content} are a documented "template engine, trusted /
+ * first-party content only" sink (caller sanitizes, framework emits verbatim - see their javadoc). A write-path
+ * confirmation/detail body built from live data (an incident title, a Slack thread, a request parameter) must
+ * never reach it. Because this framework's own sources have no such confirmation emitters yet, this gate is
+ * necessarily forward-looking: it is proven correct here by exhaustive RED/GREEN unit checks on the scanner
+ * mechanism itself (section a), then pointed at this module's real tree (section b) to lock in that today's
+ * sources - main <i>and</i> test - are clean, and to prove the live scan is not accidentally vacuous.
+ */
+class RawContentSink_SecurityScan_Test extends TestBase {
+
+	// -----------------------------------------------------------------------------------------------------------
+	// a: Unit RED/GREEN on the scanner mechanism itself
+	// -----------------------------------------------------------------------------------------------------------
+
+	/** RED: a bare identifier argument - the classic "pour a variable into content" shape - is a violation. */
+	@Test void a01_identifierArgument_isFlagged() {
+		var src = """
+			class X {
+			  void f(String incidentTitle) {
+			    Tab.create("t", "T").content(incidentTitle);
+			  }
+			}
+			""";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+		assertFalse(r.sinks().get(0).literalOnly());
+	}
+
+	/** RED: a method-call argument - the shape a confirmation/detail body renderer would use - is a violation. */
+	@Test void a02_methodCallArgument_isFlagged() {
+		var src = """
+			class X {
+			  void f(Object gack) {
+			    Subtab.create("s", "S").content(renderConfirmationHtml(gack));
+			  }
+			}
+			""";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+	}
+
+	/** RED: concatenating a literal with a non-literal is still a violation - the literal prefix/suffix doesn't launder it. */
+	@Test void a03_concatenationWithNonLiteral_isFlagged() {
+		var src = """
+			class X {
+			  void f(String title) {
+			    Tab.create("t", "T").content("<p>" + title + "</p>");
+			  }
+			}
+			""";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+	}
+
+	/** GREEN: a single string literal - the FG-2 docs-prose shape - passes. */
+	@Test void a04_singleStringLiteral_passes() {
+		var src = """
+			class X {
+			  void f() {
+			    Tab.create("t", "T").content("<p>Hello, world.</p>");
+			  }
+			}
+			""";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(List.of(), r.violations());
+		assertTrue(r.sinks().get(0).literalOnly());
+	}
+
+	/** GREEN: a text-block literal passes (built via string concatenation here to avoid nested triple-quote escaping in this fixture). */
+	@Test void a05_textBlockLiteral_passes() {
+		var src = "class X {\n"
+			+ "  void f() {\n"
+			+ "    Tab.create(\"t\", \"T\").content(\"\"\"\n"
+			+ "      <p>Hello</p>\n"
+			+ "      \"\"\");\n"
+			+ "  }\n"
+			+ "}\n";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(List.of(), r.violations());
+		assertTrue(r.sinks().get(0).literalOnly());
+	}
+
+	/** GREEN: concatenating two-or-more literals together is still all-literal, so it passes. */
+	@Test void a06_concatenationOfOnlyLiterals_passes() {
+		var src = """
+			class X {
+			  void f() {
+			    Tab.create("t", "T").content("<p>" + "Hello" + "</p>");
+			  }
+			}
+			""";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(List.of(), r.violations());
+	}
+
+	/**
+	 * A {@code .content(} mention that lives only in a comment must NOT be mistaken for a real call - this is the
+	 * exact silent-decay failure mode a scanner exists to prevent, so it is asserted directly (mirrors
+	 * {@code ScriptJsonSinkScanner}'s equivalent check).
+	 */
+	@Test void a07_commentMentionOfContentCall_isNotASink() {
+		var src = """
+			class X {
+			  // old code used to do: Tab.create("t", "T").content(dynamicTitle);
+			  void f() {
+			    Tab.create("t", "T").content("<p>Hello.</p>");
+			  }
+			}
+			""";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(List.of(), r.violations());
+	}
+
+	/** {@code null} is treated as a safe (non-data-carrying) argument, not a violation. */
+	@Test void a08_nullArgument_isNotFlagged() {
+		var src = """
+			class X {
+			  void f() {
+			    Subtab.create("s", "S").content(null);
+			  }
+			}
+			""";
+		var r = RawContentSinkScanner.scan(src);
+		assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+		assertEquals(List.of(), r.violations());
+	}
+
+	/** Mutation check on a synthetic fixture: a GREEN literal call, once mutated to a non-literal, is flagged. */
+	@Test void a09_mutatingAGreenLiteralIntoAnIdentifier_turnsItIntoAViolation() {
+		var clean = "class X { void f() { Tab.create(\"t\", \"T\").content(\"<p>Hello.</p>\"); } }";
+		var cleanResult = RawContentSinkScanner.scan(clean);
+		assertEquals(List.of(), cleanResult.violations());
+
+		var mutated = clean.replace("\"<p>Hello.</p>\"", "buildLiveConfirmationBody(incident)");
+		var mutatedResult = RawContentSinkScanner.scan(mutated);
+		assertEquals(1, mutatedResult.violations().size(),
+			() -> "mutating a literal content(...) argument into a method call must be flagged: " + mutatedResult.violations());
+	}
+
+	// -----------------------------------------------------------------------------------------------------------
+	// b: Live gate against this module's real tree (main AND test sources)
+	// -----------------------------------------------------------------------------------------------------------
+
+	private static Path requireModuleRoot() {
+		var root = RawContentSinkScanner.locateModuleRoot();
+		assertNotNull(root, "could not locate the juneau-rest-server-views module root from the working "
+			+ "directory - the security gate must fail loudly rather than pass vacuously when it cannot find the "
+			+ "sources it guards");
+		return root;
+	}
+
+	/** GREEN: this module's real tree - main and test sources alike - has no content(...) sink fed by a non-literal. */
+	@Test void b01_realModuleTree_hasNoViolations() throws Exception {
+		var r = RawContentSinkScanner.scanTree(requireModuleRoot());
+		assertEquals(List.of(), r.violations(),
+			() -> "non-literal content(...) sink(s) found - a write-path confirmation/detail emitter may have "
+				+ "reached the raw-markup panel-content sink:\n  " + String.join("\n  ", r.violations()));
+	}
+
+	/**
+	 * Anti-vacuous: the live scan must actually be finding real content(...) call sites (this item's own
+	 * legitimate literal usages in {@code TabSubtabContent_Validation_Test}/{@code PageTableContent_Test}). If
+	 * this ever drops to zero, the "no violations" result above is meaningless - the scanner would have quietly
+	 * stopped matching, or every real usage would have been (wrongly) excluded from the tree walk.
+	 */
+	@Test void b02_realModuleTree_findsKnownGoodSinks_notVacuous() throws Exception {
+		var r = RawContentSinkScanner.scanTree(requireModuleRoot());
+		assertTrue(r.sinks().size() >= 10, () -> "expected >=10 real content(...) call sites, found: " + r.sinks());
+		assertTrue(r.sinks().stream().allMatch(RawContentSinkScanner.Sink::literalOnly),
+			() -> "expected every real sink found today to be literal-only: " + r.sinks());
+	}
+
+	/**
+	 * Mutation check against a real file in the tree: a known-good literal {@code content(...)} call in
+	 * {@code TabSubtabContent_Validation_Test} passes as-is, but mutating that exact literal into a plausible
+	 * live-data expression turns it into a violation - proving the guard genuinely exercises real files rather
+	 * than passing them by accident.
+	 */
+	@Test void b03_realKnownGoodCall_passesButFailsWhenMutatedToNonLiteral() throws Exception {
+		var root = requireModuleRoot();
+		var file = "src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java";
+		var source = Files.readString(root.resolve(file));
+		var knownGoodCall = "Tab.create(\"overview\", \"Overview\").content(\"<p>Hello, world.</p>\")";
+		assertTrue(source.contains(knownGoodCall), () -> file + " no longer contains the expected known-good call "
+			+ "- update this test's fixture string to match the current source");
+
+		var clean = RawContentSinkScanner.scan(file, source);
+		assertEquals(List.of(), clean.violations());
+
+		var mutated = source.replace(knownGoodCall,
+			"Tab.create(\"overview\", \"Overview\").content(buildLiveConfirmationBody(incident))");
+		var mutatedResult = RawContentSinkScanner.scan(file, mutated);
+		assertTrue(mutatedResult.violations().size() >= 1,
+			() -> "mutating the known-good call into a method-call argument must be flagged: " + mutatedResult.violations());
+	}
+
+	/** The scanner's own accompanying fixture file is excluded from the tree walk - sanity-check that exclusion. */
+	@Test void b04_scannersOwnFixtureFile_isExcludedFromTheTreeWalk() throws Exception {
+		var r = RawContentSinkScanner.scanTree(requireModuleRoot());
+		assertTrue(r.sinks().stream().noneMatch(s -> s.file().endsWith("RawContentSink_SecurityScan_Test.java")),
+			() -> "the scanner's own RED/GREEN fixture file must be excluded from the tree walk (its example "
+				+ "strings would otherwise register as false-positive sinks): " + r.sinks());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java
new file mode 100644
index 0000000000..fd2174cd91
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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 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.apache.juneau.rest.server.filter.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The row-action half of the module's <b>JavaScript-execution harness</b>: runs the REAL served
+ * {@code juneau-views.js} in a real headless browser and asserts the fail-closed CSRF submit behaves as a user
+ * would experience it &mdash; a blank/absent/whitespace token issues NO request and renders a VISIBLE refusal, and
+ * a valid token issues a JSON {@code POST} carrying the {@code X-Csrf-Token} header (TODO-415).
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_RowActions_Test} proves the shipped script <i>contains</i> the fail-closed logic; it cannot prove
+ * the logic actually suppresses a request and surfaces a visible refusal at runtime.  This canary drives the real
+ * runtime in Chromium against a stubbed {@code fetch}, so "the request was not sent and a banner became visible" is
+ * measured rather than inferred.  The client refusal is defense-against-consumer-omission; the server-side
+ * {@link LoopbackBoundary} is the real control.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  It reuses that profile's provisioned Node + Playwright browser, and derives its own prober
+ * ({@code row-actions.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is needed
+ * to add this second canary.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link PagePanelVisibility_BrowserTest} &mdash; the sibling {@code juneau-pages.js} canary.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=RowActionCsrf_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RowActionCsrf_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		// The pom's js-tests profile provisions ONE harness property (the panel-visibility prober); this second
+		// canary lives beside it in src/test/js, so it is derived from that property's directory rather than adding
+		// a new pom property.
+		var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("row-actions.cjs");
+
+		// The fixture restates nothing under test: it loads the REAL served juneau-views.js.  No jQuery/DataTables
+		// is needed - the prober drives the runtime's exposed pure + DOM helpers directly.
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.VIEWS_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("row-actions.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	/** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("row-actions-stdout.json");
+		var stderr = dir.resolve("row-actions-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@SuppressWarnings("unchecked")
+	private static Map<String,Object> req(String key) {
+		return (Map<String,Object>) report.get(key);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) The runtime loaded, at contract version "3"
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_runtimeLoadedAtContractVersionThree() {
+		assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+		assertEquals("3", report.get("contractVersion"), () -> report.toString());
+		assertEquals(LoopbackBoundary.DEFAULT_CSRF_HEADER, report.get("defaultCsrfHeader"), () -> report.toString());
+		assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) The pure request builder fails closed
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_whitespaceTokenRefuses() {
+		assertEquals(Boolean.TRUE, req("reqBlankWhitespace").get("refuse"), () -> report.toString());
+		assertEquals("missing-token", req("reqBlankWhitespace").get("reason"), () -> report.toString());
+	}
+
+	@Test void b02_absentTokenRefuses() {
+		assertEquals(Boolean.TRUE, req("reqAbsent").get("refuse"), () -> report.toString());
+		assertEquals("missing-token", req("reqAbsent").get("reason"), () -> report.toString());
+	}
+
+	@Test void b03_safeMethodRefuses() {
+		assertEquals(Boolean.TRUE, req("reqSafeMethod").get("refuse"), () -> report.toString());
+		assertEquals("safe-method", req("reqSafeMethod").get("reason"), () -> report.toString());
+	}
+
+	@Test void b04_validTokenYieldsJsonRequestWithCsrfHeader() {
+		var r = req("reqValid");
+		assertNull(r.get("refuse"), () -> report.toString());
+		assertEquals("POST", r.get("method"), () -> report.toString());
+		@SuppressWarnings("unchecked")
+		var headers = (Map<String,Object>) r.get("headers");
+		assertEquals("application/json", headers.get("Content-Type"), () -> report.toString());
+		assertEquals("tok-123", headers.get(LoopbackBoundary.DEFAULT_CSRF_HEADER), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) The DOM submit path: blank token = visible refusal + NO request; valid token = a real request, no banner
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_blankTokenIssuesNoRequestAndShowsVisibleRefusal() {
+		assertEquals(Boolean.FALSE, report.get("blankTokenFetchIssued"), () -> "a request was issued with no token: " + report);
+		assertEquals(Boolean.TRUE, report.get("blankTokenRefusalVisible"), () -> "no VISIBLE refusal rendered: " + report);
+		assertNotNull(report.get("blankTokenRefusalText"), () -> report.toString());
+	}
+
+	@Test void c02_validTokenIssuesTheRequestWithNoRefusal() {
+		assertEquals(Boolean.TRUE, report.get("validTokenFetchIssued"), () -> "no request was issued with a valid token: " + report);
+		assertEquals("POST", report.get("validTokenMethod"), () -> report.toString());
+		assertEquals("application/json", report.get("validTokenContentType"), () -> report.toString());
+		assertEquals("tok-123", report.get("validTokenCsrfHeader"), () -> report.toString());
+		assertEquals(Boolean.FALSE, report.get("validTokenRefusalVisible"), () -> "a refusal rendered for a valid submit: " + report);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowSelectionBulk_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowSelectionBulk_BrowserTest.java
new file mode 100644
index 0000000000..b15a9256da
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowSelectionBulk_BrowserTest.java
@@ -0,0 +1,263 @@
+/*
+ * 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 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.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The row-selection + bulk-mutation half of the module's <b>JavaScript-execution harness</b> ({@code TODO-428}):
+ * runs the REAL served {@code juneau-views.js} in a real headless browser and asserts, as a user would experience
+ * it, that:
+ * <ul>
+ * 	<li>per-row selection and select-all toggle a live selection set keyed by the STABLE row id (never a DOM index);
+ * 	<li>a poll/sort/page draw silently DROPS any selected id no longer on screen (MED-11's persistence rule);
+ * 	<li>row-selection and bulk-mutation are two INDEPENDENT opt-ins - a selection-only (e.g. export) table never
+ * 		carries the bulk marker, even though both are declared via the same {@code hasSelection}/{@code hasBulk}
+ * 		DOM-attribute mechanism (HIGH-5);
+ * 	<li>a bulk action fires N INDEPENDENT per-row writes - never one aggregate request - each with its OWN
+ * 		in-flight marker and its OWN typed {@code ActionResult}, so one target's failure/refusal can never be
+ * 		hidden behind an overall "success" (MED-4);
+ * 	<li>a selected id whose row is no longer present (went off-screen between the click and execution) is silently
+ * 		skipped rather than targeted;
+ * 	<li>the bulk toolbar's buttons are live-gated on the selection count, and the independently-versioned bulk
+ * 		sidecar is read/contract-checked correctly at runtime (R2).
+ * </ul>
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_Selection_Test} proves the shipped script <i>contains</i> the selection/bulk logic in the right
+ * shape; it cannot prove a checkbox click actually updates the live selection set, that a draw actually prunes an
+ * off-screen id, or that a bulk action actually issues N independent requests with N independent settled outcomes.
+ * This canary drives the real runtime in Chromium against a stubbed {@code fetch}, so those user-visible facts are
+ * measured rather than inferred.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  It reuses that profile's provisioned Node + Playwright browser and derives its own prober
+ * ({@code row-selection-bulk.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is
+ * needed.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ModalResult_BrowserTest} &mdash; the sibling TODO-416/417 canary this reuses the settle
+ * 		path from.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=RowSelectionBulk_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RowSelectionBulk_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("row-selection-bulk.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.VIEWS_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("row-selection-bulk.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("row-selection-bulk-stdout.json");
+		var stderr = dir.resolve("row-selection-bulk-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@SuppressWarnings("unchecked")
+	private static Map<String,Object> sub(String key) {
+		return (Map<String,Object>) report.get(key);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) The runtime loaded, at the bulk contract's OWN version (independent of VIEW_META's)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_runtimeLoadedAtBulkContractVersion() {
+		assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+		assertEquals(BulkMutateDef.CONTRACT_VERSION, report.get("bulkContractVersion"), () -> report.toString());
+		assertEquals("1", BulkMutateDef.CONTRACT_VERSION);
+		assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Per-row selection + select-all, keyed by the stable row id
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_checkingTwoRowsSelectsExactlyThoseStableIds() {
+		assertEquals(List.of("1", "2"), report.get("afterTwoChecked"), () -> report.toString());
+	}
+
+	@Test void b02_selectAllSelectsEveryRowCurrentlyOnScreen() {
+		assertEquals(Boolean.TRUE, report.get("hasSelectAllCheckbox"), () -> report.toString());
+		assertEquals(List.of("1", "2", "3"), report.get("afterSelectAll"), () -> report.toString());
+	}
+
+	@Test void b03_deselectAllClearsTheSelection() {
+		assertEquals(List.of(), report.get("afterDeselectAll"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) The off-screen-id-drop persistence rule (MED-11/Q2) actually prunes on a draw
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_aDrawDropsAnIdThatLeftTheScreen() {
+		// Row '3' was removed from the DOM (simulating it leaving the current page/sort/poll draw) before the
+		// draw.dt tick fired - the persistence rule must drop it, keeping only '1' and '2'.
+		assertEquals(List.of("1", "2"), report.get("selectedAfterOffScreenDraw"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Two INDEPENDENT opt-ins (HIGH-5) - verified against the ACTUAL DOM-attribute detection at runtime
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_selectionOnlyTableNeverCarriesTheBulkMarker() {
+		assertEquals(Boolean.TRUE, report.get("selectOnlyHasSelection"), () -> report.toString());
+		assertEquals(Boolean.FALSE, report.get("selectOnlyHasBulk"),
+			() -> "a selection-only (export) table must never surface a bulk-mutate control: " + report);
+	}
+
+	@Test void d02_bulkTableCarriesBothMarkers() {
+		assertEquals(Boolean.TRUE, report.get("withBulkHasSelection"), () -> report.toString());
+		assertEquals(Boolean.TRUE, report.get("withBulkHasBulk"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) Bulk = N INDEPENDENT per-row writes; per-target typed result; per-row in-flight marker; off-screen skip
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_bulkIssuesExactlyOneRequestPerSelectedOnScreenTarget() {
+		var bulk = sub("bulk");
+		// Four ids were "selected" but only three ('1','2','3') have an on-screen row; '9' must be silently
+		// skipped rather than targeted - never a fourth, aggregate, or missing-target request.
+		assertEquals(3L, ((Number) bulk.get("fetchCount")).longValue(), () -> report.toString());
+		assertEquals(List.of("1", "2", "3"), bulk.get("targetIds"), () -> report.toString());
+		assertEquals(List.of("ack", "ack", "ack"), bulk.get("actionIds"), () -> report.toString());
+	}
+
+	@Test void e02_eachTargetWasMarkedInFlightBeforeItsOwnRequest() {
+		var bulk = sub("bulk");
+		@SuppressWarnings("unchecked")
+		var atFetch = (Map<String,Object>) bulk.get("inflightAtFetchTime");
+		assertEquals(Boolean.TRUE, atFetch.get("1"), () -> report.toString());
+		assertEquals(Boolean.TRUE, atFetch.get("2"), () -> report.toString());
+		assertEquals(Boolean.TRUE, atFetch.get("3"), () -> report.toString());
+	}
+
+	@Test void e03_everyTargetsInFlightMarkerClearedOnItsOwnTerminalOutcome() {
+		// MED-4: per-target marker clearing on every terminal outcome - success, failure, AND refusal.
+		var bulk = sub("bulk");
+		@SuppressWarnings("unchecked")
+		var after = (Map<String,Object>) bulk.get("inflightAfter");
+		assertEquals(Boolean.FALSE, after.get("1"), () -> report.toString());
+		assertEquals(Boolean.FALSE, after.get("2"), () -> report.toString());
+		assertEquals(Boolean.FALSE, after.get("3"), () -> report.toString());
+	}
+
+	@Test void e04_perTargetResultsAreIndependent_noAggregateSuccessMasksAFailure() {
+		// The load-bearing HIGH-5/MED-4 case: target '1' succeeds, '2' fails, '3' is refused - all three render
+		// their OWN outcome; none is hidden behind (or overwritten by) another target's result.
+		var bulk = sub("bulk");
+		@SuppressWarnings("unchecked")
+		var o1 = (Map<String,Object>) bulk.get("outcome1");
+		@SuppressWarnings("unchecked")
+		var o2 = (Map<String,Object>) bulk.get("outcome2");
+		@SuppressWarnings("unchecked")
+		var o3 = (Map<String,Object>) bulk.get("outcome3");
+		assertEquals("success", o1.get("state"), () -> report.toString());
+		assertTrue(String.valueOf(o1.get("text")).contains("Done"), () -> report.toString());
+		assertEquals("failure", o2.get("state"), () -> report.toString());
+		assertTrue(String.valueOf(o2.get("text")).contains("nope"), () -> report.toString());
+		assertEquals("refusal", o3.get("state"), () -> report.toString());
+		assertTrue(String.valueOf(o3.get("text")).contains("write-guard:not-armed"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// f) The bulk toolbar live-gates its buttons on the selection count
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_toolbarButtonsAreDisabledUntilSomethingIsSelected() {
+		var toolbar = sub("toolbar");
+		assertEquals(Boolean.TRUE, toolbar.get("disabledInitially"), () -> report.toString());
+		assertEquals(Boolean.FALSE, toolbar.get("disabledWithSelection"), () -> report.toString());
+		assertTrue(String.valueOf(toolbar.get("countTextWithSelection")).contains("2"), () -> report.toString());
+		assertEquals(Boolean.TRUE, toolbar.get("disabledAfterCleared"), () -> report.toString());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// g) The independently-versioned bulk sidecar is actually read + contract-checked at runtime (R2)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void g01_bulkSidecarIsReadAndContractChecked() {
+		var sidecar = sub("bulkSidecar");
+		assertEquals(BulkMutateDef.CONTRACT_VERSION, sidecar.get("contractVersion"), () -> report.toString());
+		assertEquals(1L, ((Number) sidecar.get("actionCount")).longValue(), () -> report.toString());
+		assertEquals(Boolean.TRUE, sidecar.get("missingSidecarReturnsNull"), () -> report.toString());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java
new file mode 100644
index 0000000000..764b921196
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java
@@ -0,0 +1,214 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * TODO-420: validation-matrix tests for {@link Tab#content}/{@link Subtab#content}.
+ *
+ * <p>
+ * Pins the panel-body matrices stated on {@link Tab} and {@link Subtab}'s javadoc and mirrored across all three
+ * enforcement sites (design doc §"Findings resolution" MED-2):
+ * <ul class='spaced-list'>
+ * 	<li><b>{@code Tab = {view} | {subtabs} | {content} | {content+subtabs}}</b> &mdash; exactly one of
+ * 		{@code view}/{@code subtabs}/{@code content}, EXCEPT {@code content} may co-occur with {@code subtabs}
+ * 		(the preamble case).
+ * 	<li><b>{@code Subtab = {view} | {content}}</b> &mdash; exactly one.
+ * </ul>
+ *
+ * <p>
+ * Also covers the {@link PageDef#build()} consequence of widening the matrix: a content-only tab (no
+ * {@code view}, no {@code subtabs}) and a subtab carrying {@code content} instead of {@code view} must not throw
+ * (the pre-420 {@code PageDef.validate()} unconditionally dereferenced {@code t.subtabs}/{@code s.view} and would
+ * NPE on either shape).
+ *
+ * <p>
+ * Deliberately kept in its own 420-owned file (not added to the pre-existing {@code PageDef_Validation_Test}), per
+ * the item's constraint to keep every new assertion in a 420-owned fixture.
+ */
+class TabSubtabContent_Validation_Test extends TestBase {
+
+	public static class Release {
+		public String name;
+	}
+
+	private static ViewDef view(String id) {
+		return ViewDef.create(id)
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/" + id + "/data")
+			.columns(Column.of("name").title("Name"))
+			.build();
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Tab matrix: {view} | {subtabs} | {content} | {content+subtabs}
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_tabWithContentOnly_accepted() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("overview", "Overview").content("<p>Hello, world.</p>"))
+			.build();
+		assertEquals("<p>Hello, world.</p>", page.tabs.get(0).content);
+	}
+
+	@Test void a02_tabWithContentAndSubtabs_accepted() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog")
+				.content("<p>Preamble.</p>")
+				.subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		assertEquals("<p>Preamble.</p>", page.tabs.get(0).content);
+		assertEquals(1, page.tabs.get(0).subtabs.size());
+	}
+
+	@Test void a03_tabWithViewAndContent_rejected() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("t", "T").view(view("v")).content("<p>x</p>"));
+		var e = assertThrows(IllegalArgumentException.class, page::build);
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void a04_tabWithViewAndSubtabsAndContent_rejected() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("t", "T").view(view("v")).content("<p>x</p>")
+				.subtabs(Subtab.create("s", "S").view(view("s"))));
+		var e = assertThrows(IllegalArgumentException.class, page::build);
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void a05_tabWithNeitherViewNorSubtabsNorContent_rejected() {
+		var page = PageDef.create("docs").tabs(Tab.create("t", "T"));
+		var e = assertThrows(IllegalArgumentException.class, page::build);
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void a06_tabWithViewAndSubtabsNoContent_stillRejected() {
+		// Regression: content's addition must not loosen the pre-existing view+subtabs exclusion.
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("t", "T").view(view("v")).subtabs(Subtab.create("s", "S").view(view("s"))));
+		var e = assertThrows(IllegalArgumentException.class, page::build);
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void a07_tabWithViewOnly_stillAccepted() {
+		// Regression: the pre-420 leaf-view shape must still build cleanly.
+		var page = PageDef.create("docs").tabs(Tab.create("t", "T").view(view("v"))).build();
+		assertNotNull(page.tabs.get(0).view);
+	}
+
+	@Test void a08_tabWithSubtabsOnlyNoContent_stillAccepted() {
+		// Regression: the pre-420 sub-tabbed shape (no preamble) must still build cleanly.
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("t", "T").subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		assertNull(page.tabs.get(0).content);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Subtab matrix: {view} | {content}
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_subtabWithContentOnly_accepted() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>Body.</p>")))
+			.build();
+		assertEquals("<p>Body.</p>", page.tabs.get(0).subtabs.get(0).content);
+		assertNull(page.tabs.get(0).subtabs.get(0).view);
+	}
+
+	@Test void b02_subtabWithViewOnly_stillAccepted() {
+		// Regression: the pre-420 subtab-must-have-a-view shape still builds cleanly.
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+			.build();
+		assertNull(page.tabs.get(0).subtabs.get(0).content);
+	}
+
+	@Test void b03_subtabWithBothViewAndContent_rejected() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog")
+				.subtabs(Subtab.create("s", "S").view(view("s")).content("<p>x</p>")));
+		var e = assertThrows(IllegalArgumentException.class, page::build);
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void b04_subtabWithNeitherViewNorContent_rejected() {
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S")));
+		var e = assertThrows(IllegalArgumentException.class, page::build);
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void b05_mixedViewAndContentSubtabsUnderOneTab_accepted() {
+		// A tab may mix view-backed and content-backed subtabs freely.
+		var page = PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(
+				Subtab.create("data", "Data").view(view("data")),
+				Subtab.create("about", "About").content("<p>About this catalog.</p>")))
+			.build();
+		assertNotNull(page.tabs.get(0).subtabs.get(0).view);
+		assertNotNull(page.tabs.get(0).subtabs.get(1).content);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// PageDef.build() consequences: must not NPE on content-only shapes (view-id dedup logic)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_contentOnlyTab_doesNotNpeOnViewIdDedup() {
+		// Pre-420, PageDef.validate() unconditionally iterated t.subtabs when t.view was null - a content-only
+		// tab (no subtabs at all) would NPE there. Must build cleanly instead.
+		assertDoesNotThrow(() -> PageDef.create("docs")
+			.tabs(Tab.create("overview", "Overview").content("<p>Hi.</p>"))
+			.build());
+	}
+
+	@Test void c02_contentOnlySubtab_doesNotNpeOnViewIdDedup() {
+		// Pre-420, PageDef.validate() unconditionally read s.view.id for every subtab - a content-only subtab
+		// (s.view == null) would NPE there. Must build cleanly instead.
+		assertDoesNotThrow(() -> PageDef.create("docs")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>Hi.</p>")))
+			.build());
+	}
+
+	@Test void c03_multipleContentOnlyTabs_doNotCollideOnAbsentViewIds() {
+		// Two content-only tabs (no ViewDef at all) must not be mistaken for a duplicate-view-id collision.
+		var page = PageDef.create("docs")
+			.tabs(
+				Tab.create("a", "A").content("<p>A</p>"),
+				Tab.create("b", "B").content("<p>B</p>"))
+			.build();
+		assertEquals(2, page.tabs.size());
+	}
+
+	@Test void c04_duplicateViewIdAcrossAContentTabAndAViewTab_stillDetected() {
+		// The dedup rule itself must still fire when an actual duplicate view id IS present, alongside an
+		// unrelated content-only tab.
+		var page = PageDef.create("docs")
+			.tabs(
+				Tab.create("a", "A").content("<p>A</p>"),
+				Tab.create("b", "B").view(view("shared")),
+				Tab.create("c", "C").view(view("shared")));
+		var e = assertThrows(IllegalArgumentException.class, page::build);
+		assertTrue(e.getMessage().contains("shared"), e::getMessage);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
index 67b70d4c08..be7184bebb 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
@@ -113,14 +113,23 @@ class TablePolling_Wiring_Test extends TestBase {
 		assertTrue(fnBody.contains("if (document.hidden) return;"), fnBody);
 	}
 
-	/** A plain interval fetch - not a streaming/SSE transport. */
+	/**
+	 * A plain interval fetch - not a streaming/SSE transport.
+	 *
+	 * <p>
+	 * Scoped to the {@code initPolling} function body: the TABLE-POLLING transport must stay a plain
+	 * {@code setInterval} + {@code dt.ajax.reload} loop and must never itself become an {@code EventSource} stream.
+	 * The check is deliberately NOT over the whole file: the async-job feature (TODO-425) legitimately opens an
+	 * {@code EventSource} elsewhere (its own DISTINCT job-running affordance, HIGH-9), which must not freeze polling -
+	 * so streaming may exist in the file, just never inside the polling loop.
+	 */
 	@Test void b05_initPolling_isAPlainIntervalFetch_notAStreamingTransport() throws Exception {
 		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
 		var fnBody = functionBody(body, "function initPolling(");
 		assertTrue(fnBody.contains("setInterval(poll, intervalMs)"), fnBody);
 		assertTrue(fnBody.contains("dt.ajax.reload(null, false)"), fnBody);
-		assertFalse(body.toLowerCase().contains("eventsource"), body);
-		assertFalse(body.contains("text/event-stream"), body);
+		assertFalse(fnBody.toLowerCase().contains("eventsource"), fnBody);
+		assertFalse(fnBody.contains("text/event-stream"), fnBody);
 	}
 
 	/** A failed round trip must flip a DISTINCT visible state, not just leave a frozen "fresh" timestamp. */
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
index 3ef8f2687c..662a54d517 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
@@ -53,7 +53,7 @@ class ViewMeta_Contract_Test extends TestBase {
 	 */
 	private static final String EXPECTED_VIEW_META = """
 		{
-		  "contractVersion": "2",
+		  "contractVersion": "3",
 		  "id": "releases",
 		  "rowType": "Release",
 		  "dataMode": "server",
@@ -125,9 +125,10 @@ class ViewMeta_Contract_Test extends TestBase {
 
 	@Test void a03_reservedFieldsOmittedNotNull() {
 		var json = Json.of(releasesView());
-		// "details" is implemented - see e07/e08 below for its POSITIVE assertion. The remaining reserved C/D/E
-		// stubs must still be omitted entirely (not serialized as null keys).
-		for (var k : List.of("rowActions", "catalog", "format", "description", "pinned", "defaultVisible")) {
+		// "details" and "rowActions" are implemented - see f01-f04 (details) and g01-g05 (rowActions) below for
+		// their POSITIVE assertions. The remaining reserved C/D/E stubs must still be omitted entirely (not
+		// serialized as null keys).
+		for (var k : List.of("catalog", "format", "description", "pinned", "defaultVisible")) {
 			var key = "\"" + k + "\"";
 			assertFalse(json.contains(key), () -> "Reserved field leaked into VIEW_META: " + k + "\n" + json);
 		}
@@ -429,4 +430,81 @@ class ViewMeta_Contract_Test extends TestBase {
 		assertEquals("owner", d.data);
 		assertNull(d.title);
 	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// g) Row actions: the positive assertion for the "rowActions" key (moved out of a03's negative reserved list -
+	// same positive-assertion-plus-remaining-negatives split used for "details" in section f).  This freezes the
+	// COMPLETE RowAction wire schema (TODO-399 Decision 8) so a later wave (TODO-416) can rely on it verbatim.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void g01_rowActions_omittedWhenUnset() {
+		// The golden fixture view never calls .rowActions(...) - confirms the new field doesn't leak when absent,
+		// and that a01/a02's frozen contract is untouched by this additive field.
+		var json = Json.of(releasesView());
+		assertFalse(json.contains("\"rowActions\""), json);
+	}
+
+	@Test void g02_rowActions_emittedInPinnedOrder_withCompleteWireSchema() {
+		var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+			.rowActions(
+				RowAction.create("ack").label("Acknowledge").icon("check").endpoint("servlet:/x/{id}/ack")
+					.method(RowAction.Method.POST).confirm("Acknowledge this?").form("servlet:/x/ack-form")
+					.present(RowAction.Present.DIALOG).onSuccess(RowAction.OnSuccess.MERGE_ROW))
+			.build();
+		var json = Json.of(v);
+		// Every RowAction wire field, in the pinned @BeanType order, with the exact tokens 416 will consume.
+		assertTrue(json.contains(
+			"\"rowActions\":[{\"id\":\"ack\",\"label\":\"Acknowledge\",\"icon\":\"check\","
+			+ "\"endpoint\":\"servlet:/x/{id}/ack\",\"method\":\"POST\",\"confirm\":\"Acknowledge this?\","
+			+ "\"form\":\"servlet:/x/ack-form\",\"present\":\"dialog\",\"onSuccess\":\"mergeRow\"}]"),
+			json);
+	}
+
+	@Test void g03_rowActions_omitsUnsetOptionalFields() {
+		// Only id is required; every other RowAction field is omitted (not null) when unset.
+		var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+			.rowActions(RowAction.create("del").endpoint("servlet:/x/{id}").method(RowAction.Method.DELETE))
+			.build();
+		var json = Json.of(v);
+		assertTrue(json.contains("\"rowActions\":[{\"id\":\"del\",\"endpoint\":\"servlet:/x/{id}\",\"method\":\"DELETE\"}]"), json);
+		assertFalse(json.contains("\"confirm\""), json);
+		assertFalse(json.contains("\"present\""), json);
+		assertFalse(json.contains("\"onSuccess\""), json);
+	}
+
+	@Test void g04_rowActions_isAfterRowClassRules_andBeforeDetails_inTopLevelKeyOrder() {
+		var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+			.rowClassRule("error", Op.PRESENT, "row-flagged")
+			.rowActions(RowAction.create("go").endpoint("u").method(RowAction.Method.POST))
+			.details(ViewDef.DetailDef.of("owner").title("Owner"))
+			.poll(60_000L)
+			.build();
+		Map<?,?> actual = Json.to(Json.of(v), Map.class);
+		assertEquals(
+			List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "rowClassRules", "rowActions", "details", "pollIntervalMs"),
+			new ArrayList<>(actual.keySet()));
+	}
+
+	@Test void g05_rowActionMethodTokensAreTheNonSafeMethods() {
+		// The Method enum makes a safe method (GET/HEAD/OPTIONS/TRACE) unexpressible - the build-time half of the
+		// HIGH-7 refusal - and each token is the uppercase HTTP method the client submits with.
+		assertEquals("POST", RowAction.Method.POST.wire());
+		assertEquals("PUT", RowAction.Method.PUT.wire());
+		assertEquals("PATCH", RowAction.Method.PATCH.wire());
+		assertEquals("DELETE", RowAction.Method.DELETE.wire());
+	}
+
+	@Test void g06_rowActionCreateBlankIdThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> RowAction.create("  "));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+	}
+
+	@Test void g07_rowActionPresentAndOnSuccessTokens() {
+		assertEquals("page", RowAction.Present.PAGE.wire());
+		assertEquals("dialog", RowAction.Present.DIALOG.wire());
+		assertEquals("inline", RowAction.Present.INLINE.wire());
+		assertEquals("redraw", RowAction.OnSuccess.REDRAW.wire());
+		assertEquals("mergeRow", RowAction.OnSuccess.MERGE_ROW.wire());
+		assertEquals("navigate", RowAction.OnSuccess.NAVIGATE.wire());
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java
new file mode 100644
index 0000000000..4dc0107e96
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java
@@ -0,0 +1,174 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@code TODO-428}: {@link ViewTable}'s row-selection and bulk-mutation rendering &mdash; the DOM-shape half of the
+ * separability guarantee (design-doc HIGH-5) and the R2 client-only/non-wire guard.
+ */
+class ViewTable_SelectionBulk_Test extends TestBase {
+
+	public static class Incident {
+		public String incidentId;
+		public String status;
+		public Incident(String incidentId, String status) { this.incidentId = incidentId; this.status = status; }
+	}
+
+	private static ViewDef view() {
+		return ViewDef.create("incidents")
+			.rowType(Incident.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/incidents/data")
+			.columns(Column.of("incidentId").title("Incident"), Column.of("status").title("Status"))
+			.build();
+	}
+
+	private static String bulkSidecarBody(String html, String viewId) {
+		var marker = "id=\"" + ViewTable.BULK_SIDECAR_ID_PREFIX + viewId + "\"";
+		var open = html.indexOf(marker);
+		assertTrue(open >= 0, () -> "bulk sidecar not found:\n" + html);
+		var contentStart = html.indexOf('>', open) + 1;
+		var contentEnd = html.indexOf("</script>", contentStart);
+		return html.substring(contentStart, contentEnd);
+	}
+
+	private static String viewSidecarBody(String html, String viewId) {
+		var marker = "id=\"juneau-view:" + viewId + "\"";
+		var open = html.indexOf(marker);
+		assertTrue(open >= 0, () -> "view sidecar not found:\n" + html);
+		var contentStart = html.indexOf('>', open) + 1;
+		var contentEnd = html.indexOf("</script>", contentStart);
+		return html.substring(contentStart, contentEnd);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) An ordinary table (no selection) - unaffected, no new attributes at all.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_ordinaryTable_hasNeitherSelectNorBulkAttributes() {
+		var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open"))));
+		assertFalse(html.contains(ViewTable.SELECT_ATTR), html);
+		assertFalse(html.contains(ViewTable.BULK_ATTR), html);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Selection-only opt-in (e.g. selectable-for-export, PagerDuty-style: selection WITHOUT bulk mutation)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_selectionOnly_stampsSelectAttrsButNeverBulk() {
+		var selection = SelectionDef.create("incidentId");
+		var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open")), selection));
+		assertTrue(html.contains(ViewTable.SELECT_ATTR + "=\"1\""), html);
+		assertTrue(html.contains(ViewTable.ROW_ID_FIELD_ATTR + "=\"incidentId\""), html);
+		assertTrue(html.contains(ViewTable.SELECT_ALL_ATTR + "=\"1\""), html);   // selectAll() default true
+		assertTrue(html.contains("juneau-view-select-th"), html);
+		// The load-bearing separability fact (HIGH-5): a selection-only table has NO bulk affordance anywhere -
+		// not the marker attribute, not a bulk sidecar - there is no code path in ViewTable that could add one
+		// from a bare SelectionDef.
+		assertFalse(html.contains(ViewTable.BULK_ATTR), html);
+		assertFalse(html.contains(ViewTable.BULK_SIDECAR_ID_PREFIX), html);
+	}
+
+	@Test void b02_selectAllFalse_stampsZero() {
+		var selection = SelectionDef.create("incidentId").selectAll(false);
+		var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open")), selection));
+		assertTrue(html.contains(ViewTable.SELECT_ALL_ATTR + "=\"0\""), html);
+	}
+
+	@Test void b03_nullSelection_throws() {
+		assertThrows(IllegalArgumentException.class, () -> ViewTable.of(view(), List.of(), (SelectionDef) null));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) Bulk-mutate opt-in - requires (and renders) selection too, plus its OWN independently-versioned sidecar.
+	//------------------------------------------------------------------------------------------------------------------
+
+	private static BulkMutateDef bulkDef(SelectionDef selection) {
+		var ack = RowAction.create("ack").label("Acknowledge").endpoint("servlet:/incidents/bulk-ack").method(RowAction.Method.POST);
+		return BulkMutateDef.create(WritePermit.forCapability("incidents:ack"), selection).actions(ack);
+	}
+
+	@Test void c01_bulkMutate_stampsSelectAndBulkAttrs_plusBulkSidecar() {
+		var selection = SelectionDef.create("incidentId");
+		var bulk = bulkDef(selection);
+		var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open")), bulk));
+
+		assertTrue(html.contains(ViewTable.SELECT_ATTR + "=\"1\""), html);
+		assertTrue(html.contains(ViewTable.BULK_ATTR + "=\"1\""), html);
+		assertTrue(html.contains("id=\"" + ViewTable.BULK_SIDECAR_ID_PREFIX + "incidents\""), html);
+
+		var body = bulkSidecarBody(html, "incidents");
+		var expected = Json.to(Json.of(bulk), Map.class);
+		var actual = Json.to(body, Map.class);
+		assertEquals(expected, actual, body);
+		assertEquals(Set.of("contractVersion", "actions"), actual.keySet(), body);
+	}
+
+	@Test void c02_nullBulkMutate_throws() {
+		assertThrows(IllegalArgumentException.class, () -> ViewTable.of(view(), List.of(), (BulkMutateDef) null));
+	}
+
+	/**
+	 * The compile/DOM-shape guarantee from the OTHER direction: {@code ViewTable.of(..., BulkMutateDef)} has no
+	 * parameter through which a caller could supply a DIFFERENT {@link SelectionDef} than the one the
+	 * {@link BulkMutateDef} was built against - the overload only ever reads {@code bulkMutate.selection()}. The
+	 * lower-level core method enforces this explicitly when a caller mixes the two by hand.
+	 */
+	@Test void c03_core_rejectsASelectionThatIsNotTheBulkDefsOwnSelection() {
+		var selectionA = SelectionDef.create("incidentId");
+		var selectionB = SelectionDef.create("incidentId");   // a different instance, same field name
+		var bulk = bulkDef(selectionA);
+		assertThrows(IllegalArgumentException.class,
+			() -> ViewTable.of(MarshallingContext.DEFAULT, view(), List.of(), null, selectionB, bulk));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) R2 guard: selection/bulk are 100% client-only/non-wire - VIEW_META is byte-identical with or without them.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_viewMetaSidecarIsUnaffectedByEitherOptIn() {
+		var v = view();
+		var plainHtml = Html.of(ViewTable.of(v, List.of(new Incident("INC-1", "open"))));
+		var selectionHtml = Html.of(ViewTable.of(v, List.of(new Incident("INC-1", "open")), SelectionDef.create("incidentId")));
+		var bulkHtml = Html.of(ViewTable.of(v, List.of(new Incident("INC-1", "open")), bulkDef(SelectionDef.create("incidentId"))));
+
+		var plainMeta = viewSidecarBody(plainHtml, "incidents");
+		var selectionMeta = viewSidecarBody(selectionHtml, "incidents");
+		var bulkMeta = viewSidecarBody(bulkHtml, "incidents");
+
+		// Byte-identical VIEW_META regardless of the opt-in: selection/bulk never touch ViewDef's serialized form.
+		assertEquals(plainMeta, selectionMeta, "SelectionDef must not alter VIEW_META");
+		assertEquals(plainMeta, bulkMeta, "BulkMutateDef must not alter VIEW_META");
+		assertFalse(plainMeta.toLowerCase().contains("select"), plainMeta);
+		assertFalse(plainMeta.toLowerCase().contains("bulk"), plainMeta);
+	}
+
+	@Test void d02_viewDefContractVersionUnchanged() {
+		// Sanity: this wave must not have bumped the existing VIEW_META contract version.
+		assertEquals("3", ViewDef.CONTRACT_VERSION);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_AsyncJobs_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_AsyncJobs_Test.java
new file mode 100644
index 0000000000..1738c720db
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_AsyncJobs_Test.java
@@ -0,0 +1,200 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} async-job + SSE-streaming plumbing (TODO-425).
+ * Mirrors {@link ViewsJs_ModalResult_Test}'s served-script substring style: proves the load-bearing pieces of the
+ * async contract are present in the shipped asset without booting a browser (the behavioral proof lives in the opt-in
+ * {@code AsyncJob_BrowserTest} canary).
+ *
+ * <p>
+ * The single most important invariant asserted here is HIGH-9: the job-running affordance is a DISTINCT marker
+ * ({@code data-juneau-job}) that {@code hasInFlightRow} does NOT read, so a long job never freezes the table's
+ * polling the way the synchronous {@code data-juneau-inflight} marker does.
+ */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_AsyncJobs_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	private static String fn(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> signature + " not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) HIGH-9: the job-running affordance is a DISTINCT marker that never freezes polling
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_setRowJobRunning_setsDistinctJobMarker_neverTheInflightMarker() throws Exception {
+		var s = fn(viewsJs(), "function setRowJobRunning(");
+		assertTrue(s.contains("setAttribute(\"data-juneau-job\""), s);
+		assertTrue(s.contains("removeAttribute(\"data-juneau-job\")"), s);
+		// The whole point of HIGH-9: a job must NEVER set the synchronous in-flight marker that freezes polling.
+		assertFalse(s.contains("data-juneau-inflight"), () -> "job affordance must NOT touch data-juneau-inflight:\n" + s);
+		// A second job can't be launched on the same row while one runs.
+		assertTrue(s.contains("trigger.disabled"), s);
+	}
+
+	@Test void a02_hasInFlightRow_ignoresTheJobMarker_soPollingKeepsRunningDuringAJob() throws Exception {
+		// hasInFlightRow reads ONLY data-juneau-inflight; it must NOT read data-juneau-job, or a job would freeze
+		// the whole table's polling for up to the 120s hard timeout (hiding a resurrected incident) - exactly the
+		// failure HIGH-9 forbids.
+		var h = fn(viewsJs(), "function hasInFlightRow(");
+		assertTrue(h.contains("data-juneau-inflight"), h);
+		assertFalse(h.contains("data-juneau-job"), () -> "hasInFlightRow must ignore the job marker:\n" + h);
+	}
+
+	@Test void a03_startJobStream_usesTheJobAffordance_neverTheInflightMarker() throws Exception {
+		var s = fn(viewsJs(), "function startJobStream(");
+		assertTrue(s.contains("setRowJobRunning(tr, true)"), s);
+		assertTrue(s.contains("setRowJobRunning(tr, false)"), s);
+		// It must not co-opt the synchronous in-flight lifecycle.
+		assertFalse(s.contains("setRowInFlight("), () -> "startJobStream must not use the sync in-flight marker:\n" + s);
+		assertFalse(s.contains("data-juneau-inflight"), s);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) The async start is detected by RESPONSE SHAPE (a job pointer), not a new RowAction wire field
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_parseJobStarted_requiresANonBlankStreamUrl_andIsDisjointFromATypedResult() throws Exception {
+		var p = fn(viewsJs(), "function parseJobStarted(");
+		assertTrue(p.contains("isBlankToken(o.streamUrl)"), p);
+		// The two 2xx shapes are disjoint: a job pointer is recognized by streamUrl, a terminal result by outcome.
+		var settle = fn(viewsJs(), "function settleActionResponse(");
+		assertTrue(settle.contains("parseJobStarted(text)"), settle);
+		assertTrue(settle.contains("startJobStream("), settle);
+		// The job branch is checked BEFORE the typed-result branch.
+		assertTrue(settle.indexOf("parseJobStarted(text)") < settle.indexOf("parseActionResult(text)"), settle);
+	}
+
+	@Test void b02_settleClearsInflightBeforeRoutingToTheJobStream_soPollingResumesFirst() throws Exception {
+		// The in-flight marker is cleared as the FIRST statement of settle (417), so table polling has already
+		// resumed before the (possibly 120s) job even starts streaming - the job then only ever carries the
+		// distinct data-juneau-job marker.
+		var settle = fn(viewsJs(), "function settleActionResponse(");
+		var open = settle.indexOf("{");
+		var firstStmt = settle.substring(open + 1, settle.indexOf(";", open));
+		assertTrue(firstStmt.contains("setRowInFlight(tr, false)"), () -> "settle must clear the in-flight marker first:\n" + settle);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) The stream URL IS the capability (HIGH-4): an EventSource GET, no CSRF header on the stream
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_startJobStream_opensAnEventSourceOnTheCapabilityStreamUrl() throws Exception {
+		var s = fn(viewsJs(), "function startJobStream(");
+		assertTrue(s.contains("new EventSource(started.streamUrl)"), s);
+		// A browser EventSource cannot set headers, so the stream carries NO CSRF header - unguessability is the gate.
+		assertFalse(s.contains(DEFAULT_CSRF_HEADER_LITERAL), () -> "the SSE GET must not attach a CSRF header:\n" + s);
+		// Progress + a single terminal result event; a stream error is itself a non-optimistic terminal outcome.
+		assertTrue(s.contains("addEventListener(\"progress\""), s);
+		assertTrue(s.contains("addEventListener(\"result\""), s);
+		assertTrue(s.contains("addEventListener(\"error\""), s);
+	}
+
+	/** The exact header token the runtime uses for the CSRF header (so c01's negative assertion is meaningful). */
+	private static final String DEFAULT_CSRF_HEADER_LITERAL = "X-Csrf-Token";
+
+	@Test void c02_missingEventSourceDegradesToAVisibleUnknown_notASilentHang() throws Exception {
+		var s = fn(viewsJs(), "function startJobStream(");
+		assertTrue(s.contains("typeof EventSource === \"undefined\""), s);
+		assertTrue(s.contains("outcome: \"unknown\""), s);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Live progress is painted with textContent ONLY (never innerHTML) - the streamed content is customer-adjacent
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_renderJobProgress_usesTextContentNeverInnerHtml_andWiresCancel() throws Exception {
+		var body = viewsJs();
+		var r = fn(body, "function renderJobProgress(");
+		assertTrue(r.contains(".textContent"), r);
+		assertFalse(r.contains(".innerHTML"), () -> "job progress must never use innerHTML:\n" + r);
+		assertFalse(r.contains("insertAdjacentHTML"), r);
+		assertTrue(r.contains("juneau-view-job-progress"), r);
+		assertTrue(r.contains("juneau-view-job-cancel"), r);
+		assertTrue(r.contains("cancelJob(started, table, tr)"), r);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) Terminal result reuses the SAME contract handshake + outcome render (cancelled / cancelled-after-effect)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_finishJobFromResult_reusesContractHandshakeAndOutcomeRender() throws Exception {
+		var f = fn(viewsJs(), "function finishJobFromResult(");
+		assertTrue(f.contains("result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION"), f);
+		assertTrue(f.contains("normalizeOutcome(result)"), f);
+		assertTrue(f.contains("applySuccessBehavior("), f);
+		assertTrue(f.contains("renderActionOutcome("), f);
+	}
+
+	@Test void e02_actionOutcomeMessage_rendersBothReservedAsyncOutcomes() throws Exception {
+		// The frozen ActionResult contract reserved cancelled / cancelled-after-effect; the runtime renders both
+		// without any new UI (Q4: the two are DIFFERENT outcomes and must not be collapsed).
+		var body = viewsJs();
+		var m = fn(body, "function actionOutcomeMessage(");
+		assertTrue(m.contains("case \"cancelled\":"), m);
+		assertTrue(m.contains("case \"cancelled-after-effect\":"), m);
+		// And they are recognized outcomes (not normalized away to unknown).
+		assertTrue(body.contains("\"cancelled\": 1"), body);
+		assertTrue(body.contains("\"cancelled-after-effect\": 1"), body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// f) Cancel is a fail-closed non-safe POST; the SERVER is authoritative
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_buildJobCancelRequest_isAFailClosedCsrfPost() throws Exception {
+		var b = fn(viewsJs(), "function buildJobCancelRequest(");
+		assertTrue(b.contains("no-cancel-url"), b);
+		assertTrue(b.contains("missing-token"), b);           // fail-closed: a blank token sends nothing
+		assertTrue(b.contains("method: \"POST\""), b);         // a non-safe write, so the boundary's full checks apply
+		assertTrue(b.contains(DEFAULT_CSRF_HEADER_LITERAL) || b.contains("DEFAULT_CSRF_HEADER"), b);
+	}
+
+	@Test void f02_asyncFunctionsAreExposedOnThePublicApi() throws Exception {
+		var body = viewsJs();
+		for (var name : new String[]{"parseJobStarted", "buildJobCancelRequest", "setRowJobRunning", "renderJobProgress",
+				"clearJobProgress", "startJobStream", "finishJobFromResult", "cancelJob"})
+			assertTrue(body.contains(name + ": " + name), () -> "async function not exposed on NS.init: " + name);
+	}
+}
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
new file mode 100644
index 0000000000..5cbcb2bfe6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
@@ -0,0 +1,234 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.filter.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} declarative-modal, typed-action-result and
+ * in-flight-row plumbing (TODO-416/417).  Mirrors {@link ViewsJs_RowActions_Test}'s served-script substring style:
+ * proves the load-bearing pieces of the modal/result/in-flight contract are present in the shipped asset, without
+ * booting a browser (the behavioral proof lives in the opt-in {@code ModalResult_BrowserTest} canary).
+ */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_ModalResult_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	private static String fn(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> signature + " not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) The typed action-result contract version is its OWN constant, not aliased to the view contract (MED-7)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_actionResultContractVersion_isItsOwnConstant() throws Exception {
+		var body = viewsJs();
+		// The runtime constant must equal the server's ActionResult.CONTRACT_VERSION, and must NOT be the view one.
+		assertTrue(body.contains("JUNEAU_ACTION_RESULT_CONTRACT_VERSION = \"" + ActionResult.CONTRACT_VERSION + "\""), body);
+		assertEquals("1", ActionResult.CONTRACT_VERSION);
+		assertNotEquals(ViewDef.CONTRACT_VERSION, ActionResult.CONTRACT_VERSION);
+	}
+
+	@Test void a02_contractMismatchOn2xxRendersUnknown() throws Exception {
+		// A 2xx typed result whose contractVersion differs is a visible, non-optimistic UNKNOWN - never silently misread.
+		var body = viewsJs();
+		var settle = fn(body, "function settleActionResponse(");
+		assertTrue(settle.contains("result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION"), settle);
+		assertTrue(settle.contains("outcome: \"unknown\""), settle);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) The modal-open confirmation renders TYPED FIELDS via textContent - never innerHTML / raw markup (BLK-1/MED-9)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_dialogOverlayUsesTextContentNeverInnerHtml() throws Exception {
+		var body = viewsJs();
+		var build = fn(body, "function buildDialogOverlay(");
+		assertTrue(build.contains(".textContent"), build);
+		assertFalse(build.contains(".innerHTML"), () -> "dialog overlay must never use innerHTML:\n" + build);
+		assertFalse(build.contains("insertAdjacentHTML"), build);
+		// The typed confirmation fields are painted as dt/dd label+value pairs.
+		assertTrue(build.contains("juneau-view-dialog-fields"), build);
+	}
+
+	@Test void b02_settleAndOutcomePathsNeverUseInnerHtml() throws Exception {
+		// The write-result render path (settle -> outcome banner) must not use an HTML sink; the pre-existing
+		// innerHTML uses live only in the trusted icon/caret render helpers, never in the action-result path.
+		var body = viewsJs();
+		for (var sig : new String[]{"function settleActionResponse(", "function renderActionOutcome(",
+				"function showActionDialog(", "function submitActionDialog(", "function openActionDialog("}) {
+			var f = fn(body, sig);
+			assertFalse(f.contains(".innerHTML"), () -> sig + " must never use innerHTML:\n" + f);
+			assertFalse(f.contains("insertAdjacentHTML"), () -> sig + " must never use insertAdjacentHTML:\n" + f);
+		}
+	}
+
+	@Test void b03_dialogIsOpenedForPresentDialogActions() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("function isDialogAction("), body);
+		assertTrue(body.contains("action.present === \"dialog\""), body);
+		assertTrue(body.contains("function openActionDialog("), body);
+		// The modal-open confirmation fetch is a read-only GET.
+		var open = fn(body, "function openActionDialog(");
+		assertTrue(open.contains("method: \"GET\""), open);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) A non-2xx is a VISIBLE transport refusal read from the boundary envelope - not "HTTP 200 + schema" (HIGH-3)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_nonOkReadsLoopbackBoundaryEnvelope() throws Exception {
+		var body = viewsJs();
+		var settle = fn(body, "function settleActionResponse(");
+		assertTrue(settle.contains("! resp.ok"), settle);
+		assertTrue(settle.contains("X-Loopback-Boundary"), settle);
+		assertTrue(settle.contains("transport: true"), settle);
+	}
+
+	@Test void c02_transportRefusalPrefersBoundaryReasonThenEnvelope() throws Exception {
+		var body = viewsJs();
+		var t = fn(body, "function transportRefusal(");
+		assertTrue(t.contains("boundaryReason"), t);
+		assertTrue(t.contains("envelope"), t);
+		// The header name the boundary filter actually sets on a rejection.
+		assertEquals("X-Loopback-Boundary", LoopbackBoundaryFilter.REJECTION_HEADER);
+	}
+
+	@Test void c03_transportStatusMessagesCoverTheMappedCodes() throws Exception {
+		// 403/415/421 all map to a visible non-optimistic message.
+		var body = viewsJs();
+		var m = fn(body, "function transportStatusMessage(");
+		assertTrue(m.contains("403"), m);
+		assertTrue(m.contains("415"), m);
+		assertTrue(m.contains("421"), m);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) All four outcomes render; the outcome banner is visible + assistive
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_renderActionOutcomePaintsVisibleBanner() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("function renderActionOutcome("), body);
+		assertTrue(body.contains("juneau-view-action-outcome"), body);
+		var r = fn(body, "function renderActionOutcome(");
+		assertTrue(r.contains(".textContent"), r);
+		assertTrue(r.contains("\"status\""), r);   // success -> role=status
+		assertTrue(r.contains("\"alert\""), r);     // everything else -> role=alert
+	}
+
+	@Test void d02_outcomeMessageCoversAllFourSyncOutcomes() throws Exception {
+		var body = viewsJs();
+		var m = fn(body, "function actionOutcomeMessage(");
+		for (var o : new String[]{"success", "failure", "refusal"})
+			assertTrue(m.contains("case \"" + o + "\":"), () -> o + " not handled:\n" + m);
+		// unknown is the default branch.
+		assertTrue(m.contains("Outcome unknown"), m);
+	}
+
+	@Test void d03_normalizeOutcomeFallsBackToUnknown() throws Exception {
+		var body = viewsJs();
+		var n = fn(body, "function normalizeOutcome(");
+		assertTrue(n.contains("\"unknown\""), n);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) 417: in-flight marker set on the SYNC write, cleared on EVERY terminal outcome (incl. UNKNOWN) (MED-4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_setRowInFlightTogglesMarkerAndDisablesTrigger() throws Exception {
+		var body = viewsJs();
+		var s = fn(body, "function setRowInFlight(");
+		assertTrue(s.contains("setAttribute(\"data-juneau-inflight\""), s);
+		assertTrue(s.contains("removeAttribute(\"data-juneau-inflight\")"), s);
+		assertTrue(s.contains("trigger.disabled"), s);
+	}
+
+	@Test void e02_settleClearsMarkerFirstOnEveryOutcome() throws Exception {
+		// The very first statement of settle must clear the marker so polling ALWAYS resumes - the UNKNOWN clear is
+		// a hard requirement (hasInFlightRow/initPolling freeze polling while ANY marker is set).
+		var body = viewsJs();
+		var settle = fn(body, "function settleActionResponse(");
+		var open = settle.indexOf("{");
+		var firstStmt = settle.substring(open + 1, settle.indexOf(";", open));
+		assertTrue(firstStmt.contains("setRowInFlight(tr, false)"),
+			() -> "settleActionResponse must clear the in-flight marker FIRST:\n" + settle);
+	}
+
+	@Test void e03_submitSetsMarkerAndNetworkFailureClearsIt() throws Exception {
+		var body = viewsJs();
+		var submit = fn(body, "function submitRowAction(");
+		assertTrue(submit.contains("setRowInFlight(tr, true)"), submit);
+		// A network-level failure is itself a terminal outcome: clear the marker (polling resumes) + visible refusal.
+		assertTrue(submit.contains("setRowInFlight(tr, false)"), submit);
+		assertTrue(submit.contains("request-failed"), submit);
+	}
+
+	@Test void e04_pollingFreezeStillKeyedOnTheSameMarker() throws Exception {
+		// The marker attribute the 417 lifecycle toggles is the exact one hasInFlightRow reads to freeze polling.
+		var body = viewsJs();
+		var h = fn(body, "function hasInFlightRow(");
+		assertTrue(h.contains("data-juneau-inflight"), h);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// f) Re-render from the authoritative result payload (MERGE_ROW)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_mergeRowFromResultReRendersFromPayload() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("function mergeRowFromResult("), body);
+		var apply = fn(body, "function applySuccessBehavior(");
+		assertTrue(apply.contains("mergeRow"), apply);
+		assertTrue(apply.contains("result.row"), apply);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// g) The submit carries the idempotency key + target id bound at modal-open
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void g01_submitDialogCarriesIdempotencyKeyAndTargetId() throws Exception {
+		var body = viewsJs();
+		var s = fn(body, "function submitActionDialog(");
+		assertTrue(s.contains("idempotencyKey"), s);
+		assertTrue(s.contains("targetId"), s);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
new file mode 100644
index 0000000000..77c4308b88
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
@@ -0,0 +1,123 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.filter.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} row-action + fail-closed CSRF plumbing
+ * (TODO-415).  Mirrors {@code ViewsMixin_Serving_Test}'s served-script substring style: proves the load-bearing
+ * pieces of the runtime's row-menu/submit contract are present in the shipped asset, without booting a browser
+ * (the behavioral proof lives in the opt-in {@code RowActionCsrf_BrowserTest} canary).
+ *
+ * <p>
+ * The client refusal these tests pin is <b>defense-against-consumer-omission</b>, not the security control: the
+ * landed server-side {@link LoopbackBoundary} is.  The value of pinning the shapes here is that the two halves must
+ * agree by construction &mdash; same default header, same JSON content type, same {@code isBlank}-not-{@code isEmpty}
+ * fail-closed test, same non-safe-method rule.
+ */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_RowActions_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	@Test void a01_contractVersionBumpedToThree() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION = \"3\""), body);
+		assertEquals("3", ViewDef.CONTRACT_VERSION);
+	}
+
+	@Test void a02_defaultCsrfHeaderMatchesTheServerBoundary() throws Exception {
+		// The runtime's default header MUST equal LoopbackBoundary.DEFAULT_CSRF_HEADER or the two halves disagree.
+		var body = viewsJs();
+		assertTrue(body.contains("DEFAULT_CSRF_HEADER = \"" + LoopbackBoundary.DEFAULT_CSRF_HEADER + "\""), body);
+		assertEquals("X-Csrf-Token", LoopbackBoundary.DEFAULT_CSRF_HEADER);
+	}
+
+	@Test void a03_failClosedTokenTestUsesBlankNotEmpty() throws Exception {
+		// isBlankToken must match the boundary's check() (isBlank), so a WHITESPACE token also refuses - not the
+		// SynchronizerToken.matches() isEmpty(), which would let whitespace through to a confusing 403.
+		var body = viewsJs();
+		var start = body.indexOf("function isBlankToken(");
+		assertTrue(start >= 0, () -> "isBlankToken not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		var fn = body.substring(start, end < 0 ? body.length() : end);
+		assertTrue(fn.contains(".trim()"), fn);   // whitespace-sensitive
+	}
+
+	@Test void a04_actionSubmitSendsJsonBodyAndCsrfHeader() throws Exception {
+		var body = viewsJs();
+		var start = body.indexOf("function buildActionRequest(");
+		assertTrue(start >= 0, () -> "buildActionRequest not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		var fn = body.substring(start, end < 0 ? body.length() : end);
+		// JSON content type (so the write passes LoopbackBoundary.isJson) + the token under the header name.
+		assertTrue(fn.contains("\"Content-Type\": \"application/json\""), fn);
+		assertTrue(fn.contains("JSON.stringify("), fn);
+		assertTrue(fn.contains("headerName || DEFAULT_CSRF_HEADER"), fn);
+		// Both fail-closed refusals: safe/absent method, and blank token.
+		assertTrue(fn.contains("isSafeMethod(action.method)"), fn);
+		assertTrue(fn.contains("isBlankToken(token)"), fn);
+		assertTrue(fn.contains("refuse: true"), fn);
+	}
+
+	@Test void a05_safeMethodSetMirrorsMethodSafety() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("const SAFE_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1 }"), body);
+	}
+
+	@Test void a06_rowMenuAndTriggerAreWired() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("function initRowActions("), body);
+		assertTrue(body.contains("function buildRowActionMenu("), body);
+		assertTrue(body.contains("juneau-view-action-trigger"), body);
+		assertTrue(body.contains("juneau-view-action-menu"), body);
+	}
+
+	@Test void a07_refusalIsVisibleNotSilent() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("function renderRowActionRefusal("), body);
+		assertTrue(body.contains("juneau-view-action-refusal"), body);
+		assertTrue(body.contains("role\", \"alert\""), body);
+	}
+
+	@Test void a08_readsAutoEmbeddedCsrfAttribute() throws Exception {
+		// The runtime reads the token ViewTable auto-embeds under data-juneau-csrf (ViewTable.CSRF_ATTR).
+		var body = viewsJs();
+		assertTrue(body.contains("data-juneau-csrf"), body);
+		assertEquals("data-juneau-csrf", ViewTable.CSRF_ATTR);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java
new file mode 100644
index 0000000000..14447d8504
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java
@@ -0,0 +1,206 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} row-selection + bulk-mutation plumbing
+ * ({@code TODO-428}).  Mirrors {@link ViewsJs_RowActions_Test}'s served-script substring style: proves the
+ * load-bearing pieces of the two-independent-opt-ins contract are present in the shipped asset, without booting a
+ * browser (the behavioral proof lives in the opt-in {@code RowSelectionBulk_BrowserTest} canary).
+ */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_Selection_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	private static String functionBody(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) DOM attribute names mirror ViewTable's constants exactly - both halves must agree by construction.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_selectionDomAttrNamesMirrorViewTable() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("const SELECT_ATTR = \"" + ViewTable.SELECT_ATTR + "\""), body);
+		assertTrue(body.contains("const ROW_ID_ATTR = \"data-juneau-row-id\""), body);
+		assertTrue(body.contains("const ROW_ID_FIELD_ATTR = \"" + ViewTable.ROW_ID_FIELD_ATTR + "\""), body);
+		assertTrue(body.contains("const SELECT_ALL_ATTR = \"" + ViewTable.SELECT_ALL_ATTR + "\""), body);
+		assertTrue(body.contains("const BULK_ATTR = \"" + ViewTable.BULK_ATTR + "\""), body);
+		assertTrue(body.contains("const BULK_SIDECAR_ID_PREFIX = \"" + ViewTable.BULK_SIDECAR_ID_PREFIX + "\""), body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) A THIRD, independently-versioned contract for bulk actions - never aliased to VIEW_META or the
+	//    action-result contract (R2 guard).
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_bulkContractVersionIsItsOwnThirdConstant() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("const JUNEAU_BULK_CONTRACT_VERSION = \"" + BulkMutateDef.CONTRACT_VERSION + "\""), body);
+		// Three DISTINCT contract-version constants must all be present - none aliased to another.
+		assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION"), body);
+		assertTrue(body.contains("JUNEAU_ACTION_RESULT_CONTRACT_VERSION"), body);
+		assertTrue(body.contains("NS.BULK_CONTRACT_VERSION = JUNEAU_BULK_CONTRACT_VERSION"), body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) Selection identity is the STABLE ROW ID (MED-11) - rowIdOf/stampRowId never fall back to a DOM index.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_rowIdOf_resolvesFromRowDataOnly_neverAnIndex() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function rowIdOf(");
+		assertTrue(fn.contains("rowData[rowIdField]"), fn);
+		// No index-based fallback anywhere in this function - the whole point of MED-11.
+		assertFalse(fn.contains("index"), fn);
+	}
+
+	@Test void c02_stampRowId_writesTheStableIdAttribute_neverAnIndex() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function stampRowId(");
+		assertTrue(fn.contains("rowIdOf(rowData, rowIdField)"), fn);
+		assertTrue(fn.contains("rowEl.setAttribute(ROW_ID_ATTR"), fn);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) The off-screen-id-drop persistence rule (Q2/MED-11) - pure, DOM-free.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_pruneSelection_dropsIdsNotInTheCurrentDraw() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function pruneSelection(");
+		assertTrue(fn.contains("present[String(id)] = true"), fn);
+		assertTrue(fn.contains("Object.hasOwn(present, String(id))"), fn);
+	}
+
+	@Test void d02_initSelection_prunesOnEveryDraw() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function initSelection(");
+		assertTrue(fn.contains("\"draw.dt\""), fn);
+		assertTrue(fn.contains("pruneSelection(Array.from(selectionState.selected), currentRowIds())"), fn);
+		assertTrue(fn.contains("selectionState.selected = new Set(pruned)"), fn);
+	}
+
+	@Test void e01_selectAll_isScopedToTheCurrentDrawsRowsOnly() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function initSelection(");
+		// The select-all header checkbox only ever iterates rows currently in the tbody - never an off-screen page.
+		assertTrue(fn.contains("SELECT_ALL_ATTR"), fn);
+		assertTrue(fn.contains("tbody tr[\" + ROW_ID_ATTR + \"]"), fn);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// f) Two INDEPENDENT opt-ins (HIGH-5): hasBulk(...) is only ever reachable from inside the hasSelection(...)
+	//    branch of initTable - selection alone can never surface a bulk-mutate control.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_initTable_bulkIsOnlyEverConsultedInsideTheSelectionBranch() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function initTable(");
+		var selectionIdx = fn.indexOf("const selectionState = hasSelection(table)");
+		var selectionBranchIdx = fn.indexOf("if (selectionState) {");
+		var bulkCheckIdx = fn.indexOf("hasBulk(table)");
+		var bulkToolbarIdx = fn.indexOf("buildBulkToolbar(bulkDef, table, ctx, selectionState)");
+		assertTrue(selectionIdx >= 0, fn);
+		assertTrue(selectionBranchIdx > selectionIdx, fn);
+		assertTrue(bulkCheckIdx > selectionBranchIdx, fn);
+		assertTrue(bulkToolbarIdx > bulkCheckIdx, fn);
+	}
+
+	@Test void f02_initTable_unshiftsASyntheticLeadingSelectionColumn() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function initTable(");
+		assertTrue(fn.contains("opts.columns.unshift(buildSelectionColumnDef(selectionState))"), fn);
+	}
+
+	@Test void f03_initTable_selectionWiringIsUnconditionalOnBulkHealth() throws Exception {
+		// initSelection(...) must run whenever selection was declared, REGARDLESS of whether the bulk sidecar is
+		// present/healthy - selection (e.g. for export) must keep working even if bulk mutation is withheld.
+		var body = viewsJs();
+		var fn = functionBody(body, "function initTable(");
+		var initSelectionIdx = fn.indexOf("initSelection(table, dt, selectionState, ctx)");
+		var bulkCheckIdx = fn.indexOf("hasBulk(table)");
+		assertTrue(initSelectionIdx >= 0 && initSelectionIdx < bulkCheckIdx, fn);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// g) Per-target bulk execution (HIGH-5) - N independent submitRowAction(...) calls, never one aggregate request.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void g01_executeBulkAction_isAPerTargetLoop_notAnAggregateCall() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function executeBulkAction(");
+		assertTrue(fn.contains("ids.forEach(function (id) {"), fn);
+		assertTrue(fn.contains("submitRowAction(action, table, tr, ctx, { targetId: id })"), fn);
+		// No aggregate transport - a bulk action never opens its own fetch/Promise.all; it only reuses the
+		// single-row submit path once per target.
+		assertFalse(fn.contains("Promise.all"), fn);
+		assertFalse(fn.contains("fetch("), fn);
+	}
+
+	@Test void g02_executeBulkAction_skipsIdsThatHaveGoneOffScreen() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function executeBulkAction(");
+		assertTrue(fn.contains("if (!tr) return;"), fn);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// h) Bulk toolbar reflects the live selection count and gates on it (nothing to target with zero selected).
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void h01_bulkToolbar_disablesButtonsWhenSelectionIsEmpty() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function buildBulkToolbar(");
+		assertTrue(fn.contains("btn.disabled = true"), fn);
+		assertTrue(fn.contains("b.disabled = count === 0"), fn);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// i) A missing/contract-mismatched bulk sidecar is withheld, fail-loud, WITHOUT killing selection.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void i01_bulkContractMismatch_isLoggedAndWithheld_selectionSurvives() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function initTable(");
+		assertTrue(fn.contains("bulkDef.contractVersion !== JUNEAU_BULK_CONTRACT_VERSION"), fn);
+		assertTrue(fn.contains("bulk mutation withheld"), fn);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index 5014699b3a..dfd2af8b78 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -248,7 +248,7 @@ class ViewsMixin_Serving_Test extends TestBase {
 
 	@Test void d01_contractVersion_equalsViewDefContractVersion() {
 		assertEquals(ViewDef.CONTRACT_VERSION, ViewsMixin.CONTRACT_VERSION);
-		assertEquals("2", ViewsMixin.CONTRACT_VERSION);
+		assertEquals("3", ViewsMixin.CONTRACT_VERSION);
 	}
 
 	@Test void d02_viewsJs_bakesInContractVersionHandshake() throws Exception {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/WritePermit_SelectionDef_BulkMutateDef_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/WritePermit_SelectionDef_BulkMutateDef_Test.java
new file mode 100644
index 0000000000..fa0d0a2c30
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/WritePermit_SelectionDef_BulkMutateDef_Test.java
@@ -0,0 +1,175 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import java.lang.reflect.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@code TODO-428}: {@link WritePermit}, {@link SelectionDef}, and {@link BulkMutateDef} &mdash; the API-shape half
+ * of the separability guarantee (design-doc HIGH-5).
+ *
+ * <p>
+ * These are NOT prose assertions: {@link BulkMutateDef#create(WritePermit, SelectionDef)} is the ONLY way to obtain
+ * a {@link BulkMutateDef} (a private constructor + reflection below prove there is no other, narrower factory), and
+ * both parameters are non-null-checked at that single call site &mdash; so a bulk-mutate opt-in that omits a
+ * {@link WritePermit} cannot compile against any DIFFERENT, permit-less overload (there isn't one) and cannot
+ * construct one by passing {@code null} either (it throws).
+ */
+class WritePermit_SelectionDef_BulkMutateDef_Test extends TestBase {
+
+	//------------------------------------------------------------------------------------------------------------------
+	// WritePermit
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_forCapability_rejectsNullAndBlank() {
+		assertThrows(IllegalArgumentException.class, () -> WritePermit.forCapability(null));
+		assertThrows(IllegalArgumentException.class, () -> WritePermit.forCapability(""));
+		assertThrows(IllegalArgumentException.class, () -> WritePermit.forCapability("   "));
+	}
+
+	@Test void a02_forCapability_roundTripsTheCapability() {
+		var p = WritePermit.forCapability("incidents:ack");
+		assertEquals("incidents:ack", p.capability());
+		assertTrue(p.toString().contains("incidents:ack"), p.toString());
+	}
+
+	/** WritePermit has no public constructor - the factory is the only way in, mirroring WriteGuard's discipline. */
+	@Test void a03_noPublicConstructor() {
+		for (var c : WritePermit.class.getDeclaredConstructors())
+			assertFalse(Modifier.isPublic(c.getModifiers()), () -> "WritePermit must not expose a public constructor: " + c);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// SelectionDef
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_create_rejectsNullAndBlankRowIdField() {
+		assertThrows(IllegalArgumentException.class, () -> SelectionDef.create(null));
+		assertThrows(IllegalArgumentException.class, () -> SelectionDef.create(""));
+		assertThrows(IllegalArgumentException.class, () -> SelectionDef.create("  "));
+	}
+
+	@Test void b02_selectAll_defaultsTrue_andIsToggleable() {
+		var s = SelectionDef.create("id");
+		assertEquals("id", s.rowIdField());
+		assertTrue(s.selectAll());
+		assertFalse(s.selectAll(false).selectAll());
+		assertTrue(s.selectAll(true).selectAll());
+	}
+
+	@Test void b03_noPublicConstructor() {
+		for (var c : SelectionDef.class.getDeclaredConstructors())
+			assertFalse(Modifier.isPublic(c.getModifiers()), () -> "SelectionDef must not expose a public constructor: " + c);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// BulkMutateDef - the separability/compile-shape guarantee (HIGH-5)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_create_requiresBothPermitAndSelection() {
+		var permit = WritePermit.forCapability("cap");
+		var selection = SelectionDef.create("id");
+		assertThrows(IllegalArgumentException.class, () -> BulkMutateDef.create(null, selection));
+		assertThrows(IllegalArgumentException.class, () -> BulkMutateDef.create(permit, null));
+		assertThrows(IllegalArgumentException.class, () -> BulkMutateDef.create(null, null));
+	}
+
+	@Test void c02_create_succeedsWithBoth_andExposesBothBack() {
+		var permit = WritePermit.forCapability("cap");
+		var selection = SelectionDef.create("id");
+		var b = BulkMutateDef.create(permit, selection);
+		assertSame(permit, b.permit());
+		assertSame(selection, b.selection());
+	}
+
+	@Test void c03_actions_rejectsNullAndEmpty() {
+		var b = BulkMutateDef.create(WritePermit.forCapability("cap"), SelectionDef.create("id"));
+		assertThrows(IllegalArgumentException.class, () -> b.actions((RowAction[]) null));
+		assertThrows(IllegalArgumentException.class, b::actions);
+	}
+
+	@Test void c04_actions_roundTrips() {
+		var ack = RowAction.create("ack").label("Acknowledge").endpoint("servlet:/bulk/ack").method(RowAction.Method.POST);
+		var b = BulkMutateDef.create(WritePermit.forCapability("cap"), SelectionDef.create("id")).actions(ack);
+		assertEquals(1, b.actions.size());
+		assertSame(ack, b.actions.get(0));
+	}
+
+	/**
+	 * The API-SHAPE proof (HIGH-5), not a prose claim: {@code create(...)} is the ONLY public factory on
+	 * {@link BulkMutateDef} and its signature is EXACTLY {@code (WritePermit, SelectionDef)} - there is no
+	 * narrower overload a caller could reach for that skips the permit. Combined with c01 (a null permit throws)
+	 * and c05/c06 below (no public constructor, no setter for the permit/selection fields), this proves a
+	 * bulk-mutate opt-in cannot be constructed without supplying a real {@link WritePermit} - by shape, not by
+	 * javadoc sentence.
+	 */
+	@Test void c05_onlyOneCreateOverload_andItsSignatureRequiresWritePermitFirst() {
+		var createMethods = Arrays.stream(BulkMutateDef.class.getMethods())
+			.filter(m -> m.getName().equals("create"))
+			.toList();
+		assertEquals(1, createMethods.size(), () -> "expected exactly one create(...) overload: " + createMethods);
+		var m = createMethods.get(0);
+		assertArrayEquals(new Class<?>[] { WritePermit.class, SelectionDef.class }, m.getParameterTypes(),
+			() -> "BulkMutateDef.create(...) must require (WritePermit, SelectionDef), in that order: " + m);
+	}
+
+	@Test void c06_noPublicConstructor() {
+		for (var c : BulkMutateDef.class.getDeclaredConstructors())
+			assertFalse(Modifier.isPublic(c.getModifiers()), () -> "BulkMutateDef must not expose a public constructor: " + c);
+	}
+
+	/**
+	 * The permit/selection fields backing separability are NOT public mutable fields either - a caller cannot
+	 * bypass the constructor-time requirement by building a bare instance (impossible; no public ctor) and later
+	 * poking a permit in via reflection-free code.
+	 */
+	@Test void c07_permitAndSelectionAreNotPublicSettableFields() {
+		for (var f : BulkMutateDef.class.getDeclaredFields()) {
+			if (f.getType() == WritePermit.class || f.getType() == SelectionDef.class)
+				assertFalse(Modifier.isPublic(f.getModifiers()), () -> "must not be a public field: " + f);
+		}
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Independent contract version + per-target result shape (no permit/selection leak onto the wire)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_ownContractVersion_independentOfViewAndActionResult() {
+		// A third, independently-versioned contract, alongside ViewDef's and ActionResult's - see BulkMutateDef's
+		// class javadoc; this test only pins that the constant exists and is its own string, not that it must
+		// differ numerically (an incidental "3" vs "1" tells us nothing) - the independence is structural
+		// (three separate constants, never aliased), which the ViewTable render tests further verify at the wire.
+		assertEquals("1", BulkMutateDef.CONTRACT_VERSION);
+	}
+
+	@Test void d02_serializedForm_carriesOnlyContractVersionAndActions_neverThePermitOrSelection() {
+		var ack = RowAction.create("ack").endpoint("servlet:/bulk/ack").method(RowAction.Method.POST);
+		var b = BulkMutateDef.create(WritePermit.forCapability("cap"), SelectionDef.create("id")).actions(ack);
+		var json = Json.of(b);
+		assertFalse(json.contains("cap"), () -> "the WritePermit's capability must never reach the wire: " + json);
+		assertFalse(json.toLowerCase().contains("rowidfield"), () -> "the SelectionDef must never reach the wire: " + json);
+		var parsed = Json.to(json, Map.class);
+		assertEquals(Set.of("contractVersion", "actions"), parsed.keySet(), json);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/async-job.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/async-job.cjs
new file mode 100644
index 0000000000..9ffdb23ec5
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/async-job.cjs
@@ -0,0 +1,248 @@
+/*
+ * 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.
+ */
+
+/*
+ * async-job.cjs - real-browser prober for the juneau-views.js async-job + SSE-streaming contract (TODO-425).
+ *
+ * Never runs in a default build.  It is driven by AsyncJob_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ *   Usage:  node async-job.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, drives an async row action through its full lifecycle against
+ * a controllable FAKE EventSource (there is no server; the fixture is a file://): the start POST returns a job
+ * pointer (not a terminal result), the row picks up the DISTINCT data-juneau-job affordance (NOT
+ * data-juneau-inflight, so the table KEEPS polling - the load-bearing HIGH-9 fact), progress events update a
+ * visible banner painted with textContent, and the single terminal `result` event settles the row to
+ * success / cancelled / cancelled-after-effect, or a stream error settles it to a non-optimistic unknown.  Cancel
+ * issues a fail-closed CSRF POST to the job's cancelUrl.  Prints ONE JSON object to stdout.
+ *
+ * DIVISION OF LABOUR (mirrors modal-result.cjs / row-actions.cjs): this script only OBSERVES; every assertion
+ * lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/* Runs inside the page.  Async: the settle path reads the response body via a promise. */
+const PROBE = async function () {
+	const NS = window.JuneauViews;
+	const init = NS && NS.init;
+	const out = { hasInit: !!init };
+	if (!init) return out;
+
+	out.actionResultContractVersion = init.ACTION_RESULT_CONTRACT_VERSION;
+	const V = init.ACTION_RESULT_CONTRACT_VERSION;
+	const tick = () => new Promise(r => setTimeout(r, 0));
+
+	function rendered(el) {
+		if (!el) return false;
+		const r = el.getBoundingClientRect();
+		return r.width > 0 && r.height > 0;
+	}
+
+	// A minimal row with an actions cell and a trigger button (setRowJobRunning disables the trigger).
+	function makeRow(rowId) {
+		const table = document.createElement('table');
+		table.setAttribute('data-juneau-view', 'v');
+		table.setAttribute('data-juneau-csrf', 'tok-xyz');   // so the fail-closed cancel POST is armed
+		const tbody = document.createElement('tbody');
+		const tr = document.createElement('tr');
+		if (rowId != null) tr.setAttribute('data-juneau-row-id', rowId);
+		const td = document.createElement('td');
+		td.className = 'juneau-view-actions-cell';
+		const trigger = document.createElement('button');
+		trigger.className = 'juneau-view-action-trigger';
+		td.appendChild(trigger);
+		tr.appendChild(td);
+		tbody.appendChild(tr);
+		table.appendChild(tbody);
+		document.body.appendChild(table);
+		return { table: table, tr: tr, td: td, trigger: trigger };
+	}
+
+	// A fake fetch Response with a synchronous headers.get and an async text().
+	function resp(o) {
+		return {
+			ok: o.ok,
+			status: o.status,
+			headers: { get: n => (o.headers || {})[n] || null },
+			text: () => Promise.resolve(o.body != null ? o.body : '')
+		};
+	}
+
+	// A CONTROLLABLE fake EventSource: startJobStream opens one, and the prober emits progress/result/error on it.
+	// (There is no server; the fixture is a file:// page, so a real EventSource would just error immediately.)
+	const esInstances = [];
+	function FakeEventSource(url) {
+		this.url = url;
+		this.closed = false;
+		this._listeners = {};
+		esInstances.push(this);
+	}
+	FakeEventSource.prototype.addEventListener = function (type, fn) {
+		(this._listeners[type] = this._listeners[type] || []).push(fn);
+	};
+	FakeEventSource.prototype.close = function () { this.closed = true; };
+	FakeEventSource.prototype.emit = function (type, data) {
+		(this._listeners[type] || []).slice().forEach(fn => fn({ data: data }));
+	};
+	window.EventSource = FakeEventSource;
+
+	function lastEs() { return esInstances[esInstances.length - 1]; }
+	function jobBanner(dom) { return dom.td.querySelector('.juneau-view-job-progress'); }
+	function jobMsg(dom) {
+		const b = jobBanner(dom);
+		const m = b ? b.querySelector('.juneau-view-job-progress-msg') : null;
+		return m ? m.textContent : null;
+	}
+	function outcomeOf(dom) {
+		const b = dom.td.querySelector('.juneau-view-action-outcome');
+		return {
+			visible: rendered(b),
+			state: b ? b.getAttribute('data-state') : null,
+			role: b ? b.getAttribute('role') : null,
+			text: b ? b.textContent : null
+		};
+	}
+
+	// Start an async job by settling the start-POST response with a JOB POINTER (no `outcome` -> not a typed result).
+	async function startJob(rowId) {
+		const dom = makeRow(rowId || 'INC-1');
+		init.setRowInFlight(dom.tr, true);   // the sync submit set the in-flight marker; settle must clear it FIRST
+		const jobPointer = { jobId: 'cap-' + rowId, streamUrl: '/juneau-jobs/cap/stream', cancelUrl: '/juneau-jobs/cap/cancel' };
+		init.settleActionResponse(resp({ ok: true, status: 200, body: JSON.stringify(jobPointer) }),
+			{ id: 'ack', onSuccess: 'mergeRow' }, dom.table, dom.tr,
+			{ mergeRow: function (tr, row) { dom._merged = row; } });
+		await tick(); await tick(); await tick();
+		return dom;
+	}
+
+	// ---- Scenario 1: a running job uses the DISTINCT marker and does NOT freeze polling (HIGH-9) ----
+	{
+		const dom = await startJob('INC-1');
+		out.running = {
+			hasJobMarker: dom.tr.hasAttribute('data-juneau-job'),
+			hasInflightMarker: dom.tr.hasAttribute('data-juneau-inflight'),
+			// The load-bearing HIGH-9 fact: polling is NOT frozen while the job runs.
+			pollingFrozen: init.hasInFlightRow(dom.table),
+			progressVisible: rendered(jobBanner(dom)),
+			progressText: jobMsg(dom),
+			triggerDisabled: !!dom.trigger.disabled,
+			esOpened: esInstances.length > 0,
+			esUrl: lastEs() ? lastEs().url : null
+		};
+		// A progress event updates the live banner (still not frozen, still no outcome yet).
+		lastEs().emit('progress', 'step 2 of 3');
+		await tick();
+		out.progress = {
+			text: jobMsg(dom),
+			pollingFrozenDuringProgress: init.hasInFlightRow(dom.table),
+			outcomeYet: !!dom.td.querySelector('.juneau-view-action-outcome')
+		};
+		// The terminal `result` event settles the row to success: banner cleared, marker removed, trigger re-enabled.
+		lastEs().emit('result', JSON.stringify({ contractVersion: V, outcome: 'success', row: { id: 'INC-1', status: 'ack' } }));
+		await tick(); await tick();
+		out.settledSuccess = {
+			esClosed: lastEs().closed,
+			jobMarkerCleared: !dom.tr.hasAttribute('data-juneau-job'),
+			progressCleared: !jobBanner(dom),
+			triggerReEnabled: !dom.trigger.disabled,
+			pollingFrozen: init.hasInFlightRow(dom.table),
+			mergedRow: dom._merged,
+			outcome: outcomeOf(dom)
+		};
+	}
+
+	// ---- Scenario 2: Cancel issues a fail-closed CSRF POST; the SERVER-authoritative outcome arrives over SSE ----
+	{
+		const dom = await startJob('INC-2');
+		const fetchCalls = [];
+		const realFetch = window.fetch;
+		window.fetch = function (url, opts) { fetchCalls.push({ url: url, opts: opts }); return Promise.resolve(resp({ ok: true, status: 200, body: '' })); };
+		const cancelBtn = jobBanner(dom).querySelector('.juneau-view-job-cancel');
+		out.cancel = { cancelBtnVisible: rendered(cancelBtn) };
+		cancelBtn.click();
+		await tick(); await tick();
+		window.fetch = realFetch;
+		out.cancel.postIssued = fetchCalls.length > 0;
+		if (fetchCalls.length > 0) {
+			out.cancel.url = fetchCalls[0].url;
+			out.cancel.method = fetchCalls[0].opts.method;
+			out.cancel.csrfHeader = (fetchCalls[0].opts.headers || {})['X-Csrf-Token'];
+		}
+		// The authoritative terminal outcome still arrives over the stream: cancelled.
+		lastEs().emit('result', JSON.stringify({ contractVersion: V, outcome: 'cancelled' }));
+		await tick(); await tick();
+		out.cancel.outcome = outcomeOf(dom);
+		out.cancel.jobMarkerCleared = !dom.tr.hasAttribute('data-juneau-job');
+	}
+
+	// ---- Scenario 3: cancelled-after-effect is a DISTINCT terminal outcome (Q4), rendered on its own ----
+	{
+		const dom = await startJob('INC-3');
+		lastEs().emit('result', JSON.stringify({ contractVersion: V, outcome: 'cancelled-after-effect' }));
+		await tick(); await tick();
+		out.cancelledAfterEffect = outcomeOf(dom);
+	}
+
+	// ---- Scenario 4: a stream error is itself a non-optimistic terminal outcome; polling was never frozen ----
+	{
+		const dom = await startJob('INC-4');
+		lastEs().emit('error', {});
+		await tick(); await tick();
+		out.streamError = {
+			outcome: outcomeOf(dom),
+			jobMarkerCleared: !dom.tr.hasAttribute('data-juneau-job'),
+			pollingFrozen: init.hasInFlightRow(dom.table)
+		};
+	}
+
+	return out;
+};
+
+(async () => {
+	const [fixture] = process.argv.slice(2);
+	if (!fixture) {
+		process.stderr.write('usage: node async-job.cjs <page.html>\n');
+		process.exit(2);
+	}
+	if (!fs.existsSync(fixture))
+		throw new Error('fixture not found: ' + fixture);
+
+	const url = 'file://' + path.resolve(fixture);
+	const browser = await chromium.launch();
+	try {
+		const page = await browser.newPage();
+		const failures = [];
+		page.on('pageerror', e => failures.push(String(e)));
+		page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+		await page.goto(url);
+		await page.evaluate(() => new Promise(requestAnimationFrame));
+		const report = await page.evaluate(PROBE);
+		report.jsFailures = failures.slice();
+		process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+	} finally {
+		await browser.close();
+	}
+})().catch(e => {
+	process.stderr.write(String((e && e.stack) || e) + '\n');
+	process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
new file mode 100644
index 0000000000..fef9bb651f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
@@ -0,0 +1,227 @@
+/*
+ * 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.
+ */
+
+/*
+ * modal-result.cjs - real-browser prober for the juneau-views.js declarative-modal + typed-action-result +
+ * in-flight-row contract (TODO-416/417).
+ *
+ * Never runs in a default build.  It is driven by ModalResult_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ *   Usage:  node modal-result.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, exercises the modal overlay, the typed-field confirmation
+ * (painted with textContent, so an HTML-shaped field value must NOT become an element), every settled outcome
+ * (success/failure/refusal/unknown) plus a non-2xx transport refusal, and the in-flight marker lifecycle.  Prints
+ * ONE JSON object to stdout.
+ *
+ * DIVISION OF LABOUR (mirrors row-actions.cjs): this script only OBSERVES; every assertion lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/* Runs inside the page.  Async: the settle/dialog paths read the response body via promises. */
+const PROBE = async function () {
+	const NS = window.JuneauViews;
+	const init = NS && NS.init;
+	const out = { hasInit: !!init };
+	if (!init) return out;
+
+	out.actionResultContractVersion = init.ACTION_RESULT_CONTRACT_VERSION;
+
+	const tick = () => new Promise(r => setTimeout(r, 0));
+
+	function rendered(el) {
+		if (!el) return false;
+		const r = el.getBoundingClientRect();
+		return r.width > 0 && r.height > 0;
+	}
+
+	// A minimal row with an actions cell and a trigger button (setRowInFlight disables the trigger).
+	function makeRow(rowId) {
+		const table = document.createElement('table');
+		table.setAttribute('data-juneau-view', 'v');
+		const tbody = document.createElement('tbody');
+		const tr = document.createElement('tr');
+		if (rowId != null) tr.setAttribute('data-juneau-row-id', rowId);
+		const td = document.createElement('td');
+		td.className = 'juneau-view-actions-cell';
+		const trigger = document.createElement('button');
+		trigger.className = 'juneau-view-action-trigger';
+		td.appendChild(trigger);
+		tr.appendChild(td);
+		tbody.appendChild(tr);
+		table.appendChild(tbody);
+		document.body.appendChild(table);
+		return { table: table, tr: tr, td: td, trigger: trigger };
+	}
+
+	// A fake fetch Response with a synchronous headers.get and an async text().
+	function resp(o) {
+		return {
+			ok: o.ok,
+			status: o.status,
+			headers: { get: n => (o.headers || {})[n] || null },
+			text: () => Promise.resolve(o.body != null ? o.body : '')
+		};
+	}
+
+	function outcomeOf(dom) {
+		const b = dom.td.querySelector('.juneau-view-action-outcome');
+		return {
+			visible: rendered(b),
+			state: b ? b.getAttribute('data-state') : null,
+			role: b ? b.getAttribute('role') : null,
+			text: b ? b.textContent : null,
+			inflight: dom.tr.hasAttribute('data-juneau-inflight'),
+			triggerDisabled: !!dom.trigger.disabled,
+			pollingFrozen: init.hasInFlightRow(dom.table)
+		};
+	}
+
+	// Drive settleActionResponse against a fabricated response, starting from an in-flight row.
+	async function settle(r, action, ctx) {
+		const dom = makeRow('INC-1');
+		init.setRowInFlight(dom.tr, true);
+		const before = outcomeOf(dom);
+		init.settleActionResponse(r, action || {}, dom.table, dom.tr, ctx || {});
+		await tick(); await tick(); await tick();
+		const after = outcomeOf(dom);
+		after.wasInflightBefore = before.inflight;
+		return after;
+	}
+
+	const V = init.ACTION_RESULT_CONTRACT_VERSION;
+
+	// ---- All four typed outcomes on a 2xx typed result ----
+	out.success = await settle(resp({ ok: true, status: 200,
+		body: JSON.stringify({ contractVersion: V, outcome: 'success', row: { id: 'INC-1', status: 'ack' } }) }),
+		{ id: 'ack', onSuccess: 'mergeRow' },
+		{ mergeRow: function (tr, row) { out.mergedRow = row; } });
+
+	out.failure = await settle(resp({ ok: true, status: 200,
+		body: JSON.stringify({ contractVersion: V, outcome: 'failure', message: 'nope' }) }));
+
+	out.refusal = await settle(resp({ ok: true, status: 200,
+		body: JSON.stringify({ contractVersion: V, outcome: 'refusal', refusalCode: 'write-guard:not-armed' }) }));
+
+	out.unknown = await settle(resp({ ok: true, status: 200,
+		body: JSON.stringify({ contractVersion: V, outcome: 'unknown' }) }));
+
+	// ---- Contract-version mismatch on a 2xx -> visible UNKNOWN (never silently misread) ----
+	out.contractMismatch = await settle(resp({ ok: true, status: 200,
+		body: JSON.stringify({ contractVersion: '999', outcome: 'success', row: {} }) }));
+
+	// ---- Non-2xx transport refusal: read X-Loopback-Boundary + {reason,message} envelope ----
+	out.transport403 = await settle(resp({ ok: false, status: 403,
+		headers: { 'X-Loopback-Boundary': 'CSRF_TOKEN_MISSING' },
+		body: JSON.stringify({ reason: 'CSRF_TOKEN_MISSING', message: 'missing token' }) }));
+
+	out.transport421 = await settle(resp({ ok: false, status: 421, body: '' }));
+
+	// ---- Merge re-render marked the row ----
+	{
+		const dom = makeRow('INC-9');
+		const ctx = { mergeRow: function (tr, row) { ctx._row = row; } };
+		init.settleActionResponse(resp({ ok: true, status: 200,
+			body: JSON.stringify({ contractVersion: V, outcome: 'success', row: { id: 'INC-9', status: 's' } }) }),
+			{ id: 'ack', onSuccess: 'mergeRow' }, dom.table, dom.tr, ctx);
+		await tick(); await tick();
+		out.mergeMarkedRow = dom.tr.getAttribute('data-juneau-row-merged');
+		out.mergeCtxRow = ctx._row;
+	}
+
+	// ---- Modal overlay + typed-field confirmation via textContent (XSS-safe) ----
+	{
+		const dom = makeRow('INC-1');
+		const evil = '<img src=x onerror=alert(1)>';
+		const modal = {
+			title: 'Acknowledge this incident?',
+			fields: [
+				{ label: 'Incident', value: 'INC-1' },
+				{ label: 'Title', value: evil }
+			],
+			idempotencyKey: 'key-abc'
+		};
+		const action = { id: 'ack', label: 'Acknowledge', endpoint: '/x/ack', method: 'POST', present: 'dialog' };
+		const ui = init.showActionDialog(modal, action, dom.table, dom.tr, {});
+		await tick();
+		const backdrop = document.querySelector('.juneau-view-dialog-backdrop');
+		const fieldsEl = backdrop ? backdrop.querySelector('.juneau-view-dialog-fields') : null;
+		const dds = fieldsEl ? Array.from(fieldsEl.querySelectorAll('dd')) : [];
+		out.modal = {
+			backdropVisible: rendered(backdrop),
+			title: backdrop ? (backdrop.querySelector('.juneau-view-dialog-title') || {}).textContent : null,
+			fieldCount: dds.length,
+			evilFieldText: dds.length > 1 ? dds[1].textContent : null,
+			// The XSS proof: the HTML-shaped value must NOT have become an <img> element anywhere in the dialog.
+			injectedImgCount: backdrop ? backdrop.querySelectorAll('img').length : -1
+		};
+		// Confirm-button click submits with the idempotency key + target id (stub fetch to capture the body).
+		const fetchCalls = [];
+		const realFetch = window.fetch;
+		window.fetch = function (url, opts) {
+			fetchCalls.push({ url: url, opts: opts });
+			return Promise.resolve(resp({ ok: true, status: 200,
+				body: JSON.stringify({ contractVersion: V, outcome: 'success' }) }));
+		};
+		dom.table.setAttribute('data-juneau-csrf', 'tok-xyz');
+		ui.confirmBtn.click();
+		await tick(); await tick();
+		window.fetch = realFetch;
+		out.modal.submitIssued = fetchCalls.length > 0;
+		if (fetchCalls.length > 0) {
+			out.modal.submitBody = fetchCalls[0].opts.body;
+			out.modal.backdropClosedAfterConfirm = !document.querySelector('.juneau-view-dialog-backdrop');
+		}
+	}
+
+	return out;
+};
+
+(async () => {
+	const [fixture] = process.argv.slice(2);
+	if (!fixture) {
+		process.stderr.write('usage: node modal-result.cjs <page.html>\n');
+		process.exit(2);
+	}
+	if (!fs.existsSync(fixture))
+		throw new Error('fixture not found: ' + fixture);
+
+	const url = 'file://' + path.resolve(fixture);
+	const browser = await chromium.launch();
+	try {
+		const page = await browser.newPage();
+		const failures = [];
+		page.on('pageerror', e => failures.push(String(e)));
+		page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+		await page.goto(url);
+		await page.evaluate(() => new Promise(requestAnimationFrame));
+		const report = await page.evaluate(PROBE);
+		report.jsFailures = failures.slice();
+		process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+	} finally {
+		await browser.close();
+	}
+})().catch(e => {
+	process.stderr.write(String((e && e.stack) || e) + '\n');
+	process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-actions.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/row-actions.cjs
new file mode 100644
index 0000000000..16e424db35
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-actions.cjs
@@ -0,0 +1,153 @@
+/*
+ * 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.
+ */
+
+/*
+ * row-actions.cjs - real-browser prober for the juneau-views.js row-action + fail-closed CSRF contract (TODO-415).
+ *
+ * Never runs in a default build.  It is driven by RowActionCsrf_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ *   Usage:  node row-actions.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, exercises the runtime's row-action pipeline and its
+ * fail-closed CSRF submit via the real DOM and a stubbed window.fetch.  Prints ONE JSON object to stdout.
+ *
+ * DIVISION OF LABOUR (mirrors panel-visibility.cjs): this script only OBSERVES; every assertion lives in the Java
+ * test.  It reports facts (was fetch issued, with what method/headers/body; did a visible refusal banner appear)
+ * and lets JUnit decide pass/fail, so the expectations live next to the runtime they constrain.
+ *
+ * WHY A REAL BROWSER: the fail-closed refusal is a user-visible outcome (a rendered banner and a NOT-sent request);
+ * measuring "a request was suppressed and a banner became visible" is exactly what a browser, and not a substring
+ * assertion, can show.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/*
+ * Runs inside the page.  window.JuneauViews.init is populated by the real juneau-views.js the fixture loaded.
+ * Drives the pure request builder AND the DOM menu/submit path against a stubbed fetch, returning a plain report.
+ */
+const PROBE = function () {
+	const NS = window.JuneauViews;
+	const init = NS && NS.init;
+	const out = { hasInit: !!init };
+	if (!init) return out;
+
+	out.contractVersion = NS.CONTRACT_VERSION;
+	out.defaultCsrfHeader = init.DEFAULT_CSRF_HEADER;
+
+	// Pure request builder: whitespace, absent, safe-method all refuse; a valid token yields a JSON request.
+	const action = { id: 'ack', label: 'Acknowledge', endpoint: '/x/ack', method: 'POST' };
+	out.reqBlankWhitespace = init.buildActionRequest(action, '   ', null);
+	out.reqAbsent = init.buildActionRequest(action, null, null);
+	out.reqSafeMethod = init.buildActionRequest({ id: 'g', endpoint: '/x', method: 'GET' }, 'tok', null);
+	out.reqValid = init.buildActionRequest(action, 'tok-123', null);
+
+	// Behavioural DOM path: build a table+row carrying a token attribute, open the menu, click the item, and
+	// record whether fetch was issued and whether a visible refusal banner rendered.
+	const fetchCalls = [];
+	const realFetch = window.fetch;
+	window.fetch = function (url, opts) {
+		fetchCalls.push({ url: url, opts: opts });
+		return Promise.resolve({ ok: true });   // 415 does not act on the body; a resolved ok is enough here
+	};
+
+	function makeTable(tokenValue) {
+		const table = document.createElement('table');
+		table.setAttribute('data-juneau-view', 'v');
+		if (tokenValue != null) table.setAttribute('data-juneau-csrf', tokenValue);
+		const tbody = document.createElement('tbody');
+		const tr = document.createElement('tr');
+		const td = document.createElement('td');
+		td.className = 'juneau-view-actions-cell';
+		tr.appendChild(td);
+		tbody.appendChild(tr);
+		table.appendChild(tbody);
+		document.body.appendChild(table);
+		return { table: table, tr: tr, td: td };
+	}
+
+	function rendered(el) {
+		if (!el) return false;
+		const r = el.getBoundingClientRect();
+		return r.width > 0 && r.height > 0;
+	}
+
+	// Case 1: BLANK (whitespace) token -> visible refusal, NO fetch.
+	const before1 = fetchCalls.length;
+	const dom1 = makeTable('   ');
+	const menu1 = init.buildRowActionMenu({ rowActions: [action] }, dom1.table, dom1.tr, {});
+	dom1.td.appendChild(menu1);
+	menu1.querySelector('.juneau-view-action-item').click();
+	out.blankTokenFetchIssued = fetchCalls.length > before1;
+	const banner1 = dom1.td.querySelector('.juneau-view-action-refusal');
+	out.blankTokenRefusalVisible = rendered(banner1);
+	out.blankTokenRefusalText = banner1 ? banner1.textContent : null;
+
+	// Case 2: VALID token -> fetch issued with POST + JSON content type + the CSRF header, NO refusal banner.
+	const before2 = fetchCalls.length;
+	const dom2 = makeTable('tok-123');
+	const menu2 = init.buildRowActionMenu({ rowActions: [action] }, dom2.table, dom2.tr, {});
+	dom2.td.appendChild(menu2);
+	menu2.querySelector('.juneau-view-action-item').click();
+	const call = fetchCalls[fetchCalls.length - 1];
+	out.validTokenFetchIssued = fetchCalls.length > before2;
+	if (out.validTokenFetchIssued) {
+		out.validTokenUrl = call.url;
+		out.validTokenMethod = call.opts.method;
+		out.validTokenContentType = call.opts.headers['Content-Type'];
+		out.validTokenCsrfHeader = call.opts.headers[init.DEFAULT_CSRF_HEADER];
+		out.validTokenBody = call.opts.body;
+	}
+	out.validTokenRefusalVisible = rendered(dom2.td.querySelector('.juneau-view-action-refusal'));
+
+	window.fetch = realFetch;
+	return out;
+};
+
+(async () => {
+	const [fixture] = process.argv.slice(2);
+	if (!fixture) {
+		process.stderr.write('usage: node row-actions.cjs <page.html>\n');
+		process.exit(2);
+	}
+	if (!fs.existsSync(fixture))
+		throw new Error('fixture not found: ' + fixture);
+
+	const url = 'file://' + path.resolve(fixture);
+	const browser = await chromium.launch();
+	try {
+		const page = await browser.newPage();
+		const failures = [];
+		page.on('pageerror', e => failures.push(String(e)));
+		page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+		await page.goto(url);
+		await page.evaluate(() => new Promise(requestAnimationFrame));
+		const report = await page.evaluate(PROBE);
+		report.jsFailures = failures.slice();
+		process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+	} finally {
+		await browser.close();
+	}
+})().catch(e => {
+	process.stderr.write(String((e && e.stack) || e) + '\n');
+	process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-selection-bulk.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/row-selection-bulk.cjs
new file mode 100644
index 0000000000..27a93c3523
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-selection-bulk.cjs
@@ -0,0 +1,272 @@
+/*
+ * 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.
+ */
+
+/*
+ * row-selection-bulk.cjs - real-browser prober for the juneau-views.js row-selection + bulk-mutation contract
+ * (TODO-428).
+ *
+ * Never runs in a default build.  It is driven by RowSelectionBulk_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ *   Usage:  node row-selection-bulk.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, drives the exposed selection/bulk helpers directly (no
+ * jQuery/DataTables is bundled by this module, so - mirroring row-actions.cjs and modal-result.cjs - this prober
+ * never boots a real DataTable; it exercises initSelection/executeBulkAction/etc. against a fabricated DOM +
+ * fetch, which is exactly the same binding surface initTable itself calls into).  Prints ONE JSON object to
+ * stdout.
+ *
+ * DIVISION OF LABOUR (mirrors row-actions.cjs / modal-result.cjs): this script only OBSERVES; every assertion
+ * lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/* Runs inside the page.  Async: the bulk-settle path reads response bodies via promises. */
+const PROBE = async function () {
+	const NS = window.JuneauViews;
+	const init = NS && NS.init;
+	const out = { hasInit: !!init };
+	if (!init) return out;
+
+	out.bulkContractVersion = NS.BULK_CONTRACT_VERSION;
+
+	const tick = () => new Promise(r => setTimeout(r, 0));
+
+	// Builds a table with one row per id in `rowIds`, each carrying the stable ROW_ID_ATTR (never a DOM index),
+	// a selection-cell checkbox, and an actions-cell + trigger (for the bulk-settle path to render into).
+	function makeTable(rowIds, opts) {
+		opts = opts || {};
+		const table = document.createElement('table');
+		table.setAttribute('data-juneau-view', 'v');
+		if (opts.select) table.setAttribute(init.SELECT_ATTR, '1');
+		if (opts.select && opts.selectAll !== false) table.setAttribute(init.SELECT_ALL_ATTR, '1');
+		if (opts.rowIdField) table.setAttribute(init.ROW_ID_FIELD_ATTR, opts.rowIdField);
+		if (opts.bulk) table.setAttribute(init.BULK_ATTR, '1');
+		if (opts.csrf) table.setAttribute('data-juneau-csrf', opts.csrf);
+
+		const thead = document.createElement('thead');
+		const headRow = document.createElement('tr');
+		const selectTh = document.createElement('th');
+		selectTh.className = 'juneau-view-select-th';
+		headRow.appendChild(selectTh);
+		thead.appendChild(headRow);
+		table.appendChild(thead);
+
+		const tbody = document.createElement('tbody');
+		const trs = {};
+		rowIds.forEach(function (id) {
+			const tr = document.createElement('tr');
+			tr.setAttribute(init.ROW_ID_ATTR, id);
+			const selectTd = document.createElement('td');
+			selectTd.innerHTML = init.selectionCellMarkup(false);
+			tr.appendChild(selectTd);
+			const actionsTd = document.createElement('td');
+			actionsTd.className = 'juneau-view-actions-cell';
+			const trigger = document.createElement('button');
+			trigger.className = 'juneau-view-action-trigger';
+			actionsTd.appendChild(trigger);
+			tr.appendChild(actionsTd);
+			tbody.appendChild(tr);
+			trs[id] = tr;
+		});
+		table.appendChild(tbody);
+		document.body.appendChild(table);
+		return { table: table, trs: trs, selectTh: selectTh };
+	}
+
+	// A minimal fake DataTables instance exposing only `.on(event, cb)` - just enough for initSelection's
+	// draw.dt-driven off-screen-id-drop wiring; `.fire` lets the probe simulate a poll/sort/page tick.
+	function fakeDt() {
+		const handlers = {};
+		return {
+			on: function (evt, cb) { (handlers[evt] = handlers[evt] || []).push(cb); },
+			fire: function (evt) { (handlers[evt] || []).forEach(function (cb) { cb(); }); }
+		};
+	}
+
+	function dispatchChange(el) {
+		el.dispatchEvent(new Event('change', { bubbles: true }));
+	}
+
+	// ---- a) per-row selection + select-all (MED-11: identity is the stable id, never a DOM index) ----
+	{
+		const dom = makeTable(['1', '2', '3'], { select: true, rowIdField: 'id' });
+		const selectionState = { selected: new Set(), rowIdField: 'id' };
+		init.initSelection(dom.table, fakeDt(), selectionState, {});
+
+		function check(id, checked) {
+			const cb = dom.trs[id].querySelector('.juneau-view-select-checkbox');
+			cb.checked = checked;
+			dispatchChange(cb);
+		}
+
+		check('1', true);
+		check('2', true);
+		out.afterTwoChecked = Array.from(selectionState.selected).sort();
+
+		const allCb = dom.selectTh.querySelector('.juneau-view-select-all-checkbox');
+		out.hasSelectAllCheckbox = !!allCb;
+		allCb.checked = true;
+		dispatchChange(allCb);
+		out.afterSelectAll = Array.from(selectionState.selected).sort();
+
+		allCb.checked = false;
+		dispatchChange(allCb);
+		out.afterDeselectAll = Array.from(selectionState.selected).sort();
+	}
+
+	// ---- b) off-screen-id-drop persistence rule (MED-11/Q2): a poll/sort/page draw prunes ids no longer present ----
+	{
+		const dom = makeTable(['1', '2', '3'], { select: true, rowIdField: 'id' });
+		const selectionState = { selected: new Set(['1', '2', '3']), rowIdField: 'id' };
+		const dt = fakeDt();
+		init.initSelection(dom.table, dt, selectionState, {});
+
+		dom.trs['3'].parentNode.removeChild(dom.trs['3']);   // row '3' left the current draw
+		dt.fire('draw.dt');
+
+		out.selectedAfterOffScreenDraw = Array.from(selectionState.selected).sort();
+	}
+
+	// ---- c) two INDEPENDENT opt-ins (HIGH-5): a selection-only (export) table never carries the bulk marker ----
+	{
+		const selectOnly = makeTable(['1'], { select: true, rowIdField: 'id', bulk: false });
+		const withBulk = makeTable(['1'], { select: true, rowIdField: 'id', bulk: true });
+		out.selectOnlyHasSelection = init.hasSelection(selectOnly.table);
+		out.selectOnlyHasBulk = init.hasBulk(selectOnly.table);
+		out.withBulkHasSelection = init.hasSelection(withBulk.table);
+		out.withBulkHasBulk = init.hasBulk(withBulk.table);
+	}
+
+	// ---- d) bulk = N INDEPENDENT per-row writes; per-target typed result; per-row in-flight marker ----
+	{
+		const dom = makeTable(['1', '2', '3'], { select: true, rowIdField: 'id', bulk: true, csrf: 'tok-xyz' });
+		// '9' has no corresponding row - simulates an id that went off-screen between the click and this run;
+		// executeBulkAction must silently skip it rather than target a row that no longer exists.
+		const selectionState = { selected: new Set(['1', '2', '3', '9']), rowIdField: 'id' };
+
+		const fetchCalls = [];
+		const inflightAtFetchTime = {};
+		const realFetch = window.fetch;
+		window.fetch = function (url, opts) {
+			const body = JSON.parse(opts.body);
+			fetchCalls.push({ url: url, body: body });
+			inflightAtFetchTime[body.targetId] = dom.trs[body.targetId].hasAttribute('data-juneau-inflight');
+			let respBody;
+			if (body.targetId === '1') respBody = { contractVersion: '1', outcome: 'success' };
+			else if (body.targetId === '2') respBody = { contractVersion: '1', outcome: 'failure', message: 'nope' };
+			else respBody = { contractVersion: '1', outcome: 'refusal', refusalCode: 'write-guard:not-armed' };
+			return Promise.resolve({
+				ok: true, status: 200,
+				headers: { get: function () { return null; } },
+				text: function () { return Promise.resolve(JSON.stringify(respBody)); }
+			});
+		};
+
+		const action = { id: 'ack', label: 'Acknowledge', endpoint: '/x/ack', method: 'POST' };
+		init.executeBulkAction(action, dom.table, {}, selectionState);
+		await tick(); await tick(); await tick(); await tick(); await tick();
+		window.fetch = realFetch;
+
+		function outcomeOf(id) {
+			const cell = dom.trs[id].querySelector('.juneau-view-action-outcome');
+			return cell ? { state: cell.getAttribute('data-state'), text: cell.textContent } : null;
+		}
+
+		out.bulk = {
+			fetchCount: fetchCalls.length,
+			targetIds: fetchCalls.map(function (c) { return c.body.targetId; }).sort(),
+			actionIds: fetchCalls.map(function (c) { return c.body.action; }),
+			inflightAtFetchTime: inflightAtFetchTime,
+			inflightAfter: {
+				'1': dom.trs['1'].hasAttribute('data-juneau-inflight'),
+				'2': dom.trs['2'].hasAttribute('data-juneau-inflight'),
+				'3': dom.trs['3'].hasAttribute('data-juneau-inflight')
+			},
+			outcome1: outcomeOf('1'),
+			outcome2: outcomeOf('2'),
+			outcome3: outcomeOf('3')
+		};
+	}
+
+	// ---- e) the bulk toolbar reflects the live selection count and gates its buttons on it ----
+	{
+		const dom = makeTable(['1', '2'], { select: true, rowIdField: 'id', bulk: true });
+		const selectionState = { selected: new Set(), rowIdField: 'id' };
+		const bulkDef = { contractVersion: NS.BULK_CONTRACT_VERSION, actions: [{ id: 'ack', label: 'Acknowledge' }] };
+		const toolbar = init.buildBulkToolbar(bulkDef, dom.table, {}, selectionState);
+		const btn = toolbar.el.querySelector('.juneau-view-bulk-action-btn');
+		out.toolbar = { disabledInitially: btn.disabled };
+		toolbar.refresh(2);
+		out.toolbar.disabledWithSelection = btn.disabled;
+		out.toolbar.countTextWithSelection = toolbar.el.querySelector('.juneau-view-bulk-count').textContent;
+		toolbar.refresh(0);
+		out.toolbar.disabledAfterCleared = btn.disabled;
+	}
+
+	// ---- f) the bulk sidecar is read + contract-checked at runtime (R2: independent of VIEW_META) ----
+	{
+		const id = 'v-sidecar';
+		const sidecar = document.createElement('script');
+		sidecar.type = 'application/json';
+		sidecar.id = init.BULK_SIDECAR_ID_PREFIX + id;
+		sidecar.textContent = JSON.stringify({ contractVersion: NS.BULK_CONTRACT_VERSION, actions: [{ id: 'a' }] });
+		document.body.appendChild(sidecar);
+		const read = init.readBulkDef(id);
+		out.bulkSidecar = {
+			contractVersion: read ? read.contractVersion : null,
+			actionCount: read && read.actions ? read.actions.length : -1,
+			missingSidecarReturnsNull: init.readBulkDef('does-not-exist') === null
+		};
+	}
+
+	return out;
+};
+
+(async () => {
+	const [fixture] = process.argv.slice(2);
+	if (!fixture) {
+		process.stderr.write('usage: node row-selection-bulk.cjs <page.html>\n');
+		process.exit(2);
+	}
+	if (!fs.existsSync(fixture))
+		throw new Error('fixture not found: ' + fixture);
+
+	const url = 'file://' + path.resolve(fixture);
+	const browser = await chromium.launch();
+	try {
+		const page = await browser.newPage();
+		const failures = [];
+		page.on('pageerror', e => failures.push(String(e)));
+		page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+		await page.goto(url);
+		await page.evaluate(() => new Promise(requestAnimationFrame));
+		const report = await page.evaluate(PROBE);
+		report.jsFailures = failures.slice();
+		process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+	} finally {
+		await browser.close();
+	}
+})().catch(e => {
+	process.stderr.write(String((e && e.stack) || e) + '\n');
+	process.exit(1);
+});