diff --git a/scripts/opnsense-set-iface-v4.php b/scripts/opnsense-set-iface-v4.php
new file mode 100644
index 0000000..dfb1da4
--- /dev/null
+++ b/scripts/opnsense-set-iface-v4.php
@@ -0,0 +1,210 @@
+
[]
+ *
+ * e.g. opnsense-set-iface-v4.php --commit lan 10.12.4.1 22
+ * opnsense-set-iface-v4.php --commit wan 172.30.2.2 24 172.30.2.1
+ *
+ * Sets a STATIC IPv4 address on an OPNsense base interface -- and, when a gateway is
+ * given, the upstream default gateway for it -- using OPNSENSE'S OWN CONFIG OBJECT,
+ * the same object the GUI's Interfaces > [WAN] page mutates when you press Save.
+ *
+ * This is the IPv4 sibling of opnsense-set-iface-v6.php. Same design, same guards;
+ * only the leaf fields differ (ipaddr/subnet rather than ipaddrv6/subnetv6), plus the
+ * gateway half, which has no v6 counterpart here.
+ *
+ * WHY THIS EXISTS (D-113 AMENDMENT, 2026-07-14, RE-MEASURED ON 26.7 2026-07-20):
+ * D-113(a2)'s text claims "DHCP, firewall, interfaces: the REST API". The
+ * `interfaces` half is FALSE. Base-interface addressing is served by the LEGACY page
+ * /interfaces.php?if=; only sub-trees (Devices, Neighbors, VIPs, ...) were
+ * migrated to MVC. MEASURED on OPNsense 26.7 on the vr1-dc0 edge: the global
+ * `interfaces/settings/get` endpoint exists but carries only offload flags, while
+ * `interfaces/overview/list` and `interfaces//get` both return 404 "Endpoint not
+ * found". So the amendment now holds on TWO majors (26.1 and 26.7), measured -- not
+ * inferred, and not a 26.1-only quirk.
+ *
+ * WHY THIS IS NOT THE FORBIDDEN config.xml PUSH. What D-113 banned is a
+ * HAND-AUTHORED / RENDERED config.xml pushed wholesale over the live one -- the
+ * clobber path that would wipe API-managed Kea DHCP and the firewall rules. This does
+ * the opposite: it LOADS the edge's live config through the vendor's own Config
+ * singleton, mutates a few leaf fields, and lets the vendor's own code serialize it
+ * back. It is the code path interfaces.php runs on Save.
+ *
+ * STRUCTURAL GUARDS. The interface COUNT must not change, and the gateway count may
+ * only grow by the one item we deliberately add. Both are asserted before saving and
+ * re-asserted on a fresh read-back: a silent structural drop on a LIVE ROUTER must
+ * fail loud, not be discovered later by a ping that stopped.
+ *
+ * DRY BY DEFAULT. Without --commit it prints the current -> desired delta and writes
+ * NOTHING. (D-117's lesson: a write-by-default config tool is how an off-by-one lands
+ * in production unannounced.)
+ *
+ * This script does NOT apply the change to the running kernel -- saving config and
+ * reconfiguring the interface are separate steps, deliberately. The caller
+ * (scripts/opnsense-set-interface-v4.sh) runs `configctl interface reconfigure`.
+ *
+ * Run from /usr/local/opnsense/mvc (load_phalcon.php is resolved relative to it).
+ *
+ * Exit: 0 ok (or dry run) | 1 usage/validation error | 2 save failed.
+ */
+require_once('script/load_phalcon.php');
+
+use OPNsense\Core\Config;
+
+$argvv = $argv;
+array_shift($argvv);
+$commit = false;
+if (isset($argvv[0]) && $argvv[0] === '--commit') {
+ $commit = true;
+ array_shift($argvv);
+}
+
+if (count($argvv) < 3) {
+ fwrite(STDERR, "usage: opnsense-set-iface-v4.php [--commit] []\n");
+ fwrite(STDERR, " e.g. opnsense-set-iface-v4.php --commit wan 172.30.2.2 24 172.30.2.1\n");
+ exit(1);
+}
+$ifname = $argvv[0];
+$address = $argvv[1];
+$prefixlen = $argvv[2];
+$gwaddr = isset($argvv[3]) ? $argvv[3] : '';
+
+/* ---- validate BEFORE touching the config. Never write an unvalidated value. ---- */
+if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
+ fwrite(STDERR, "FAIL: '{$address}' is not a valid IPv4 address\n");
+ exit(1);
+}
+if (!ctype_digit((string)$prefixlen) || (int)$prefixlen < 1 || (int)$prefixlen > 32) {
+ fwrite(STDERR, "FAIL: '{$prefixlen}' is not a valid IPv4 prefix length (1-32)\n");
+ exit(1);
+}
+$prefixlen = (string)(int)$prefixlen;
+if ($gwaddr !== '' && filter_var($gwaddr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
+ fwrite(STDERR, "FAIL: '{$gwaddr}' is not a valid IPv4 gateway address\n");
+ exit(1);
+}
+
+Config::getInstance()->lock();
+$cfg = Config::getInstance()->object();
+
+if (!isset($cfg->interfaces)) {
+ fwrite(STDERR, "FAIL: config has no node -- refusing to guess\n");
+ exit(1);
+}
+if (!isset($cfg->interfaces->$ifname)) {
+ $known = array();
+ foreach ($cfg->interfaces->children() as $k => $v) {
+ $known[] = $k;
+ }
+ fwrite(STDERR, "FAIL: no such interface '{$ifname}'. Known: " . implode(', ', $known) . "\n");
+ exit(1);
+}
+
+$iface_count_before = count($cfg->interfaces->children());
+$gw_count_before = isset($cfg->gateways) ? count($cfg->gateways->children()) : 0;
+
+$node = $cfg->interfaces->$ifname;
+$cur_ip = isset($node->ipaddr) ? (string)$node->ipaddr : '';
+$cur_len = isset($node->subnet) ? (string)$node->subnet : '';
+$cur_gw = isset($node->gateway) ? (string)$node->gateway : '';
+$gwname = strtoupper($ifname) . '_GW';
+
+printf("interface : %s (%s)\n", $ifname, isset($node->descr) ? (string)$node->descr : '');
+printf(" ipaddr : %s -> %s\n", $cur_ip === '' ? '(none)' : $cur_ip, $address);
+printf(" subnet : %s -> %s\n", $cur_len === '' ? '(none)' : $cur_len, $prefixlen);
+if ($gwaddr !== '') {
+ printf(" gateway : %s -> %s (%s = %s)\n", $cur_gw === '' ? '(none)' : $cur_gw, $gwname, $gwname, $gwaddr);
+} else {
+ printf(" gateway : %s [UNTOUCHED -- no gateway argument]\n", $cur_gw === '' ? '(none)' : $cur_gw);
+}
+printf(" ipaddrv6 (v6) : %s [UNTOUCHED]\n", isset($node->ipaddrv6) ? (string)$node->ipaddrv6 : '(none)');
+printf(" interfaces in config: %d\n", $iface_count_before);
+printf(" gateway items : %d\n", $gw_count_before);
+
+$addr_same = ($cur_ip === $address && $cur_len === $prefixlen);
+$gw_same = ($gwaddr === '' || $cur_gw === $gwname);
+if ($addr_same && $gw_same) {
+ print("\nNO CHANGE -- already set to the desired value. Nothing to do.\n");
+ exit(0);
+}
+
+if (!$commit) {
+ print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.\n");
+ exit(0);
+}
+
+$node->ipaddr = $address;
+$node->subnet = $prefixlen;
+
+/* ---- the gateway half. Only when asked, and only ever ONE added item. ---- */
+$gw_added = 0;
+if ($gwaddr !== '') {
+ if (!isset($cfg->gateways)) {
+ $cfg->addChild('gateways');
+ }
+ $found = null;
+ foreach ($cfg->gateways->gateway_item as $item) {
+ if ((string)$item->name === $gwname) {
+ $found = $item;
+ break;
+ }
+ }
+ if ($found === null) {
+ $found = $cfg->gateways->addChild('gateway_item');
+ $found->addChild('name', $gwname);
+ $found->addChild('interface', $ifname);
+ $found->addChild('gateway', $gwaddr);
+ $found->addChild('ipprotocol', 'inet');
+ $found->addChild('defaultgw', '1');
+ $found->addChild('descr', 'set by opnsense-set-iface-v4.php');
+ $gw_added = 1;
+ } else {
+ $found->gateway = $gwaddr;
+ $found->interface = $ifname;
+ $found->defaultgw = '1';
+ }
+ $node->gateway = $gwname;
+}
+
+$iface_count_after = count($cfg->interfaces->children());
+if ($iface_count_after !== $iface_count_before) {
+ fwrite(STDERR, "FAIL: interface count changed {$iface_count_before} -> {$iface_count_after}. NOT SAVING.\n");
+ exit(2);
+}
+$gw_count_after = isset($cfg->gateways) ? count($cfg->gateways->children()) : 0;
+if ($gw_count_after !== $gw_count_before + $gw_added) {
+ fwrite(STDERR, "FAIL: gateway count changed {$gw_count_before} -> {$gw_count_after} (expected +{$gw_added}). NOT SAVING.\n");
+ exit(2);
+}
+
+Config::getInstance()->save();
+
+/* Read back from a FRESH load -- the service's own verdict, not our in-memory copy. */
+Config::getInstance()->forceReload();
+$verify = Config::getInstance()->object();
+$got_ip = (string)$verify->interfaces->$ifname->ipaddr;
+$got_len = (string)$verify->interfaces->$ifname->subnet;
+$got_cnt = count($verify->interfaces->children());
+
+if ($got_ip !== $address || $got_len !== $prefixlen) {
+ fwrite(STDERR, "FAIL: read-back mismatch -- got {$got_ip}/{$got_len}, wanted {$address}/{$prefixlen}\n");
+ exit(2);
+}
+if ($got_cnt !== $iface_count_before) {
+ fwrite(STDERR, "FAIL: read-back interface count {$got_cnt} != {$iface_count_before} -- CONFIG DAMAGED\n");
+ exit(2);
+}
+if ($gwaddr !== '') {
+ $got_gw = (string)$verify->interfaces->$ifname->gateway;
+ if ($got_gw !== $gwname) {
+ fwrite(STDERR, "FAIL: read-back gateway mismatch -- got '{$got_gw}', wanted '{$gwname}'\n");
+ exit(2);
+ }
+}
+
+printf("\nOK: saved and read back -- %s = %s/%s (%d interfaces intact)\n", $ifname, $got_ip, $got_len, $got_cnt);
+if ($gwaddr !== '') {
+ printf("OK: gateway %s = %s is the default route for %s\n", $gwname, $gwaddr, $ifname);
+}
+print("NOTE: config is saved but NOT yet applied to the running kernel.\n");
+print(" The caller must run: configctl interface reconfigure {$ifname}\n");
diff --git a/scripts/opnsense-set-interface-v4.sh b/scripts/opnsense-set-interface-v4.sh
new file mode 100755
index 0000000..c51d603
--- /dev/null
+++ b/scripts/opnsense-set-interface-v4.sh
@@ -0,0 +1,156 @@
+#!/usr/bin/env bash
+# scripts/opnsense-set-interface-v4.sh [--commit] []
+#
+# e.g. OPNSENSE_SSH_KEY=~/vr1-dc0-creds/vr1-dc0-edge_ed25519 \
+# bash scripts/opnsense-set-interface-v4.sh --commit 192.168.1.1 lan 10.12.4.1 22
+# bash scripts/opnsense-set-interface-v4.sh --commit 192.168.1.1 wan 172.30.2.2 24 172.30.2.1
+#
+# Sets a STATIC IPv4 address (and optionally the upstream default gateway) on an
+# OPNsense base interface and applies it, then proves the address is actually on the
+# wire. IPv4 sibling of opnsense-set-interface-v6.sh -- same design and same guards.
+#
+# WHY IT IS NOT THE REST API (D-113 AMENDMENT, 2026-07-14; RE-MEASURED ON 26.7,
+# 2026-07-20). Base-interface addressing is served by the LEGACY page
+# /interfaces.php?if=; only sub-trees were migrated to MVC. Measured on the live
+# vr1-dc0 edge running 26.7: `interfaces/settings/get` returns only offload flags,
+# while `interfaces/overview/list` and `interfaces//get` both 404. The amendment
+# therefore holds on TWO majors, measured -- it is not a 26.1-only quirk.
+#
+# WHY IT IS NOT THE FORBIDDEN config.xml PUSH. It never authors a config. It ships
+# scripts/opnsense-set-iface-v4.php, which loads the edge's LIVE config through
+# OPNsense's own Config singleton, mutates leaf fields, asserts the interface count is
+# unchanged (and that the gateway count grew by at most the one item it adds), and
+# lets the vendor's own code serialize it -- the identical code path interfaces.php
+# runs on Save. Nothing is replaced wholesale, so API-managed Kea DHCP and firewall
+# rules cannot be clobbered.
+#
+# REUSABLE BY EVERY DC EDGE. dc1 and the Roosevelt metal edges take this verbatim;
+# only the host/address/gateway change.
+#
+# ORDERING WARNING (learned on the vr1-dc0 build): re-addressing the LAN moves the
+# very interface you are reaching the edge THROUGH. Do the WAN first, verify, then the
+# LAN last -- and reconnect at the new LAN address afterwards.
+#
+# DRY BY DEFAULT -- without --commit, nothing is written and nothing is applied.
+#
+# root's shell on the edge is TCSH, not sh. A $(...) inside a quoted remote command
+# dies with "Illegal variable name", and it dies QUIETLY. Every remote command here is
+# therefore fed to `sh -s` over stdin, never interpolated into a tcsh -c string.
+#
+# Exit: 0 ok (or dry run) | 1 usage/precondition | 2 remote set/apply/verify failed.
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SETTER="$HERE/opnsense-set-iface-v4.php"
+
+COMMIT=0
+if [ "${1:-}" = "--commit" ]; then COMMIT=1; shift; fi
+
+EDGE="${1:-}"
+IFNAME="${2:-}"
+ADDR="${3:-}"
+PLEN="${4:-}"
+GW="${5:-}"
+
+usage() {
+ echo "usage: opnsense-set-interface-v4.sh [--commit] []" >&2
+ echo " e.g. opnsense-set-interface-v4.sh --commit 192.168.1.1 wan 172.30.2.2 24 172.30.2.1" >&2
+ echo " env: OPNSENSE_SSH_KEY (required)" >&2
+ echo " default: DRY RUN -- prints the delta, writes nothing." >&2
+ exit 1
+}
+
+[ -n "$EDGE" ] && [ -n "$IFNAME" ] && [ -n "$ADDR" ] && [ -n "$PLEN" ] || usage
+[ -f "$SETTER" ] || { echo "FAIL: setter not found: $SETTER" >&2; exit 1; }
+
+# Validate locally too. The PHP validates as well -- but a bad value should never even
+# reach the live router.
+is_v4() {
+ printf '%s' "$1" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$' || return 1
+ o1="${1%%.*}"; rest="${1#*.}"; o2="${rest%%.*}"; rest="${rest#*.}"; o3="${rest%%.*}"; o4="${rest#*.}"
+ for o in "$o1" "$o2" "$o3" "$o4"; do [ "$o" -le 255 ] || return 1; done
+ return 0
+}
+is_v4 "$ADDR" || { echo "FAIL: '$ADDR' is not an IPv4 address" >&2; exit 1; }
+case "$PLEN" in ''|*[!0-9]*) echo "FAIL: '$PLEN' is not a prefix length" >&2; exit 1 ;; esac
+[ "$PLEN" -ge 1 ] && [ "$PLEN" -le 32 ] || { echo "FAIL: prefix length '$PLEN' out of range 1-32" >&2; exit 1; }
+if [ -n "$GW" ]; then
+ is_v4 "$GW" || { echo "FAIL: '$GW' is not an IPv4 gateway address" >&2; exit 1; }
+fi
+
+[ -n "${OPNSENSE_SSH_KEY:-}" ] || { echo "FAIL: \$OPNSENSE_SSH_KEY not set" >&2; exit 1; }
+
+SSH=(ssh -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15)
+SCP=(scp -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15 -q)
+
+REMOTE_PHP="/tmp/opnsense-set-iface-v4.$$.php"
+cleanup() { "${SSH[@]}" "root@${EDGE}" "rm -f '$REMOTE_PHP'" >/dev/null 2>&1 || true; }
+trap cleanup EXIT
+
+"${SCP[@]}" "$SETTER" "root@${EDGE}:${REMOTE_PHP}" \
+ || { echo "FAIL: could not ship the setter to $EDGE" >&2; exit 2; }
+
+# The kernel device behind a base interface is NOT guessable -- ask the edge's own
+# config which device this interface is assigned to (measured, never inferred).
+DEV="$("${SSH[@]}" "root@${EDGE}" sh -s <.*|&|p" /conf/config.xml >/dev/null 2>&1
+php -r '\$c=simplexml_load_file("/conf/config.xml"); print (string)\$c->interfaces->${IFNAME}->if;' 2>/dev/null
+EOF
+)"
+[ -n "$DEV" ] || { echo "FAIL: could not determine the device for interface '$IFNAME'" >&2; exit 2; }
+echo "interface '$IFNAME' is device '$DEV' (measured from the edge's own config)"
+
+echo "=== BEFORE (the edge's own view) ==="
+"${SSH[@]}" "root@${EDGE}" sh -s <&2; exit 2; }
+ifconfig $DEV inet 2>/dev/null | grep 'inet ' || echo " (no inet on the interface)"
+netstat -rn -f inet 2>/dev/null | grep '^default' || echo " (no default route)"
+EOF
+
+# load_phalcon.php is resolved RELATIVE to /usr/local/opnsense/mvc -- must cd there.
+PHPARGS=""
+[ "$COMMIT" = "1" ] && PHPARGS="--commit"
+
+echo
+echo "=== SET (via OPNsense's own config model) ==="
+"${SSH[@]}" "root@${EDGE}" sh -s <&2; exit 2; }
+cd /usr/local/opnsense/mvc && php '$REMOTE_PHP' $PHPARGS '$IFNAME' '$ADDR' '$PLEN' '$GW'
+EOF
+
+if [ "$COMMIT" != "1" ]; then
+ echo
+ echo "DRY RUN -- nothing written, nothing applied. Re-run with --commit."
+ exit 0
+fi
+
+echo
+echo "=== APPLY (configctl -- saving config does NOT reconfigure the kernel) ==="
+# Re-addressing the interface you are connected THROUGH drops this SSH session by
+# design; the reconfigure still completes on the edge. Do not treat that as a failure.
+"${SSH[@]}" "root@${EDGE}" sh -s </dev/null || true
+ifconfig $DEV inet 2>/dev/null | grep 'inet ' || true
+EOF
+)"
+printf '%s\n' "$OUT"
+
+# The address must actually be ON THE INTERFACE. A {"result":"saved"}-style
+# self-report is not evidence; the kernel is.
+if printf '%s' "$OUT" | grep -q "$ADDR"; then
+ echo
+ echo "OK: $ADDR/$PLEN is live on $IFNAME ($DEV)."
+ exit 0
+fi
+
+echo
+echo "FAIL: $ADDR is NOT on $IFNAME ($DEV) after reconfigure." >&2
+echo " If this re-addressed the interface you arrived on, re-verify from a host" >&2
+echo " on the NEW subnet: ssh root@$ADDR ifconfig $DEV inet" >&2
+exit 2
diff --git a/tests/opnsense-set-interface-v4/run-tests.sh b/tests/opnsense-set-interface-v4/run-tests.sh
new file mode 100755
index 0000000..64133d6
--- /dev/null
+++ b/tests/opnsense-set-interface-v4/run-tests.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# tests/opnsense-set-interface-v4/run-tests.sh
+#
+# Harness for scripts/opnsense-set-interface-v4.sh + scripts/opnsense-set-iface-v4.php.
+#
+# OFFLINE. Touches no edge. It exercises the arg contract and the SAFETY PROPERTIES,
+# which are the entire reason this script is allowed to exist: it writes an OPNsense
+# base-interface config on a LIVE router whose DHCP and firewall rules are API-managed
+# and would be destroyed by a wholesale config.xml replace.
+#
+# Same pinned properties as the v6 harness, plus the two this tool adds:
+# 7. Only ipaddr/subnet/gateway may be touched on the interface node.
+# 8. The GATEWAY half may add at most ONE gateway_item, count-guarded like the
+# interface count -- a live router's routing table is not a place for surprises.
+# ASCII + LF.
+set -uo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SDIR="$(cd "$HERE/../../scripts" && pwd)"
+SH="$SDIR/opnsense-set-interface-v4.sh"
+PHP="$SDIR/opnsense-set-iface-v4.php"
+pass=0; fail=0
+ok() { pass=$((pass+1)); }
+bad() { fail=$((fail+1)); echo " FAIL: $1"; }
+
+# 0. both exist and the shell half parses
+[ -f "$SH" ] && ok || bad "opnsense-set-interface-v4.sh missing"
+[ -f "$PHP" ] && ok || bad "opnsense-set-iface-v4.php missing"
+bash -n "$SH" 2>/dev/null && ok || bad "opnsense-set-interface-v4.sh does not parse"
+if command -v php >/dev/null 2>&1; then
+ php -l "$PHP" >/dev/null 2>&1 && ok || bad "opnsense-set-iface-v4.php does not parse (php -l)"
+else
+ ok # documented: no php on this host; the edge parses it at run time
+fi
+
+# 1. arg contract -- every missing arg must fail, not default to something.
+"$SH" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no args must fail"
+"$SH" 192.168.1.1 >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "host only must fail"
+"$SH" 192.168.1.1 lan >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no address must fail"
+"$SH" 192.168.1.1 lan 10.12.4.1 >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no prefixlen must fail"
+
+# 2. VALIDATION BEFORE THE ROUTER. A bad value must die locally -- it must never be
+# shipped to a live edge to be rejected there.
+out="$("$SH" 192.168.1.1 lan 2602:f3e2:f01:100::1 22 2>&1)"
+printf '%s' "$out" | grep -q "not an IPv4 address" && ok || bad "a v6 address was not rejected locally"
+out="$("$SH" 192.168.1.1 lan 10.12.4.999 22 2>&1)"
+printf '%s' "$out" | grep -q "not an IPv4 address" && ok || bad "an out-of-range octet was not rejected locally"
+out="$("$SH" 192.168.1.1 lan 10.12.4.1 33 2>&1)"
+printf '%s' "$out" | grep -q "out of range" && ok || bad "prefixlen 33 was not rejected locally"
+out="$("$SH" 192.168.1.1 lan 10.12.4.1 abc 2>&1)"
+printf '%s' "$out" | grep -q "not a prefix length" && ok || bad "a non-numeric prefixlen was not rejected"
+out="$("$SH" 192.168.1.1 wan 172.30.2.2 24 notanip 2>&1)"
+printf '%s' "$out" | grep -q "not an IPv4 gateway" && ok || bad "a bad gateway was not rejected locally"
+
+# 3. no key -> refuse. (Validation runs first, so use an otherwise-valid call.)
+out="$(env -u OPNSENSE_SSH_KEY "$SH" 192.168.1.1 lan 10.12.4.1 22 2>&1)"
+printf '%s' "$out" | grep -q 'OPNSENSE_SSH_KEY not set' && ok || bad "missing OPNSENSE_SSH_KEY was not refused"
+
+# 4. DRY BY DEFAULT -- in BOTH halves.
+grep -q '\-\-commit' "$SH" && ok || bad "the wrapper has no --commit flag"
+grep -q '\-\-commit' "$PHP" && ok || bad "the php has no --commit flag"
+grep -q 'DRY RUN -- nothing written, nothing applied' "$SH" && ok || bad "the wrapper does not announce its dry run"
+grep -q 'DRY RUN -- nothing was written' "$PHP" && ok || bad "the php does not announce its dry run"
+grep -q 'if (!\$commit)' "$PHP" && ok || bad "the php has no dry-run guard before the write"
+
+# 5. IT MUST NEVER RENDER OR PUSH A config.xml -- the D-113 red line.
+grep -qiE 'config\.xml\.tmpl|render-config|scp .*config\.xml|>[[:space:]]*/conf/config\.xml' "$SH" \
+ && bad "the wrapper renders or pushes a config.xml -- THE FORBIDDEN CLOBBER PATH" || ok
+grep -qE 'file_put_contents|fopen\(.*/conf/config\.xml' "$PHP" \
+ && bad "the php writes config.xml directly instead of via the vendor's Config object" || ok
+grep -q 'Config::getInstance()' "$PHP" && ok || bad "the php does not use OPNsense's own Config singleton"
+grep -q 'use OPNsense\\Core\\Config;' "$PHP" && ok || bad "the php no longer imports OPNsense's Config"
+
+# 6. THE STRUCTURAL GUARDS -- interface count, before and after a fresh read-back.
+grep -q 'iface_count_before' "$PHP" && ok || bad "the interface-count guard is gone"
+grep -q 'NOT SAVING' "$PHP" && ok || bad "the php no longer refuses to save on a count mismatch"
+grep -q 'CONFIG DAMAGED' "$PHP" && ok || bad "the post-save count re-check is gone"
+grep -q 'forceReload' "$PHP" && ok || bad "the php does not re-read from disk -- it would trust its own memory"
+grep -q 'read-back mismatch' "$PHP" && ok || bad "the php does not verify what it actually wrote"
+
+# 7. only ipaddr/subnet/gateway may be touched on the interface node.
+touched="$(grep -oE '\$node->[a-zA-Z0-9_]+ =' "$PHP" | sort -u | sed 's/\$node->//; s/ =//')"
+[ "$(printf '%s\n' "$touched" | grep -c .)" -eq 3 ] && ok \
+ || bad "the php assigns to other than 3 interface fields: $(printf '%s' "$touched" | tr '\n' ' ')"
+printf '%s\n' "$touched" | grep -qx 'ipaddr' && ok || bad "the php no longer sets ipaddr"
+printf '%s\n' "$touched" | grep -qx 'subnet' && ok || bad "the php no longer sets subnet"
+printf '%s\n' "$touched" | grep -qx 'gateway' && ok || bad "the php no longer sets the interface gateway"
+grep -q 'UNTOUCHED' "$PHP" && ok || bad "the php no longer shows which fields it leaves alone"
+
+# 8. THE GATEWAY HALF: count-guarded, at most one added item, and only when asked.
+grep -q 'gw_count_before' "$PHP" && ok || bad "the gateway-count guard is gone"
+grep -q 'expected +' "$PHP" && ok || bad "the php does not assert HOW MANY gateway items it added"
+grep -q 'gw_added' "$PHP" && ok || bad "the php no longer tracks whether it added a gateway item"
+grep -q "if (\$gwaddr !== '')" "$PHP" && ok || bad "the gateway half is not conditional -- it must be opt-in"
+grep -q 'read-back gateway mismatch' "$PHP" && ok || bad "the php does not verify the gateway it wrote"
+
+# 9. THE TCSH TRAP. root's shell on the edge is tcsh; a $(...) inside a quoted remote
+# command dies QUIETLY. Every remote command must be fed to `sh -s`.
+grep -q 'sh -s' "$SH" && ok || bad "remote commands are not fed to 'sh -s' -- the TCSH trap"
+grep -qi 'tcsh' "$SH" && ok || bad "the tcsh trap is no longer documented"
+
+# 10. saving config != applying it.
+grep -q 'configctl interface reconfigure' "$SH" && ok || bad "the wrapper never applies the change"
+grep -q 'NOT yet applied to the running kernel' "$PHP" && ok || bad "the php no longer warns that a save is not an apply"
+
+# 11. GROUND TRUTH. The kernel decides, not the config file and not a self-report.
+grep -q 'ifconfig' "$SH" && ok || bad "the wrapper never checks the KERNEL for the address"
+grep -q 'GROUND TRUTH' "$SH" && ok || bad "the ground-truth read-back is gone"
+grep -q 'is NOT on' "$SH" && ok || bad "the wrapper does not FAIL when the address is absent after reconfigure"
+
+# 12. THE DEVICE IS MEASURED, NOT GUESSED. The v6 sibling hardcodes lan->vtnet0 /
+# wan->vtnet1 via sed; that mapping is per-edge and must be read from the edge.
+grep -q "s/lan/vtnet0/" "$SH" && bad "the device mapping is HARDCODED (lan->vtnet0) -- it must be measured per edge" || ok
+grep -q 'measured from the edge' "$SH" && ok || bad "the wrapper does not state that it measured the device"
+
+# 13. the D-113 amendment must stay cited, WITH its 26.7 re-measurement -- this tool
+# only exists because the API cannot do this.
+grep -q 'D-113' "$SH" && ok || bad "the wrapper no longer cites D-113 (why this is not the REST API)"
+grep -q 'D-113' "$PHP" && ok || bad "the php no longer cites D-113"
+grep -q '26.7' "$SH" && ok || bad "the 26.7 re-measurement is no longer cited in the wrapper"
+grep -q '26.7' "$PHP" && ok || bad "the 26.7 re-measurement is no longer cited in the php"
+grep -qi 'interfaces.php' "$SH" && ok || bad "the measured evidence (legacy /interfaces.php page) is no longer cited"
+
+# 14. THE ORDERING WARNING. Re-addressing the LAN moves the interface you arrived on;
+# losing that warning is how a future operator strands themselves.
+grep -qi 'WAN first' "$SH" && ok || bad "the WAN-before-LAN ordering warning is gone"
+
+echo
+total=$((pass+fail))
+if [ "$fail" -eq 0 ]; then echo "opnsense-set-interface-v4: $pass/$total PASS"; exit 0; fi
+echo "opnsense-set-interface-v4: $fail/$total FAIL"; exit 1