From 919b60abe0efde0b0df0166ced4ba0a0b4ff49be Mon Sep 17 00:00:00 2001
From: abrichr
Date: Wed, 4 Nov 2026 18:11:14 -0500
Subject: [PATCH 01/21] on-prem: install, update and roll back the real engine
offline in CI
bin/build-release.sh builds a release archive from uv.lock (pinned wheels,
hash-checked) plus the flow wheel, a checksum and an openssl signature.
bin/test-offline-install.sh builds two signed releases, then as root inside
run-egress-blocked.sh installs the first from its wheelhouse, refuses a
tampered second, updates to the signed second, rolls back and runs the
air-gap attestation. The new flow-onprem-offline job runs it, and runs
bin/test-update.sh, which no job ran before.
---
.github/workflows/README.md | 2 +-
.github/workflows/packages.yml | 42 ++++
packages/flow/deploy/on-prem/README.md | 2 +
packages/flow/deploy/on-prem/UPDATE.md | 18 +-
.../flow/deploy/on-prem/bin/build-release.sh | 90 ++++++++
.../on-prem/bin/test-offline-install.sh | 198 ++++++++++++++++++
6 files changed, 350 insertions(+), 2 deletions(-)
create mode 100755 packages/flow/deploy/on-prem/bin/build-release.sh
create mode 100755 packages/flow/deploy/on-prem/bin/test-offline-install.sh
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 5cce5d3943..8117e74e17 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -102,7 +102,7 @@ App 15368 is GitHub Actions, the app the current checks are pinned to.
| main.yml | `run-ci` | ubuntu-latest, Python 3.12 | ubuntu-latest, Python 3.12 | ubuntu-latest and macos-latest, Python 3.10 to 3.12 |
| main.yml | `unsupported-python-guard` | no | no | Python 3.13 and 3.14 |
| packages.yml | `types`, `agent` | Python 3.12 | Python 3.12 | Python 3.10 and 3.12 |
-| packages.yml | `flow-lint`, `flow-critical` | yes | yes | yes |
+| packages.yml | `flow-lint`, `flow-critical`, `flow-onprem-offline` | yes | yes | yes |
| ci.yml | `smoke`, `ci` | yes | yes | no |
| packages.yml | `flow-unit` | 3 shards packed by measured time, fast lane (without `nightly_only.txt`) | no | 2 shards |
| packages.yml | `flow-native-census` | no | no | Windows and macOS |
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
index 30656bfec9..d59cd2105d 100644
--- a/.github/workflows/packages.yml
+++ b/.github/workflows/packages.yml
@@ -630,6 +630,46 @@ jobs:
sys.exit(1 if bad else 0)
PY
+ # The on-prem install path for real: build two signed offline releases of
+ # openadapt-flow[privacy] from uv.lock on this connected runner, then, as
+ # root in a network namespace with no route out, install the first from its
+ # wheelhouse, refuse a tampered second, update to the signed second, roll
+ # back, and run the air-gap attestation (egress probe, audit chain). pip has
+ # no index and the machine has no network, so a wheel missing from the
+ # archive fails the install. deploy/on-prem/bin/test-offline-install.sh.
+ # First, bin/test-update.sh: the update state machine's refusal, migration
+ # and recovery paths with a fake venv build, which nothing else runs.
+ flow-onprem-offline:
+ name: flow on-prem offline install, update and rollback (egress blocked)
+ needs: scope
+ if: github.event.pull_request.draft != true && (needs.scope.outputs.flow == 'true')
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ defaults:
+ run:
+ working-directory: packages/flow
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
+ with:
+ python-version: "3.12"
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
+ with:
+ enable-cache: false
+ - name: Signed update and rollback state machine (hermetic, fake build)
+ run: bash deploy/on-prem/bin/test-update.sh
+ - name: Build two signed offline releases from the lock (connected)
+ run: bash deploy/on-prem/bin/test-offline-install.sh stage "$RUNNER_TEMP/offline"
+ - name: Install, update and roll back with egress blocked
+ # env -u SUDO_UID keeps the command root inside the namespace:
+ # install.sh owns the release tree as root.
+ run: >-
+ sudo env -u SUDO_UID -u SUDO_GID "PATH=$PATH"
+ deploy/on-prem/bin/run-egress-blocked.sh
+ bash deploy/on-prem/bin/test-offline-install.sh verify "$RUNNER_TEMP/offline"
+
# The agent-framework adapters (openadapt_flow.integrations.agents): each
# framework's own agent loop with a scripted model, writing through a real
# HTTP app, from the lock with every framework extra (CrewAI pinned beside
@@ -1425,6 +1465,7 @@ jobs:
- flow-lint
- flow-unit
- flow-critical
+ - flow-onprem-offline
- flow-agent-frameworks
- flow-native-census
- flow-linux-atspi
@@ -1478,6 +1519,7 @@ jobs:
"flow-lint": "flow",
"flow-unit": "flow",
"flow-critical": "flow",
+ "flow-onprem-offline": "flow",
"flow-agent-frameworks": "flow",
"flow-native-census": "flow",
"flow-linux-atspi": "flow",
diff --git a/packages/flow/deploy/on-prem/README.md b/packages/flow/deploy/on-prem/README.md
index a9a36396a9..c68dff4b87 100644
--- a/packages/flow/deploy/on-prem/README.md
+++ b/packages/flow/deploy/on-prem/README.md
@@ -21,6 +21,8 @@ install story) and **[COMPLIANCE.md](COMPLIANCE.md)** (posture + data-flow).
| `bin/verify-airgap.sh` | Asserts no outbound path: config/env/deployment URL scan, optional active egress probe, optional audit-chain walk. | REAL (best-effort; firewall is primary) |
| `bin/run-egress-blocked.sh` | Runs a command in a loopback-only Linux network namespace, refusing to start unless no other interface or route exists. CI runs the golden free path through it (`tests/e2e/test_egress_blocked_e2e.py`): VERIFIED, zero model calls, and no outbound attempt in strict no-outbound mode. | REAL (Linux, root) |
| `bin/test-update.sh` | Hermetic offline proof of signed update, refusal paths, legacy migration, data preservation, rollback/recovery, locks, and audit-chain serialization. | REAL (43 baseline assertions; 45 when local GPG key generation is available) |
+| `bin/build-release.sh` | Builds a release archive on a connected host from `uv.lock`: the exact pinned wheels, hash-checked, plus the `openadapt-flow` wheel, a `.sha256`, and an openssl `.sig` when given the key. | REAL |
+| `bin/test-offline-install.sh` | Builds two signed releases, then as root with egress blocked installs the real engine from the first, refuses a tampered second, updates to the signed second, rolls back, and runs the air-gap attestation. CI runs it with `bin/test-update.sh` (job `flow-onprem-offline`). | REAL (Linux, root) |
| `UPDATE.md` | Offline update + rollback runbook: build a signed release, apply it atomically, revert instantly. | REAL (doc) |
| `systemd/openadapt-flow-runner.service` + `.path` | Event-driven runner unit with kernel-level egress denial (`IPAddressDeny=any`). | REAL (Linux/systemd) |
| `docker-compose.yml` + `Dockerfile` | Container alternative on an `internal:true` (egress-blocked) network; optional LAN-only VLM appliance profile. | REAL topology; STUB image build (needs offline `./wheels`) |
diff --git a/packages/flow/deploy/on-prem/UPDATE.md b/packages/flow/deploy/on-prem/UPDATE.md
index 0e594e8395..9346e2c8a8 100644
--- a/packages/flow/deploy/on-prem/UPDATE.md
+++ b/packages/flow/deploy/on-prem/UPDATE.md
@@ -42,7 +42,23 @@ all survive across any number of updates.
## Build a signed release bundle (off-site, on a connected host)
The bundle is a tarball containing a `VERSION` file and an offline pip
-wheelhouse, plus a required detached signature and an optional checksum:
+wheelhouse, plus a required detached signature and an optional checksum.
+
+`bin/build-release.sh` builds it from a source checkout: it downloads exactly
+the wheels `packages/flow/uv.lock` pins, checks each against the lock's hashes,
+adds the `openadapt-flow` wheel, and writes the archive, its `.sha256`, and an
+openssl `.sig` when given the signing key. Run it on a host with the same OS,
+CPU architecture and Python minor version as the clinic box:
+
+```bash
+deploy/on-prem/bin/build-release.sh --out dist/ --sign-key vendor_priv.pem
+```
+
+CI builds two releases this way and installs, updates and rolls back the real
+engine from them with the network cut off (`bin/test-offline-install.sh`).
+
+To assemble one by hand instead (this resolves the newest matching
+dependencies rather than the locked ones):
```bash
# 1. Assemble the payload
diff --git a/packages/flow/deploy/on-prem/bin/build-release.sh b/packages/flow/deploy/on-prem/bin/build-release.sh
new file mode 100755
index 0000000000..5b62d7878f
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/build-release.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+#
+# build-release.sh — build an offline release archive from the lock file.
+#
+# Run on a CONNECTED build host with the same OS, CPU architecture and Python
+# minor version as the air-gapped target. Produces, in --out:
+#
+# release-.tar.gz VERSION, wheels/, requirements.lock.txt
+# release-.tar.gz.sha256 checksum (corruption check only)
+# release-.tar.gz.sig openssl signature, when --sign-key is given
+#
+# Every third-party wheel is the exact version pinned in packages/flow/uv.lock
+# and is checked against the lock's hashes while downloading (pip
+# --require-hashes), so the archive holds what CI tested and nothing newer.
+# The openadapt-flow wheel is built from this checkout unless --flow-wheel
+# names one. Stage the archive with `install.sh --update` (see UPDATE.md), or
+# extract it and pass release/wheels to `install.sh --wheelhouse` for a first
+# install.
+#
+# Usage:
+# build-release.sh --out DIR [--flow-wheel FILE] [--sign-key PRIVATE.pem]
+# [--python PYTHON]
+#
+# Needs uv and the target Python (default python3). Exit 0 on success.
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+FLOW_DIR="$(cd "$HERE/../../.." && pwd)"
+OUT=""
+FLOW_WHEEL=""
+SIGN_KEY=""
+PYTHON="python3"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --out) OUT="${2:-}"; shift 2 ;;
+ --flow-wheel) FLOW_WHEEL="${2:-}"; shift 2 ;;
+ --sign-key) SIGN_KEY="${2:-}"; shift 2 ;;
+ --python) PYTHON="${2:-}"; shift 2 ;;
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "build-release.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+[[ -n "$OUT" ]] || { echo "build-release.sh: --out DIR is required" >&2; exit 2; }
+command -v uv >/dev/null || { echo "build-release.sh: uv is required" >&2; exit 2; }
+[[ -f "$FLOW_DIR/uv.lock" ]] || { echo "build-release.sh: no uv.lock in $FLOW_DIR" >&2; exit 2; }
+if [[ -n "$SIGN_KEY" && ! -f "$SIGN_KEY" ]]; then
+ echo "build-release.sh: signing key $SIGN_KEY not found" >&2; exit 2
+fi
+
+# shellcheck source=bin/lib-release.sh
+source "$HERE/lib-release.sh"
+
+mkdir -p "$OUT"
+OUT="$(cd "$OUT" && pwd)"
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/oa-build-release.XXXXXX")"
+trap 'rm -rf "$WORK"' EXIT
+mkdir -p "$WORK/release/wheels"
+
+if [[ -z "$FLOW_WHEEL" ]]; then
+ echo "== building the openadapt-flow wheel from $FLOW_DIR =="
+ (cd "$FLOW_DIR" && uv build --wheel --out-dir "$WORK/flow" >/dev/null)
+ FLOW_WHEEL="$(find "$WORK/flow" -maxdepth 1 -name 'openadapt_flow-*.whl' -print)"
+fi
+[[ -f "$FLOW_WHEEL" ]] || { echo "build-release.sh: flow wheel not found" >&2; exit 2; }
+cp "$FLOW_WHEEL" "$WORK/release/wheels/"
+VERSION="$(rel_wheelhouse_version "$WORK/release/wheels")"
+rel_validate_version "$VERSION"
+
+echo "== exporting the locked dependencies of openadapt-flow[privacy] $VERSION =="
+(cd "$FLOW_DIR" && uv export --frozen --no-dev --extra privacy --no-emit-project \
+ --format requirements-txt --no-header -o "$WORK/release/requirements.lock.txt" >/dev/null)
+
+echo "== downloading the locked wheels for $("$PYTHON" -c 'import platform, sys; print(sys.platform, platform.machine(), "py%d.%d" % sys.version_info[:2])') =="
+"$PYTHON" -m pip download --quiet --disable-pip-version-check \
+ --only-binary=:all: --no-deps --require-hashes \
+ -r "$WORK/release/requirements.lock.txt" -d "$WORK/release/wheels"
+
+printf '%s\n' "$VERSION" > "$WORK/release/VERSION"
+ARCHIVE="$OUT/release-$VERSION.tar.gz"
+COPYFILE_DISABLE=1 tar czf "$ARCHIVE" -C "$WORK" release
+sha256_of "$ARCHIVE" > "$ARCHIVE.sha256"
+if [[ -n "$SIGN_KEY" ]]; then
+ openssl dgst -sha256 -sign "$SIGN_KEY" -out "$ARCHIVE.sig" "$ARCHIVE"
+fi
+
+count="$(find "$WORK/release/wheels" -name '*.whl' | wc -l | tr -d ' ')"
+echo "built $ARCHIVE ($count wheels)"
+[[ -n "$SIGN_KEY" ]] && echo "signed $ARCHIVE.sig" || echo "unsigned: sign it before staging (UPDATE.md)"
diff --git a/packages/flow/deploy/on-prem/bin/test-offline-install.sh b/packages/flow/deploy/on-prem/bin/test-offline-install.sh
new file mode 100755
index 0000000000..e910abf488
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/test-offline-install.sh
@@ -0,0 +1,198 @@
+#!/usr/bin/env bash
+#
+# test-offline-install.sh — install, upgrade and roll back the real engine on a
+# machine with no network.
+#
+# Unlike test-update.sh, which fakes the venv build, this installs the real
+# openadapt-flow[privacy] wheels pinned by uv.lock, so it proves the offline
+# release archive is complete: pip gets no index and the machine has no route
+# out, so any missing wheel fails the install.
+#
+# stage DIR on a connected host: build two signed releases (this checkout
+# as V1, the same source as V1.post1 as V2) and a throwaway
+# signing key, with bin/build-release.sh.
+# verify DIR as root, inside bin/run-egress-blocked.sh: first install from
+# V1's wheelhouse, refuse a tampered V2, update to the signed V2,
+# roll back to V1, then run the air-gap attestation with its
+# egress probe and audit-chain walk. Data must survive each step.
+#
+# CI runs both (packages.yml, flow on-prem offline install). By hand on Linux:
+# bin/test-offline-install.sh stage /tmp/oa-offline
+# sudo env -u SUDO_UID -u SUDO_GID "PATH=$PATH" \
+# bin/run-egress-blocked.sh bin/test-offline-install.sh verify /tmp/oa-offline
+#
+# Exit 0 when every check passes.
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ONPREM="$(cd "$HERE/.." && pwd)"
+FLOW_DIR="$(cd "$ONPREM/../.." && pwd)"
+CMD="${1:-}"
+DIR="${2:-}"
+if [[ -z "$CMD" || -z "$DIR" || ( "$CMD" != stage && "$CMD" != verify ) ]]; then
+ grep '^#' "$0" | sed 's/^# \{0,1\}//'
+ exit 2
+fi
+
+stage() {
+ set -e
+ mkdir -p "$DIR/keys" "$DIR/v1" "$DIR/v2"
+ DIR="$(cd "$DIR" && pwd)"
+ openssl genrsa -out "$DIR/keys/vendor.pem" 2048 2>/dev/null
+ openssl rsa -in "$DIR/keys/vendor.pem" -pubout -out "$DIR/keys/vendor.pub" 2>/dev/null
+ bash "$HERE/build-release.sh" --out "$DIR/v1" --sign-key "$DIR/keys/vendor.pem" \
+ ${OPENADAPT_ONPREM_PY:+--python "$OPENADAPT_ONPREM_PY"}
+
+ # V2: the same source under a new version, built the same way.
+ local v1 src
+ v1="$(find "$DIR/v1" -maxdepth 1 -name 'release-*.tar.gz' -print)"
+ v1="$(basename "$v1" .tar.gz)"
+ v1="${v1#release-}"
+ src="$(mktemp -d "${TMPDIR:-/tmp}/oa-offline-src.XXXXXX")"
+ for item in pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md \
+ public-artifacts.json openadapt_flow schemas; do
+ cp -R "$FLOW_DIR/$item" "$src/"
+ done
+ find "$src" -name __pycache__ -type d -prune -exec rm -rf {} +
+ sed -i.bak "s/^version = \"$v1\"$/version = \"$v1.post1\"/" "$src/pyproject.toml"
+ sed -i.bak "s/^__version__ = \"$v1\"$/__version__ = \"$v1.post1\"/" "$src/openadapt_flow/__init__.py"
+ rm -f "$src/pyproject.toml.bak" "$src/openadapt_flow/__init__.py.bak"
+ grep -q "^version = \"$v1.post1\"$" "$src/pyproject.toml"
+ (cd "$src" && uv build --wheel --out-dir "$src/dist" >/dev/null)
+ bash "$HERE/build-release.sh" --out "$DIR/v2" --sign-key "$DIR/keys/vendor.pem" \
+ --flow-wheel "$src/dist/openadapt_flow-$v1.post1-py3-none-any.whl" \
+ ${OPENADAPT_ONPREM_PY:+--python "$OPENADAPT_ONPREM_PY"}
+ rm -rf "$src"
+ # The private key never goes near the install.
+ rm -f "$DIR/keys/vendor.pem"
+ printf '%s\n' "$v1" > "$DIR/V1"
+ printf '%s\n' "$v1.post1" > "$DIR/V2"
+ echo "staged $v1 and $v1.post1 in $DIR"
+}
+
+PASS=0
+FAIL=0
+ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); }
+bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); }
+check() { local name="$1"; shift; if "$@"; then ok "$name"; else bad "$name"; fi; }
+check_eq() { if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi; }
+
+verify() {
+ DIR="$(cd "$DIR" && pwd)"
+ if [[ "$(id -u)" -ne 0 ]]; then
+ echo "test-offline-install.sh verify: run as root (install.sh needs it)" >&2; exit 2
+ fi
+ if [[ "${OPENADAPT_EGRESS_BLOCKED:-}" != netns ]]; then
+ echo "test-offline-install.sh verify: run inside bin/run-egress-blocked.sh" >&2; exit 2
+ fi
+ local v1 v2 py work root config rc
+ v1="$(cat "$DIR/V1")"; v2="$(cat "$DIR/V2")"
+ py="${OPENADAPT_ONPREM_PY:-python3}"
+ work="$(mktemp -d "${TMPDIR:-/tmp}/oa-offline-verify.XXXXXX")"
+ root="$work/srv"
+ config="$work/onprem.yaml"
+ cat > "$config" <&1
+ }
+
+ echo "== offline install, upgrade and rollback ($v1 -> $v2 -> $v1), no network =="
+ check "the machine has no route out" "$py" -c '
+import socket, sys
+try:
+ socket.create_connection(("1.1.1.1", 443), timeout=3)
+except OSError:
+ sys.exit(0)
+sys.exit(1)'
+
+ echo "-- first install from the $v1 wheelhouse --"
+ mkdir -p "$work/media"
+ tar xzf "$DIR/v1/release-$v1.tar.gz" -C "$work/media"
+ bash "$ONPREM/install.sh" --config "$config" --wheelhouse "$work/media/release/wheels" \
+ > "$work/install.log" 2>&1
+ rc=$?
+ check_eq "install: exit 0" 0 "$rc"
+ [[ "$rc" -eq 0 ]] || tail -n 40 "$work/install.log"
+ check_eq "install: current -> $v1" "$root/releases/$v1" "$(readlink "$root/current")"
+ check_eq "install: engine and privacy extra import, version $v1" "$v1" "$(engine_version)"
+ check "install: engine CLI runs" "$root/current/venv/bin/openadapt-flow" --help
+
+ mkdir -p "$root/bundles" "$root/jobs/inbox"
+ printf 'bundle\n' > "$root/bundles/keepme.txt"
+ printf 'job\n' > "$root/jobs/inbox/queued-0001.job"
+ local bundle_sha job_sha
+ bundle_sha="$(shasum -a 256 "$root/bundles/keepme.txt" | awk '{print $1}')"
+ job_sha="$(shasum -a 256 "$root/jobs/inbox/queued-0001.job" | awk '{print $1}')"
+ cp "$DIR/keys/vendor.pub" "$root/keys/vendor.pub"
+
+ local arc="$DIR/v2/release-$v2.tar.gz"
+ echo "-- a tampered $v2 archive is refused --"
+ cp "$arc" "$work/media/tampered.tar.gz"
+ printf 'x' >> "$work/media/tampered.tar.gz"
+ bash "$ONPREM/install.sh" --update --config "$config" --release "$work/media/tampered.tar.gz" \
+ --signature "$arc.sig" > "$work/tampered.log" 2>&1
+ rc=$?
+ check "tampered: update refused" test "$rc" -ne 0
+ check_eq "tampered: current still $v1" "$root/releases/$v1" "$(readlink "$root/current")"
+ check "tampered: nothing built for $v2" test ! -e "$root/releases/$v2"
+
+ echo "-- signed update to $v2 --"
+ bash "$ONPREM/install.sh" --update --config "$config" --release "$arc" \
+ --checksum "$arc.sha256" --signature "$arc.sig" > "$work/update.log" 2>&1
+ rc=$?
+ check_eq "update: exit 0" 0 "$rc"
+ [[ "$rc" -eq 0 ]] || tail -n 40 "$work/update.log"
+ check_eq "update: current -> $v2" "$root/releases/$v2" "$(readlink "$root/current")"
+ check_eq "update: previous -> $v1" "$root/releases/$v1" "$(readlink "$root/previous")"
+ check_eq "update: engine version $v2" "$v2" "$(engine_version)"
+ check_eq "update: bundle unchanged" "$bundle_sha" "$(shasum -a 256 "$root/bundles/keepme.txt" | awk '{print $1}')"
+ check_eq "update: queued job unchanged" "$job_sha" "$(shasum -a 256 "$root/jobs/inbox/queued-0001.job" | awk '{print $1}')"
+
+ echo "-- rollback to $v1 --"
+ bash "$ONPREM/install.sh" --rollback --config "$config" > "$work/rollback.log" 2>&1
+ rc=$?
+ check_eq "rollback: exit 0" 0 "$rc"
+ [[ "$rc" -eq 0 ]] || tail -n 40 "$work/rollback.log"
+ check_eq "rollback: current -> $v1" "$root/releases/$v1" "$(readlink "$root/current")"
+ check_eq "rollback: engine version $v1" "$v1" "$(engine_version)"
+ check_eq "rollback: bundle unchanged" "$bundle_sha" "$(shasum -a 256 "$root/bundles/keepme.txt" | awk '{print $1}')"
+
+ echo "-- air-gap attestation --"
+ OPENADAPT_FLOW_SCRUB=on bash "$HERE/verify-airgap.sh" --config "$config" --probe --audit \
+ > "$work/airgap.log" 2>&1
+ rc=$?
+ check_eq "attestation: exit 0" 0 "$rc"
+ check "attestation: egress probe could not reach the internet" \
+ grep -q "outbound egress is blocked" "$work/airgap.log"
+ check "attestation: audit chain intact" grep -q "audit chain intact" "$work/airgap.log"
+ [[ "$rc" -eq 0 ]] || cat "$work/airgap.log"
+
+ rm -rf "$work"
+ echo
+ echo "== result: $PASS passed, $FAIL failed =="
+ [[ "$FAIL" -eq 0 ]]
+}
+
+"$CMD"
From f5eb3c39c35e70ce0de4b98e52023629300f939c Mon Sep 17 00:00:00 2001
From: abrichr
Date: Wed, 4 Nov 2026 18:17:42 -0500
Subject: [PATCH 02/21] on-prem: check the engine version from the release
venv, not the checkout
The version check ran python -c from packages/flow, so it imported the
source checkout and reported 2.6.0 after the update to 2.6.0.post1. Run it
isolated from /, require the module to live in the venv, and compare the
module with the installed distribution and the CLI's --version.
---
.../deploy/on-prem/bin/test-offline-install.sh | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/packages/flow/deploy/on-prem/bin/test-offline-install.sh b/packages/flow/deploy/on-prem/bin/test-offline-install.sh
index e910abf488..c3e6d391cd 100755
--- a/packages/flow/deploy/on-prem/bin/test-offline-install.sh
+++ b/packages/flow/deploy/on-prem/bin/test-offline-install.sh
@@ -113,9 +113,20 @@ updates:
YAML
export OPENADAPT_ONPREM_PY="$py"
export OPENADAPT_ONPREM_AUDIT_LOG="$root/audit/audit.log"
+ # -I and cd /: import only what the release venv installed, never a source
+ # checkout in the working directory. Prints the installed distribution's
+ # version, and fails unless the module is the one inside the venv.
engine_version() {
- "$root/current/venv/bin/python" -c \
- 'import openadapt_flow, presidio_analyzer, openadapt_privacy; print(openadapt_flow.__version__)' 2>&1
+ (cd / && "$root/current/venv/bin/python" -I -c '
+import importlib.metadata, os, sys
+import openadapt_flow, openadapt_privacy, presidio_analyzer
+here = os.path.realpath(openadapt_flow.__file__)
+if not here.startswith(os.path.realpath(sys.prefix) + os.sep):
+ sys.exit("openadapt_flow imported from outside the release venv: " + here)
+version = importlib.metadata.version("openadapt-flow")
+if openadapt_flow.__version__ != version:
+ sys.exit("module says %s, distribution says %s" % (openadapt_flow.__version__, version))
+print(version)' 2>&1)
}
echo "== offline install, upgrade and rollback ($v1 -> $v2 -> $v1), no network =="
@@ -137,7 +148,8 @@ sys.exit(1)'
[[ "$rc" -eq 0 ]] || tail -n 40 "$work/install.log"
check_eq "install: current -> $v1" "$root/releases/$v1" "$(readlink "$root/current")"
check_eq "install: engine and privacy extra import, version $v1" "$v1" "$(engine_version)"
- check "install: engine CLI runs" "$root/current/venv/bin/openadapt-flow" --help
+ check_eq "install: engine CLI reports $v1" "openadapt-flow $v1" \
+ "$(cd / && "$root/current/venv/bin/openadapt-flow" --version 2>&1)"
mkdir -p "$root/bundles" "$root/jobs/inbox"
printf 'bundle\n' > "$root/bundles/keepme.txt"
From efbb57c2abebf39fc5626fbb5c45d4de28020c60 Mon Sep 17 00:00:00 2001
From: abrichr
Date: Wed, 4 Nov 2026 18:32:14 -0500
Subject: [PATCH 03/21] on-prem audit: do not break a lock that a live writer
just took
A writer that found the audit lock owned by an exited process removed it
without re-checking. If the owner released between the read and the
removal and another writer took the lock, the breaker deleted the live
lock and two writers could chain to the same parent. Break stale locks
only under a second lock and only if the lock still names the same dead
owner. Add a deterministic test that forces the handover, and print the
verifier output when the concurrent-writer check fails.
---
packages/flow/deploy/on-prem/bin/audit-log.sh | 21 +++++++++-
.../flow/deploy/on-prem/bin/test-update.sh | 42 +++++++++++++++++++
2 files changed, 61 insertions(+), 2 deletions(-)
diff --git a/packages/flow/deploy/on-prem/bin/audit-log.sh b/packages/flow/deploy/on-prem/bin/audit-log.sh
index 091add53c3..9b51ce4e4a 100755
--- a/packages/flow/deploy/on-prem/bin/audit-log.sh
+++ b/packages/flow/deploy/on-prem/bin/audit-log.sh
@@ -69,7 +69,25 @@ chmod 0700 "$(dirname "$AUDIT_LOG")" 2>/dev/null || true
# interleaving, but without this lock two writers can both chain to the same
# parent and produce a structurally broken audit log.
lock_dir="${AUDIT_LOG}.lock"
+break_dir="${AUDIT_LOG}.lock-break"
lock_acquired=0
+
+# Remove a lock whose owner has exited. Between reading the owner and removing
+# the lock, that owner can release it and another writer can take it, so the
+# removal happens only under a second lock that serializes breakers, and only
+# if the lock still names the same dead owner. Without this, a breaker could
+# delete a live writer's lock and let two writers chain to the same parent.
+break_stale_lock() {
+ local stale="$1" current=""
+ mkdir "$break_dir" 2>/dev/null || return 0
+ current="$(cat "$lock_dir/pid" 2>/dev/null || true)"
+ if [[ "$current" == "$stale" ]] && ! kill -0 "$stale" 2>/dev/null; then
+ rm -f "$lock_dir/pid" 2>/dev/null || true
+ rmdir "$lock_dir" 2>/dev/null || true
+ fi
+ rmdir "$break_dir" 2>/dev/null || true
+}
+
for ((attempt = 0; attempt < 200; attempt++)); do
if mkdir "$lock_dir" 2>/dev/null; then
printf '%s\n' "$$" > "$lock_dir/pid"
@@ -81,8 +99,7 @@ for ((attempt = 0; attempt < 200; attempt++)); do
owner="$(cat "$lock_dir/pid" 2>/dev/null || true)"
fi
if [[ -n "$owner" && "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then
- rm -f "$lock_dir/pid" 2>/dev/null || true
- rmdir "$lock_dir" 2>/dev/null || true
+ break_stale_lock "$owner"
continue
fi
sleep 0.05
diff --git a/packages/flow/deploy/on-prem/bin/test-update.sh b/packages/flow/deploy/on-prem/bin/test-update.sh
index c2778b4602..26be744811 100755
--- a/packages/flow/deploy/on-prem/bin/test-update.sh
+++ b/packages/flow/deploy/on-prem/bin/test-update.sh
@@ -290,7 +290,49 @@ if grep -q 'audit chain intact' <<< "$audit_output"; then
pass "concurrent audit writers preserve one hash chain"
else
fail "concurrent audit writers preserve one hash chain"
+ printf '%s\n' "$audit_output" | sed 's/^/ /'
fi
+
+# A writer that finds a lock owned by an exited process may break it. Force the
+# race where, between that writer reading the dead owner and breaking the
+# lock, the owner releases and a live writer takes the lock: a shimmed cat
+# returns the dead owner once, then hands the lock to a live process. The
+# breaker must leave the live writer's lock alone.
+RACE="$WORK/stale-race"
+mkdir -p "$RACE/shim"
+RACE_LOG="$RACE/audit.log"
+bash -c 'exit 0' &
+dead_pid=$!
+wait "$dead_pid"
+sleep 30 &
+live_pid=$!
+mkdir "$RACE_LOG.lock"
+printf '%s\n' "$dead_pid" > "$RACE_LOG.lock/pid"
+REAL_CAT="$(command -v cat)"
+cat > "$RACE/shim/cat" < "$RACE/handed-over"
+ printf '%s\n' "$live_pid" > "$RACE_LOG.lock/pid"
+ exit 0
+fi
+exec "$REAL_CAT" "\$@"
+SHIM_EOF
+chmod +x "$RACE/shim/cat"
+OPENADAPT_ONPREM_AUDIT_LOG="$RACE_LOG" PATH="$RACE/shim:$PATH" \
+ bash "$OPENADAPT_ONPREM_AUDIT_BIN" started stale-race --note test >/dev/null 2>&1 &
+race_writer=$!
+sleep 2
+race_owner="$(cat "$RACE_LOG.lock/pid" 2>/dev/null || echo none)"
+assert_eq "stale-lock breaker leaves a live writer's lock alone" "$live_pid" "$race_owner"
+rm -f "$RACE_LOG.lock/pid"
+rmdir "$RACE_LOG.lock" 2>/dev/null || true
+kill "$live_pid" 2>/dev/null || true
+wait "$live_pid" 2>/dev/null || true
+wait "$race_writer"
+assert_eq "writer appends once the live lock is released" 0 "$?"
+assert_eq "writer appends exactly one record" 1 "$(wc -l < "$RACE_LOG" | tr -d ' ')"
if ! grep -q 'identity evidence' "$OPENADAPT_ONPREM_AUDIT_LOG"; then
pass "release audit remains PHI-free"
else
From 7f2de3449d43c59ff768b7858694b7f38b14768c Mon Sep 17 00:00:00 2001
From: abrichr
Date: Wed, 4 Nov 2026 18:55:47 -0500
Subject: [PATCH 04/21] on-prem: build the runner image from a signed release
with no network, and test it in CI
The Dockerfile installed from an unpinned ./wheels directory that nothing
produced, and its command (bin/run-queue.sh relative to the storage volume)
could not resolve. build-image.sh now verifies a signed release archive
against the pinned vendor key, extracts it, and builds with --network none
from the release's lock-pinned wheels. docker-compose.yml runs a named,
never-pulled image with an absolute command. test-container.sh builds it in
CI, refuses a tampered archive, shows a missing wheel fails the build, and
runs the runner on the compose network with no route out.
---
.github/workflows/packages.yml | 6 +-
packages/flow/deploy/on-prem/Dockerfile | 53 +++--
packages/flow/deploy/on-prem/README.md | 2 +-
.../flow/deploy/on-prem/bin/build-image.sh | 82 +++++++
.../flow/deploy/on-prem/bin/test-container.sh | 209 ++++++++++++++++++
.../flow/deploy/on-prem/docker-compose.yml | 31 +--
packages/flow/docs/ON_PREM.md | 6 +-
7 files changed, 348 insertions(+), 41 deletions(-)
create mode 100755 packages/flow/deploy/on-prem/bin/build-image.sh
create mode 100755 packages/flow/deploy/on-prem/bin/test-container.sh
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
index d59cd2105d..346a428817 100644
--- a/.github/workflows/packages.yml
+++ b/.github/workflows/packages.yml
@@ -638,7 +638,9 @@ jobs:
# no index and the machine has no network, so a wheel missing from the
# archive fails the install. deploy/on-prem/bin/test-offline-install.sh.
# First, bin/test-update.sh: the update state machine's refusal, migration
- # and recovery paths with a fake venv build, which nothing else runs.
+ # and recovery paths with a fake venv build, which nothing else runs. Then
+ # bin/test-container.sh: the runner image built from the first release with
+ # no network, run on docker-compose.yml's internal network.
flow-onprem-offline:
name: flow on-prem offline install, update and rollback (egress blocked)
needs: scope
@@ -662,6 +664,8 @@ jobs:
run: bash deploy/on-prem/bin/test-update.sh
- name: Build two signed offline releases from the lock (connected)
run: bash deploy/on-prem/bin/test-offline-install.sh stage "$RUNNER_TEMP/offline"
+ - name: Container image from the signed release (no-network build, internal network)
+ run: bash deploy/on-prem/bin/test-container.sh "$RUNNER_TEMP/offline"
- name: Install, update and roll back with egress blocked
# env -u SUDO_UID keeps the command root inside the namespace:
# install.sh owns the release tree as root.
diff --git a/packages/flow/deploy/on-prem/Dockerfile b/packages/flow/deploy/on-prem/Dockerfile
index 1e176cf75a..b71c2bdffa 100644
--- a/packages/flow/deploy/on-prem/Dockerfile
+++ b/packages/flow/deploy/on-prem/Dockerfile
@@ -1,37 +1,46 @@
-# openadapt-flow on-prem runner image — built OFFLINE from a local wheelhouse.
+# openadapt-flow on-prem runner image, built with no network from a signed
+# release archive (bin/build-release.sh).
#
-# Air-gapped hosts do not reach PyPI. Build this on a connected staging host (or
-# with a pre-populated ./wheels) and copy the resulting image tar into the
-# clinic via removable media (`docker save` / `docker load`).
+# Do not run `docker build` on this file directly. bin/build-image.sh checks the
+# archive's signature against the pinned vendor key, extracts it, lays out the
+# build context below and builds with `--network none`, so pip can only use
+# the release's own wheels (the ones pinned by uv.lock):
#
-# Prepare the wheelhouse off-site:
-# pip download 'openadapt-flow[privacy]' -d deploy/on-prem/wheels
-# Then build (context = repo root, per docker-compose.yml):
-# docker build -f deploy/on-prem/Dockerfile -t openadapt-flow-onprem:local .
+# release/ the extracted release: VERSION, wheels/, requirements.lock.txt
+# on-prem/ this directory (scripts, units, this Dockerfile)
#
-# [STUB] the ./wheels wheelhouse is NOT committed (it is large and version-
-# specific). Without it the build fails LOUDLY rather than reaching the network.
+# The base image must already be in the local image store (`docker load` it
+# on an air-gapped host). Its default is pinned by digest; the release must be
+# built for the same Python minor version and CPU architecture (see
+# build-release.sh). Move the result in with `docker save` / `docker load`.
-FROM python:3.11-slim
+ARG BASE_IMAGE=python:3.12-slim@sha256:b18b261e1b4974b4f321451ad5251ded85abb22c2b8b262da527dda1d031ab30
+FROM ${BASE_IMAGE}
-# Never let pip reach the internet from inside the image build.
ENV PIP_NO_INDEX=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
OPENADAPT_FLOW_SCRUB=on
-WORKDIR /opt/openadapt-flow
+# The same install as install.sh (lib-release.sh rel_build_release): only the
+# release's wheels, the exact version the archive declares, then a dependency
+# consistency check.
+COPY release /tmp/release
+RUN set -eu; \
+ version="$(cat /tmp/release/VERSION)"; \
+ python -m venv /opt/openadapt-flow/venv; \
+ /opt/openadapt-flow/venv/bin/pip install --no-index --only-binary=:all: \
+ --find-links /tmp/release/wheels "openadapt-flow[privacy]==${version}"; \
+ /opt/openadapt-flow/venv/bin/pip check; \
+ printf '%s\n' "$version" > /opt/openadapt-flow/VERSION; \
+ rm -rf /tmp/release
-# Copy the offline wheelhouse (staged off-site) and the deploy scripts.
-COPY deploy/on-prem/wheels /tmp/wheels
-COPY deploy/on-prem /opt/openadapt-flow/deploy/on-prem
+COPY on-prem /opt/openadapt-flow/deploy/on-prem
-# Install the engine + privacy extra from local wheels only.
-RUN pip install --no-index --find-links /tmp/wheels 'openadapt-flow[privacy]' \
- && rm -rf /tmp/wheels
+ENV PATH="/opt/openadapt-flow/venv/bin:/opt/openadapt-flow/deploy/on-prem/bin:${PATH}"
# Storage root is a bind-mounted, host-encrypted volume at runtime.
WORKDIR /srv/openadapt
-ENV PATH="/opt/openadapt-flow/deploy/on-prem:${PATH}"
-# Default: process the local job queue. Overridden by docker-compose command.
-CMD ["deploy/on-prem/bin/run-queue.sh", "watch"]
+# Absolute path: the working directory is the storage volume, not the scripts.
+CMD ["/opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh", "watch"]
diff --git a/packages/flow/deploy/on-prem/README.md b/packages/flow/deploy/on-prem/README.md
index c68dff4b87..a2f1f81afa 100644
--- a/packages/flow/deploy/on-prem/README.md
+++ b/packages/flow/deploy/on-prem/README.md
@@ -25,7 +25,7 @@ install story) and **[COMPLIANCE.md](COMPLIANCE.md)** (posture + data-flow).
| `bin/test-offline-install.sh` | Builds two signed releases, then as root with egress blocked installs the real engine from the first, refuses a tampered second, updates to the signed second, rolls back, and runs the air-gap attestation. CI runs it with `bin/test-update.sh` (job `flow-onprem-offline`). | REAL (Linux, root) |
| `UPDATE.md` | Offline update + rollback runbook: build a signed release, apply it atomically, revert instantly. | REAL (doc) |
| `systemd/openadapt-flow-runner.service` + `.path` | Event-driven runner unit with kernel-level egress denial (`IPAddressDeny=any`). | REAL (Linux/systemd) |
-| `docker-compose.yml` + `Dockerfile` | Container alternative on an `internal:true` (egress-blocked) network; optional LAN-only VLM appliance profile. | REAL topology; STUB image build (needs offline `./wheels`) |
+| `docker-compose.yml` + `Dockerfile` + `bin/build-image.sh` | Container alternative on an `internal:true` (egress-blocked) network; optional LAN-only VLM appliance profile. `bin/build-image.sh` verifies a signed release and builds the image from its wheels with `--network none`. `bin/test-container.sh` (CI, job `flow-onprem-offline`) builds it, refuses a tampered archive, checks a missing wheel fails the build, and runs the runner on the compose network with no route out. | REAL (Linux, Docker) |
| `COMPLIANCE.md` | Regulatory posture, data-flow diagram, what's encrypted, boundaries. | REAL (doc; non-legal) |
## 60-second install (systemd host)
diff --git a/packages/flow/deploy/on-prem/bin/build-image.sh b/packages/flow/deploy/on-prem/bin/build-image.sh
new file mode 100755
index 0000000000..f75b2d04ef
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/build-image.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+#
+# build-image.sh — build the on-prem runner image from a signed release
+# archive, with no network.
+#
+# Checks the archive against the pinned vendor key (the same check as
+# `install.sh --update`), extracts it with the bounded extractor, and runs
+# `docker build --network none` on ../Dockerfile, so the image holds exactly
+# the release's wheels. A missing wheel fails the build instead of reaching
+# PyPI. The base image must already be loaded locally.
+#
+# Usage:
+# build-image.sh --archive release-V.tar.gz --signature release-V.tar.gz.sig
+# --pubkey vendor.pub [--sig-tool openssl|gpg|minisign|signify]
+# [--checksum FILE] [--tag NAME:TAG] [--base IMAGE]
+# [--save image.tar]
+#
+# The default tag is openadapt-flow-onprem:. Run the image
+# with docker-compose.yml (OPENADAPT_FLOW_IMAGE=). Exit 0 on success.
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ONPREM="$(cd "$HERE/.." && pwd)"
+ARCHIVE=""
+SIGNATURE=""
+PUBKEY=""
+SIG_TOOL=""
+CHECKSUM=""
+TAG=""
+BASE=""
+SAVE=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --archive) ARCHIVE="${2:-}"; shift 2 ;;
+ --signature) SIGNATURE="${2:-}"; shift 2 ;;
+ --pubkey) PUBKEY="${2:-}"; shift 2 ;;
+ --sig-tool) SIG_TOOL="${2:-}"; shift 2 ;;
+ --checksum) CHECKSUM="${2:-}"; shift 2 ;;
+ --tag) TAG="${2:-}"; shift 2 ;;
+ --base) BASE="${2:-}"; shift 2 ;;
+ --save) SAVE="${2:-}"; shift 2 ;;
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "build-image.sh: unknown arg $1" >&2; exit 2 ;;
+ esac
+done
+[[ -n "$ARCHIVE" ]] || { echo "build-image.sh: --archive is required" >&2; exit 2; }
+command -v docker >/dev/null || { echo "build-image.sh: docker is required" >&2; exit 2; }
+
+# shellcheck source=bin/lib-release.sh
+source "$HERE/lib-release.sh"
+
+rel_verify_integrity "$ARCHIVE" "$CHECKSUM" "$SIGNATURE" "$PUBKEY" "$SIG_TOOL" || {
+ echo "build-image.sh: refusing to build from an unverified archive" >&2; exit 1
+}
+
+CTX="$(mktemp -d "${TMPDIR:-/tmp}/oa-build-image.XXXXXX")"
+trap 'rm -rf "$CTX"' EXIT
+SRC="$(rel_extract "$ARCHIVE" "$CTX/extract")" || {
+ echo "build-image.sh: release archive could not be extracted" >&2; exit 1
+}
+VERSION="$(rel_declared_version "$SRC" "$SRC/wheels")" || exit 1
+mv "$SRC" "$CTX/release"
+rm -rf "$CTX/extract"
+
+# The deploy scripts, without anything a site may have left beside them.
+mkdir -p "$CTX/on-prem"
+tar -C "$ONPREM" --exclude=./wheels --exclude=./storage --exclude=./release \
+ --exclude='*.pem' --exclude='*.env' -cf - . | tar -C "$CTX/on-prem" -xf -
+
+TAG="${TAG:-openadapt-flow-onprem:$VERSION}"
+args=(build --network none --pull=false -f "$CTX/on-prem/Dockerfile" -t "$TAG")
+[[ -n "$BASE" ]] && args+=(--build-arg "BASE_IMAGE=$BASE")
+echo "== building $TAG from release $VERSION with no network =="
+docker "${args[@]}" "$CTX"
+
+if [[ -n "$SAVE" ]]; then
+ docker save -o "$SAVE" "$TAG"
+ echo "saved $TAG to $SAVE"
+fi
+echo "built $TAG"
diff --git a/packages/flow/deploy/on-prem/bin/test-container.sh b/packages/flow/deploy/on-prem/bin/test-container.sh
new file mode 100755
index 0000000000..2abf6090bc
--- /dev/null
+++ b/packages/flow/deploy/on-prem/bin/test-container.sh
@@ -0,0 +1,209 @@
+#!/usr/bin/env bash
+#
+# test-container.sh — build the on-prem runner image from a signed release
+# with no network, and run it on the compose file's internal network.
+#
+# test-container.sh DIR
+#
+# DIR is what `bin/test-offline-install.sh stage DIR` writes (a signed V1
+# release and the vendor public key). Needs Linux, Docker with compose, and a
+# connected host only to pull the pinned base image. Checks:
+# - build-image.sh refuses a tampered archive and builds no image;
+# - the image build has no network: the same release with one dependency
+# wheel removed fails to build;
+# - the signed release builds, and inside the image (no network) the engine
+# and privacy extra import from the image's venv at the release version;
+# - the default command resolves, and run-queue.sh files a job and writes
+# the audit log on a bind-mounted storage root; it refuses to start with
+# scrubbing off;
+# - docker-compose.yml needs the image named, never pulls, publishes no
+# ports, and its network is internal: a TCP connect to the internet that
+# works from the same image on Docker's default network fails from the
+# compose runner.
+#
+# CI runs it in packages.yml (flow on-prem offline install). Exit 0 when every
+# check passes.
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ONPREM="$(cd "$HERE/.." && pwd)"
+DIR="${1:-}"
+if [[ -z "$DIR" || ! -f "$DIR/V1" ]]; then
+ grep '^#' "$0" | sed 's/^# \{0,1\}//'
+ exit 2
+fi
+DIR="$(cd "$DIR" && pwd)"
+command -v docker >/dev/null || { echo "test-container.sh: docker is required" >&2; exit 2; }
+
+PASS=0
+FAIL=0
+ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); }
+bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); }
+check_eq() { if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi; }
+
+v1="$(cat "$DIR/V1")"
+archive="$DIR/v1/release-$v1.tar.gz"
+sig="$archive.sig"
+pub="$DIR/keys/vendor.pub"
+base="$(awk -F= '/^ARG BASE_IMAGE=/ {print $2; exit}' "$ONPREM/Dockerfile")"
+tag="openadapt-flow-onprem-test:$v1"
+project="oa-onprem-test-$$"
+compose=(docker compose -p "$project" -f "$ONPREM/docker-compose.yml")
+work="$(mktemp -d "${TMPDIR:-/tmp}/oa-test-container.XXXXXX")"
+store="$work/storage"
+mkdir -p "$store"
+
+cleanup() {
+ OPENADAPT_FLOW_IMAGE="$tag" OPENADAPT_ONPREM_STORAGE="$store" \
+ "${compose[@]}" down --remove-orphans >/dev/null 2>&1 || true
+ # The container writes the storage root as root; empty it from inside.
+ docker run --rm --network none -v "$store:/s" "$tag" \
+ sh -c 'rm -rf /s/* /s/.[!.]*' >/dev/null 2>&1 || true
+ docker image rm -f "$tag" "$tag-tampered" "$tag-missing" >/dev/null 2>&1 || true
+ rm -rf "$work"
+}
+trap cleanup EXIT
+
+echo "== pulling the pinned base image (connected; the build itself has no network) =="
+docker pull -q "$base" >/dev/null || { echo "test-container.sh: cannot pull $base" >&2; exit 1; }
+
+echo "== build-image.sh refuses a tampered archive =="
+cp "$archive" "$work/tampered.tar.gz"
+printf 'x' >> "$work/tampered.tar.gz"
+if bash "$HERE/build-image.sh" --archive "$work/tampered.tar.gz" --signature "$sig" \
+ --pubkey "$pub" --sig-tool openssl --tag "$tag-tampered" >"$work/tampered.log" 2>&1; then
+ bad "tampered archive refused"
+else
+ ok "tampered archive refused"
+fi
+if docker image inspect "$tag-tampered" >/dev/null 2>&1; then
+ bad "no image built from the tampered archive"
+else
+ ok "no image built from the tampered archive"
+fi
+
+echo "== the image build has no network: a release missing one wheel fails =="
+mkdir -p "$work/missing/on-prem"
+tar xzf "$archive" -C "$work/missing"
+victim="$(find "$work/missing/release/wheels" -name 'presidio_analyzer-*.whl' -print | head -n 1)"
+if [[ -z "$victim" ]]; then
+ bad "release holds the presidio_analyzer wheel to remove"
+else
+ rm -f "$victim"
+ tar -C "$ONPREM" -cf - . | tar -C "$work/missing/on-prem" -xf -
+ if docker build --network none --pull=false -q -f "$work/missing/on-prem/Dockerfile" \
+ -t "$tag-missing" "$work/missing" >"$work/missing.log" 2>&1; then
+ bad "build without $(basename "$victim") fails (it reached a package index?)"
+ else
+ if grep -q "presidio" "$work/missing.log"; then
+ ok "build without $(basename "$victim") fails on that package"
+ else
+ bad "build without $(basename "$victim") failed for another reason"
+ tail -n 20 "$work/missing.log"
+ fi
+ fi
+fi
+
+echo "== build the signed release =="
+if bash "$HERE/build-image.sh" --archive "$archive" --signature "$sig" --pubkey "$pub" \
+ --sig-tool openssl --tag "$tag" >"$work/build.log" 2>&1; then
+ ok "signed release $v1 builds with no network"
+else
+ bad "signed release $v1 builds with no network"
+ tail -n 40 "$work/build.log"
+ echo "== result: $PASS passed, $FAIL failed =="
+ exit 1
+fi
+
+run() { docker run --rm --network none "$@"; }
+
+check_eq "image: engine CLI reports $v1" "openadapt-flow $v1" \
+ "$(run "$tag" openadapt-flow --version 2>&1)"
+check_eq "image: engine and privacy extra import from the image venv, version $v1" "$v1" \
+ "$(run -w / "$tag" python -I -c '
+import importlib.metadata, os, sys
+import openadapt_flow, openadapt_privacy, presidio_analyzer
+here = os.path.realpath(openadapt_flow.__file__)
+if not here.startswith("/opt/openadapt-flow/venv/"):
+ sys.exit("openadapt_flow imported from outside the image venv: " + here)
+if openadapt_flow.__version__ != importlib.metadata.version("openadapt-flow"):
+ sys.exit("module and distribution versions differ")
+print(openadapt_flow.__version__)
+' 2>&1)"
+check_eq "image: VERSION file" "$v1" "$(run "$tag" cat /opt/openadapt-flow/VERSION 2>&1)"
+check_eq "image: default command is the absolute run-queue path" \
+ '["/opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh","watch"]' \
+ "$(docker image inspect -f '{{json .Config.Cmd}}' "$tag")"
+
+echo "== run-queue.sh in the image, on a bind-mounted storage root =="
+mkdir -p "$store/jobs/inbox"
+printf 'params=none\n' > "$store/jobs/inbox/no-bundle.job"
+qenv=(-e OPENADAPT_ONPREM_ROOT=/srv/openadapt
+ -e OPENADAPT_ONPREM_AUDIT_LOG=/srv/openadapt/audit/audit.log
+ -v "$store:/srv/openadapt")
+if run "${qenv[@]}" "$tag" /opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh once \
+ >"$work/queue.log" 2>&1; then
+ ok "run-queue.sh once exits 0"
+else
+ bad "run-queue.sh once exits 0"; cat "$work/queue.log"
+fi
+if [[ -f "$store/jobs/failed/no-bundle.job" ]]; then
+ ok "malformed job filed under failed/"
+else
+ bad "malformed job filed under failed/"
+fi
+if grep -q "no-bundle" "$store/audit/audit.log" 2>/dev/null; then
+ ok "audit log records the job"
+else
+ bad "audit log records the job"
+fi
+if run "${qenv[@]}" -e OPENADAPT_FLOW_SCRUB=off "$tag" \
+ /opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh once >/dev/null 2>&1; then
+ bad "run-queue.sh refuses to start with scrubbing off"
+else
+ ok "run-queue.sh refuses to start with scrubbing off"
+fi
+
+echo "== docker-compose.yml =="
+if OPENADAPT_ONPREM_STORAGE="$store" "${compose[@]}" config >/dev/null 2>&1; then
+ bad "compose refuses to run without OPENADAPT_FLOW_IMAGE"
+else
+ ok "compose refuses to run without OPENADAPT_FLOW_IMAGE"
+fi
+export OPENADAPT_FLOW_IMAGE="$tag" OPENADAPT_ONPREM_STORAGE="$store"
+cfg="$("${compose[@]}" config --format json 2>"$work/config.err")"
+check_eq "compose: runner never pulls, publishes no ports, network internal" \
+ "never|none|True" \
+ "$(printf '%s' "$cfg" | python3 -c '
+import json, sys
+c = json.load(sys.stdin)
+r = c["services"]["runner"]
+nets = [c["networks"][n].get("internal", False) for n in r["networks"]]
+print("%s|%s|%s" % (r.get("pull_policy"), r.get("ports") or "none", all(nets) and bool(nets)))
+' 2>&1)"
+
+probe='import socket; socket.create_connection(("1.1.1.1", 443), timeout=5).close()'
+if docker run --rm "$tag" python -c "$probe" >/dev/null 2>&1; then
+ ok "control: the probe connects from this image on Docker's default network"
+ if "${compose[@]}" run --rm -T --no-deps runner python -c "$probe" >"$work/probe.log" 2>&1; then
+ bad "compose runner cannot connect to the internet"
+ else
+ ok "compose runner cannot connect to the internet"
+ fi
+else
+ bad "control: the probe connects from this image on Docker's default network (cannot tell a blocked network from no network)"
+fi
+
+"${compose[@]}" up -d runner >"$work/up.log" 2>&1 || { bad "compose up runner"; cat "$work/up.log"; }
+sleep 5
+state="$(docker inspect -f '{{.State.Status}}' "$("${compose[@]}" ps -q runner)" 2>/dev/null)"
+check_eq "compose: runner keeps watching the queue" "running" "$state"
+if "${compose[@]}" logs runner 2>&1 | grep -q "watching /srv/openadapt/jobs/inbox"; then
+ ok "compose: runner logs that it is watching the inbox"
+else
+ bad "compose: runner logs that it is watching the inbox"; "${compose[@]}" logs runner
+fi
+
+echo "== result: $PASS passed, $FAIL failed =="
+[[ "$FAIL" -eq 0 ]]
diff --git a/packages/flow/deploy/on-prem/docker-compose.yml b/packages/flow/deploy/on-prem/docker-compose.yml
index 850f158b9f..9da3be2e5b 100644
--- a/packages/flow/deploy/on-prem/docker-compose.yml
+++ b/packages/flow/deploy/on-prem/docker-compose.yml
@@ -8,28 +8,31 @@
# * the `clinic-lan` network is `internal: true` -> Docker installs no default
# gateway, so containers on it CANNOT reach the internet (only each other).
# * no ports are published to the host by the runner (nothing inbound either).
-# * the image is built from a LOCAL wheelhouse (see Dockerfile) — no PyPI.
+# * the image is built with no network from a signed release archive
+# (bin/build-image.sh, see Dockerfile) and never pulled: pull_policy never.
#
# What is REAL vs STUB:
# [REAL] the compose topology, internal network, volumes, fail-closed env.
# [REAL] `runner` runs the shipped `openadapt-flow run` via bin/run-queue.sh.
-# [STUB] the images must be BUILT OFFLINE first (Dockerfile needs a staged
-# ./wheels wheelhouse); this file does not fetch anything.
+# [REAL] the image: build it first with bin/build-image.sh (or `docker load`
+# one built elsewhere) and name it in OPENADAPT_FLOW_IMAGE. CI builds
+# it with no network and runs it on this file's internal network
+# (bin/test-container.sh). This file does not build or fetch anything.
# [STUB] `vlm` profile needs a real GPU box + a locally-staged model; the
# appliance is opt-in and LAN-only even here.
#
-# Validate without building: docker compose -f docker-compose.yml config
+# Every command needs the image name, e.g.
+# export OPENADAPT_FLOW_IMAGE=openadapt-flow-onprem:
+# Validate: docker compose -f docker-compose.yml config
# Bring up the runner: docker compose up -d runner
# With the VLM appliance: docker compose --profile vlm up -d
services:
runner:
- build:
- # Repo root is the build context so the engine source is available to the
- # offline wheelhouse install. See ./Dockerfile.
- context: ../..
- dockerfile: deploy/on-prem/Dockerfile
- image: openadapt-flow-onprem:local
+ # Built by bin/build-image.sh from a signed release. No default: running
+ # an unnamed or stale version is worse than refusing to start.
+ image: ${OPENADAPT_FLOW_IMAGE:?set OPENADAPT_FLOW_IMAGE to the image bin/build-image.sh built}
+ pull_policy: never
restart: unless-stopped
# Fail-closed PHI scrubbing + local storage/audit wiring. NO cloud keys.
environment:
@@ -45,7 +48,7 @@ services:
- type: bind
source: ${OPENADAPT_ONPREM_STORAGE:-./storage}
target: /srv/openadapt
- command: ["bin/run-queue.sh", "watch"]
+ command: ["/opt/openadapt-flow/deploy/on-prem/bin/run-queue.sh", "watch"]
networks:
- clinic-lan
# No `ports:` — nothing is published to the host or beyond.
@@ -55,10 +58,8 @@ services:
# docs/deployment/ON_PREM_VLM.md. Enabled only with `--profile vlm`.
vlm-appliance:
profiles: ["vlm"]
- build:
- context: ../..
- dockerfile: deploy/on-prem/Dockerfile
- image: openadapt-flow-onprem:local
+ image: ${OPENADAPT_FLOW_IMAGE:?set OPENADAPT_FLOW_IMAGE to the image bin/build-image.sh built}
+ pull_policy: never
restart: unless-stopped
environment:
VLM_BACKEND: "vllm"
diff --git a/packages/flow/docs/ON_PREM.md b/packages/flow/docs/ON_PREM.md
index fc909f21d5..cb4d2db6f6 100644
--- a/packages/flow/docs/ON_PREM.md
+++ b/packages/flow/docs/ON_PREM.md
@@ -189,8 +189,10 @@ sudo systemctl enable --now openadapt-flow-runner.path
OPENADAPT_FLOW_SCRUB=on ./bin/verify-airgap.sh --config onprem.yaml --probe
```
-Containers instead of systemd: `docker compose -f docker-compose.yml config`
-(validate), then `docker compose up -d runner`. See
+Containers instead of systemd: build the image from a signed release with
+`bin/build-image.sh` (no network; `docker save` / `docker load` it across),
+set `OPENADAPT_FLOW_IMAGE` to its tag, then `docker compose -f
+docker-compose.yml config` (validate) and `docker compose up -d runner`. See
[`deploy/on-prem/README.md`](../deploy/on-prem/README.md) and
[`deploy/on-prem/UPDATE.md`](../deploy/on-prem/UPDATE.md) for the runnable
package and update/rollback runbook, and
From 7770839e32569ff40e1774672b661609a3813b6b Mon Sep 17 00:00:00 2001
From: abrichr
Date: Wed, 4 Nov 2026 19:15:34 -0500
Subject: [PATCH 05/21] on-prem locks: take ownership with an exclusive file
create, not mkdir(1)
Ubuntu 26.04 ships uutils mkdir, which reports success to both callers when
two processes race to create one directory. The audit-log writer lock and the
release update lock both treated a zero exit from mkdir as ownership, so on
that image concurrent audit writers chained to the same parent (the
"concurrent audit writers preserve one hash chain" CI failure) and a second
update could proceed beside a live one.
Ownership is now the shell's own noclobber create of the pid file inside the
lock directory (O_CREAT|O_EXCL), and the stale-lock breaker lock is an
exclusive file too. Tests model the uutils behavior with a mkdir shim that
never reports EEXIST; on the previous code they fail.
---
packages/flow/deploy/on-prem/bin/audit-log.sh | 44 ++++++++++++-------
.../flow/deploy/on-prem/bin/lib-release.sh | 41 +++++++++++------
.../flow/deploy/on-prem/bin/test-update.sh | 37 ++++++++++++++++
3 files changed, 92 insertions(+), 30 deletions(-)
diff --git a/packages/flow/deploy/on-prem/bin/audit-log.sh b/packages/flow/deploy/on-prem/bin/audit-log.sh
index 9b51ce4e4a..a963a59a01 100755
--- a/packages/flow/deploy/on-prem/bin/audit-log.sh
+++ b/packages/flow/deploy/on-prem/bin/audit-log.sh
@@ -68,35 +68,47 @@ chmod 0700 "$(dirname "$AUDIT_LOG")" 2>/dev/null || true
# Serialize the read-last-hash + append transaction. O_APPEND prevents byte
# interleaving, but without this lock two writers can both chain to the same
# parent and produce a structurally broken audit log.
+#
+# Ownership is the exclusive creation of the pid file, done by the shell itself
+# with noclobber (open(2) with O_CREAT|O_EXCL). The exit status of mkdir(1) is
+# not trusted for exclusion: the uutils mkdir that Ubuntu 26.04 ships reports
+# success to both callers when two processes race to create one directory.
lock_dir="${AUDIT_LOG}.lock"
-break_dir="${AUDIT_LOG}.lock-break"
+lock_file="$lock_dir/pid"
+break_file="${AUDIT_LOG}.lock-breaker"
lock_acquired=0
+# Create $1 holding this process's pid; fails if $1 already exists.
+create_exclusive() {
+ ( set -C; printf '%s\n' "$$" > "$1" ) 2>/dev/null
+}
+
# Remove a lock whose owner has exited. Between reading the owner and removing
# the lock, that owner can release it and another writer can take it, so the
-# removal happens only under a second lock that serializes breakers, and only
-# if the lock still names the same dead owner. Without this, a breaker could
-# delete a live writer's lock and let two writers chain to the same parent.
+# removal happens only under a second exclusive lock that serializes breakers,
+# and only if the lock still names the same dead owner. Without this, a breaker
+# could delete a live writer's lock and let two writers chain to the same parent.
break_stale_lock() {
local stale="$1" current=""
- mkdir "$break_dir" 2>/dev/null || return 0
- current="$(cat "$lock_dir/pid" 2>/dev/null || true)"
+ create_exclusive "$break_file" || return 0
+ current="$(cat "$lock_file" 2>/dev/null || true)"
if [[ "$current" == "$stale" ]] && ! kill -0 "$stale" 2>/dev/null; then
- rm -f "$lock_dir/pid" 2>/dev/null || true
- rmdir "$lock_dir" 2>/dev/null || true
+ rm -f "$lock_file" 2>/dev/null || true
fi
- rmdir "$break_dir" 2>/dev/null || true
+ rm -f "$break_file" 2>/dev/null || true
}
for ((attempt = 0; attempt < 200; attempt++)); do
- if mkdir "$lock_dir" 2>/dev/null; then
- printf '%s\n' "$$" > "$lock_dir/pid"
- lock_acquired=1
- break
+ if [[ ! -L "$lock_dir" ]]; then
+ mkdir -p "$lock_dir" 2>/dev/null || true
+ if [[ -d "$lock_dir" && ! -L "$lock_dir" ]] && create_exclusive "$lock_file"; then
+ lock_acquired=1
+ break
+ fi
fi
owner=""
- if [[ -d "$lock_dir" && ! -L "$lock_dir" && -r "$lock_dir/pid" ]]; then
- owner="$(cat "$lock_dir/pid" 2>/dev/null || true)"
+ if [[ -d "$lock_dir" && ! -L "$lock_dir" && -f "$lock_file" && ! -L "$lock_file" ]]; then
+ owner="$(cat "$lock_file" 2>/dev/null || true)"
fi
if [[ -n "$owner" && "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then
break_stale_lock "$owner"
@@ -109,7 +121,7 @@ if [[ "$lock_acquired" -ne 1 ]]; then
exit 1
fi
release_audit_lock() {
- rm -f "$lock_dir/pid" 2>/dev/null || true
+ rm -f "$lock_file" 2>/dev/null || true
rmdir "$lock_dir" 2>/dev/null || true
}
trap release_audit_lock EXIT
diff --git a/packages/flow/deploy/on-prem/bin/lib-release.sh b/packages/flow/deploy/on-prem/bin/lib-release.sh
index 7676dc4900..295dd5c38b 100755
--- a/packages/flow/deploy/on-prem/bin/lib-release.sh
+++ b/packages/flow/deploy/on-prem/bin/lib-release.sh
@@ -344,32 +344,45 @@ rel_validate_release_dir() {
&& -x "$release_dir/venv/bin/openadapt-flow" ]]
}
+# Create $1 holding this process's pid; fails if $1 already exists. The shell
+# opens it with noclobber (O_CREAT|O_EXCL), so exclusion does not depend on
+# mkdir(1): the uutils mkdir that Ubuntu 26.04 ships reports success to both
+# callers when two processes race to create one directory.
+_rel_create_exclusive() {
+ ( set -C; printf '%s\n' "$$" > "$1" ) 2>/dev/null
+}
+
_rel_lock_acquire() {
- local root="$1" owner=""
+ local root="$1" owner="" current=""
local lock="$root/releases/.update.lock"
+ local breaker="$root/releases/.update.lock-breaker"
mkdir -p "$root/releases" || return 1
- if mkdir "$lock" 2>/dev/null; then
- printf '%s\n' "$$" > "$lock/pid" || {
- rmdir "$lock" 2>/dev/null || true
- return 1
- }
+ if [[ -L "$lock" ]]; then
+ _rel_err "release lock is a symlink; refusing: $lock"
+ return 1
+ fi
+ mkdir -p "$lock" 2>/dev/null || true
+ if [[ -d "$lock" && ! -L "$lock" ]] && _rel_create_exclusive "$lock/pid"; then
REL_LOCK_DIR="$lock"
return 0
fi
- if [[ -d "$lock" && ! -L "$lock" && -r "$lock/pid" ]]; then
+ if [[ -d "$lock" && ! -L "$lock" && -f "$lock/pid" && ! -L "$lock/pid" ]]; then
owner="$(cat "$lock/pid" 2>/dev/null || true)"
fi
if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then
_rel_err "another update or rollback is active (pid $owner)"
return 1
fi
- if [[ -d "$lock" && ! -L "$lock" ]]; then
- rm -f "$lock/pid" 2>/dev/null || true
- if rmdir "$lock" 2>/dev/null && mkdir "$lock" 2>/dev/null; then
- printf '%s\n' "$$" > "$lock/pid" || {
- rmdir "$lock" 2>/dev/null || true
- return 1
- }
+ # The owner has exited (or left an unreadable pid). Remove its lock only
+ # under the breaker lock and only if it still names that owner, so a
+ # concurrent breaker cannot remove a lock another process has just taken.
+ if [[ -n "$owner" ]] && _rel_create_exclusive "$breaker"; then
+ current="$(cat "$lock/pid" 2>/dev/null || true)"
+ if [[ "$current" == "$owner" ]]; then
+ rm -f "$lock/pid" 2>/dev/null || true
+ fi
+ rm -f "$breaker" 2>/dev/null || true
+ if [[ -d "$lock" && ! -L "$lock" ]] && _rel_create_exclusive "$lock/pid"; then
REL_LOCK_DIR="$lock"
return 0
fi
diff --git a/packages/flow/deploy/on-prem/bin/test-update.sh b/packages/flow/deploy/on-prem/bin/test-update.sh
index 26be744811..cb16fdecba 100755
--- a/packages/flow/deploy/on-prem/bin/test-update.sh
+++ b/packages/flow/deploy/on-prem/bin/test-update.sh
@@ -244,6 +244,28 @@ assert "concurrent update lock is refused" test "$?" -ne 0
rm -f "$ROOT/releases/.update.lock/pid"
rmdir "$ROOT/releases/.update.lock"
+# Ubuntu 26.04 ships uutils mkdir, which reports success to both callers when
+# two processes race to create one directory. Model that with a mkdir shim that
+# never reports EEXIST: the locks must still exclude, so they cannot rest on
+# the exit status of mkdir(1).
+EEXIST_SHIM="$WORK/eexist-shim"
+mkdir -p "$EEXIST_SHIM"
+REAL_MKDIR="$(command -v mkdir)"
+cat > "$EEXIST_SHIM/mkdir" </dev/null || true
+exit 0
+SHIM_EOF
+chmod +x "$EEXIST_SHIM/mkdir"
+mkdir "$ROOT/releases/.update.lock"
+printf '%s\n' "$$" > "$ROOT/releases/.update.lock/pid"
+PATH="$EEXIST_SHIM:$PATH" apply_release "$ROOT" "$ARC6" >/dev/null 2>&1
+assert "update lock holds when mkdir(1) never reports EEXIST" test "$?" -ne 0
+assert_eq "a refused update leaves the live owner's lock" "$$" \
+ "$(cat "$ROOT/releases/.update.lock/pid" 2>/dev/null || echo none)"
+rm -f "$ROOT/releases/.update.lock/pid"
+rmdir "$ROOT/releases/.update.lock"
+
chmod -x "$ROOT/releases/2.0.0/venv/bin/openadapt-flow"
rel_do_rollback "$ROOT" "" >/dev/null 2>&1
assert "incomplete rollback target is refused" test "$?" -ne 0
@@ -293,6 +315,21 @@ else
printf '%s\n' "$audit_output" | sed 's/^/ /'
fi
+SHIM_LOG="$WORK/eexist-audit/audit.log"
+for index in 1 2 3 4 5 6 7 8 9 10 11 12; do
+ OPENADAPT_ONPREM_AUDIT_LOG="$SHIM_LOG" PATH="$EEXIST_SHIM:$PATH" \
+ bash "$OPENADAPT_ONPREM_AUDIT_BIN" started "shim-$index" --note test >/dev/null &
+done
+wait
+audit_output="$(OPENADAPT_ONPREM_AUDIT_LOG="$SHIM_LOG" \
+ bash "$HERE/verify-airgap.sh" --config "$WORK/missing.yaml" --audit 2>&1 || true)"
+if grep -q 'audit chain intact across 12 record' <<< "$audit_output"; then
+ pass "audit writers stay serialized when mkdir(1) never reports EEXIST"
+else
+ fail "audit writers stay serialized when mkdir(1) never reports EEXIST"
+ printf '%s\n' "$audit_output" | sed 's/^/ /'
+fi
+
# A writer that finds a lock owned by an exited process may break it. Force the
# race where, between that writer reading the dead owner and breaking the
# lock, the owner releases and a live writer takes the lock: a shimmed cat
From d6db42e0a02fdb8dd08015c2d560ba26d2cdf873 Mon Sep 17 00:00:00 2001
From: abrichr
Date: Wed, 4 Nov 2026 19:24:05 -0500
Subject: [PATCH 06/21] on-prem container test: keep pip's error in the
missing-wheel log; read the root-owned audit log from inside the image
---
packages/flow/deploy/on-prem/bin/test-container.sh | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/packages/flow/deploy/on-prem/bin/test-container.sh b/packages/flow/deploy/on-prem/bin/test-container.sh
index 2abf6090bc..cc4a07c4a3 100755
--- a/packages/flow/deploy/on-prem/bin/test-container.sh
+++ b/packages/flow/deploy/on-prem/bin/test-container.sh
@@ -92,7 +92,9 @@ if [[ -z "$victim" ]]; then
else
rm -f "$victim"
tar -C "$ONPREM" -cf - . | tar -C "$work/missing/on-prem" -xf -
- if docker build --network none --pull=false -q -f "$work/missing/on-prem/Dockerfile" \
+ # --progress=plain keeps pip's own error in the log; -q would drop it.
+ if docker build --network none --pull=false --progress=plain \
+ -f "$work/missing/on-prem/Dockerfile" \
-t "$tag-missing" "$work/missing" >"$work/missing.log" 2>&1; then
bad "build without $(basename "$victim") fails (it reached a package index?)"
else
@@ -153,7 +155,10 @@ if [[ -f "$store/jobs/failed/no-bundle.job" ]]; then
else
bad "malformed job filed under failed/"
fi
-if grep -q "no-bundle" "$store/audit/audit.log" 2>/dev/null; then
+# The runner is root in the image and audit-log.sh makes the audit directory
+# 0700, so read the log the way the deployment does: from inside the image.
+if run -v "$store:/srv/openadapt" "$tag" cat /srv/openadapt/audit/audit.log 2>/dev/null \
+ | grep -q '"job":"no-bundle"'; then
ok "audit log records the job"
else
bad "audit log records the job"
From acf95a41dee4eaee50788dfe7c2f413ef9d8b4ae Mon Sep 17 00:00:00 2001
From: Richard Abrich
Date: Wed, 4 Nov 2026 19:07:32 -0800
Subject: [PATCH 07/21] fix(flow): name shared rows by task values and keep
changed columns in anchor scoring (#1852)
Part of #1675.
---
packages/flow/docs/GENERATE_CONTRACTS.md | 9 +-
.../openadapt_flow/generate/demo_analysis.py | 30 +--
.../generate/verify_contract.py | 55 +++---
...est_generate_verify_contract_attacks_r2.py | 46 +++++
...st_generate_verify_contract_false_stops.py | 182 ++++++++++++++++++
5 files changed, 279 insertions(+), 43 deletions(-)
create mode 100644 packages/flow/tests/test_generate_verify_contract_false_stops.py
diff --git a/packages/flow/docs/GENERATE_CONTRACTS.md b/packages/flow/docs/GENERATE_CONTRACTS.md
index 34f5a331d2..719f38a1af 100644
--- a/packages/flow/docs/GENERATE_CONTRACTS.md
+++ b/packages/flow/docs/GENERATE_CONTRACTS.md
@@ -292,9 +292,12 @@ is a log from its name.
**Changes the app makes to existing rows.** A column of an existing row that
the demonstrations changed with no task value is excused only on that row:
-the target itself, a row with the same key in every demonstration (a counter
-row), or the row the task's values name through a key of the main record
-(the account a contact belongs to). It is never excused by name on other
+the target itself, the row the task's values name through a key of the main
+record (the account a contact belongs to), or a row with the same key in
+every demonstration (a counter row). A row the task's values name is named
+by them even when every demonstration happened to touch the same one (both
+demonstrations were in one project), so a correct write in another project
+is not stopped. It is never excused by name on other
rows or tables. Time stamps set during the write, counters that went up and
columns set to the acting user are proposed; anything else is listed and
excused only if the person adds it (`columns_add`). A changed row the
diff --git a/packages/flow/openadapt_flow/generate/demo_analysis.py b/packages/flow/openadapt_flow/generate/demo_analysis.py
index f09d7e26bb..cbf69ba1f1 100644
--- a/packages/flow/openadapt_flow/generate/demo_analysis.py
+++ b/packages/flow/openadapt_flow/generate/demo_analysis.py
@@ -551,22 +551,28 @@ def pick_anchor(
v = demo.values.get(p)
if v is None or isinstance(v, (list, dict)):
continue
- if mode == "update":
- hit = locate(schema, demo, ch, v, before=True, max_hops=1) or locate(
- schema, demo, ch, v, changed_only=True, max_hops=1
- )
- else:
- hit = locate(schema, demo, ch, v, max_hops=1)
+
# Only the row's own columns, and keys named after the table they
# point to: a row the write added beside the main record (an
# issue's journal) reaches the record's values only through a
# link back, and a key equal to some row's id by chance proves
- # nothing.
- hit = [
- loc
- for loc in hit
- if all(h.kind == "fk" and _hop_cost(h) <= 1 for h in loc.hops)
- ]
+ # nothing. Filtered before an update falls back to its changed
+ # columns: a key equal to some row's id by chance (a project id
+ # that is also an issue id) must not hide the value the write
+ # put in the row itself.
+ def kept(locs: list[Location]) -> list[Location]:
+ return [
+ loc
+ for loc in locs
+ if all(h.kind == "fk" and _hop_cost(h) <= 1 for h in loc.hops)
+ ]
+
+ if mode == "update":
+ hit = kept(
+ locate(schema, demo, ch, v, before=True, max_hops=1)
+ ) or kept(locate(schema, demo, ch, v, changed_only=True, max_hops=1))
+ else:
+ hit = kept(locate(schema, demo, ch, v, max_hops=1))
score += 1 if hit else 0
direct += 1 if any(not loc.hops for loc in hit) else 0
if (score, direct) > (best_score, best_direct):
diff --git a/packages/flow/openadapt_flow/generate/verify_contract.py b/packages/flow/openadapt_flow/generate/verify_contract.py
index 66db61b83a..d3fb1b7334 100644
--- a/packages/flow/openadapt_flow/generate/verify_contract.py
+++ b/packages/flow/openadapt_flow/generate/verify_contract.py
@@ -1217,42 +1217,41 @@ def _touched_rows(
demos_with = {id(d) for d, _, _ in items}
selector: Optional[dict[str, Any]] = None
how = ""
- if (
+ # Reached from the main record by a key column whose target row holds
+ # located values in single columns (the task's project). Tried first:
+ # when every demonstration happened to touch the same such row, its
+ # literal key would excuse that one row for every write, and a
+ # correct write naming another row would stop.
+ for p, loc in plan.locations.items():
+ if not loc.hops or loc.hops[0].kind != "fk" or loc.hops[0].table != table:
+ continue
+ hop = loc.hops[0]
+ if all(
+ (a.after or {}).get(hop.column) == (ch.after or {}).get(pk)
+ for _, a, ch in items
+ ):
+ sel = {
+ x.columns[0]: {"param": q}
+ for q, x in plan.locations.items()
+ if len(x.hops) == 1
+ and x.hops[0] == hop
+ and len(x.columns) == 1
+ and x.rule == "exact"
+ }
+ if sel:
+ selector = sel
+ how = f"the {table} row the task's {', '.join(sorted(v['param'] for v in sel.values()))} name"
+ break
+ if selector is None and (
len(keys) == 1
and len(demos_with) == len(plan.demos)
and len(items) == len(plan.demos)
):
+ # The same row (by key) in every demonstration: a counter row.
key = (items[0][2].after or {}).get(pk)
if key is not None:
selector = {pk: {"literal": key}}
how = f"{table} row {pk}={key}"
- if selector is None:
- # Reached from the main record by a key column whose target row
- # holds located values in single columns.
- for p, loc in plan.locations.items():
- if (
- not loc.hops
- or loc.hops[0].kind != "fk"
- or loc.hops[0].table != table
- ):
- continue
- hop = loc.hops[0]
- if all(
- (a.after or {}).get(hop.column) == (ch.after or {}).get(pk)
- for _, a, ch in items
- ):
- sel = {
- x.columns[0]: {"param": q}
- for q, x in plan.locations.items()
- if len(x.hops) == 1
- and x.hops[0] == hop
- and len(x.columns) == 1
- and x.rule == "exact"
- }
- if sel:
- selector = sel
- how = f"the {table} row the task's {', '.join(sorted(v['param'] for v in sel.values()))} name"
- break
if selector is None or len(items) != len({id(d) for d, _, _ in items}):
plan.unexcused.setdefault(table, set()).update(cols)
continue
diff --git a/packages/flow/tests/test_generate_verify_contract_attacks_r2.py b/packages/flow/tests/test_generate_verify_contract_attacks_r2.py
index ee0590f66d..1b66789bca 100644
--- a/packages/flow/tests/test_generate_verify_contract_attacks_r2.py
+++ b/packages/flow/tests/test_generate_verify_contract_attacks_r2.py
@@ -429,3 +429,49 @@ def check(role: str) -> list[str]:
assert any("TRIGGER on table public.t" in p for p in ro2), ro2
assert any("CREATE on schema public" in p for p in ro2), ro2
assert check("ro3") == []
+
+
+# -- #1675 item 1: a row every demonstration happened to share -----------------
+
+
+def test_a_shared_parent_row_is_named_by_the_task_not_by_its_key(
+ gv: dict[str, Any],
+) -> None:
+ """Both set_status demonstrations were in Help Desk, so they touched the
+ same project row. The excuse names the row the task's project names,
+ not that row's literal key."""
+ rows = gv["report"]["kinds"]["set_status"]["touched_rows"]
+ projects = [r for r in rows if r["table"] == "projects"]
+ assert projects and all(not r["by_key"] for r in projects), rows
+ assert all("literal" not in s for r in projects for s in r["selector"].values()), (
+ rows
+ )
+
+
+def test_correct_status_update_in_another_project_confirmed(
+ gv: dict[str, Any],
+) -> None:
+ app = gv["app"]
+ iid = app.create_issue(
+ {"project": "Billing Portal", "subject": "S1 other", "status": "New"}
+ )
+ v = {"project": "Billing Portal", "subject": "S1 other", "status": "Resolved"}
+ out = _run(gv, "set_status", v, lambda: app.save(iid, {"status": "Resolved"}))
+ assert out.startswith("CONFIRMED"), out
+
+
+def test_status_update_that_touches_another_project_stops(
+ gv: dict[str, Any],
+) -> None:
+ """The excuse is for the task's project only: the same bookkeeping on
+ a project the task doesn't name is collateral."""
+ app = gv["app"]
+ iid = _fresh(app, "S2 other")
+ v = {"project": "Help Desk", "subject": "S2 other", "status": "Resolved"}
+
+ def write() -> None:
+ app.save(iid, {"status": "Resolved"})
+ app.q("UPDATE projects SET updated_on=now() WHERE name='Billing Portal'")
+
+ out = _run(gv, "set_status", v, write)
+ assert not out.startswith("CONFIRMED"), out
diff --git a/packages/flow/tests/test_generate_verify_contract_false_stops.py b/packages/flow/tests/test_generate_verify_contract_false_stops.py
new file mode 100644
index 0000000000..105941677b
--- /dev/null
+++ b/packages/flow/tests/test_generate_verify_contract_false_stops.py
@@ -0,0 +1,182 @@
+# ruff: noqa: F811 (pytest fixtures imported from the generator test, then used as arguments)
+"""False stops of generated contracts left open by the #1672 reviews (#1675).
+
+The round-2 tracker (one audit row per changed field, no version column) on
+a throwaway PostgreSQL, with a kind that changes one or two fields per
+demonstration. Correct writes must be CONFIRMED; the wrong writes beside
+them must still stop. Records are made up.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from typing import Any
+
+import pytest
+import yaml
+
+psycopg2 = pytest.importorskip("psycopg2")
+
+from openadapt_flow.generate import verify_contract as gen # noqa: E402
+from tests.test_generate_verify_contract import BOT, _run, pg # noqa: E402,F401
+from tests.test_generate_verify_contract_attacks_r2 import ( # noqa: E402
+ KINDS_V,
+ SCHEMA_V,
+ AppV,
+)
+
+KINDS_P = {
+ "kinds": [
+ *KINDS_V["kinds"],
+ {"kind": "edit_plan", "parameters": ["project", "subject", "done_ratio", "due_date"],
+ "examples": [
+ {"values": {"project": "Billing Portal", "subject": "Refund button missing",
+ "done_ratio": 30, "due_date": "2026-11-30"}},
+ {"values": {"project": "Help Desk", "subject": "Printer jams", "done_ratio": 50,
+ "due_date": None}},
+ ]},
+ ]
+} # fmt: skip
+
+
+@pytest.fixture(scope="module")
+def gp(pg: Any) -> dict[str, Any]:
+ root, port = pg["root"], pg["port"]
+ admin = psycopg2.connect(
+ host="127.0.0.1", port=port, user="admin", dbname="postgres"
+ )
+ admin.autocommit = True
+ admin.cursor().execute("CREATE DATABASE trackerp")
+ admin.close()
+ app = AppV(port, "trackerp")
+ with app.con() as c, c.cursor() as cur:
+ cur.execute(
+ "DO $$BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='oa_reader') "
+ "THEN CREATE ROLE oa_reader LOGIN PASSWORD 'reader-pw'; END IF; END$$"
+ )
+ cur.execute(SCHEMA_V)
+ os.environ["OA_GEN_TEST_PW"] = "reader-pw"
+ work = root / "workp"
+ work.mkdir()
+ read = {"sql_driver": "psycopg2",
+ "sql_connect_args": {"host": "127.0.0.1", "port": port, "user": "oa_reader", "dbname": "trackerp"},
+ "sql_password_env": "OA_GEN_TEST_PW"} # fmt: skip
+ (work / "read.yaml").write_text(yaml.safe_dump(read))
+ (work / "kinds.yaml").write_text(yaml.safe_dump(KINDS_P, allow_unicode=True))
+ common = ["--read", str(work / "read.yaml")]
+
+ def demo(kind: str, values: dict[str, Any], write: Any) -> None:
+ assert gen.main(["snapshot", *common, "--out", str(work / "before.json")]) == 0
+ write()
+ assert gen.main(["record", *common, "--before", str(work / "before.json"), "--kind", kind,
+ "--values", json.dumps(values), "--demos", str(work / "demos")]) == 0 # fmt: skip
+
+ for k in KINDS_P["kinds"]:
+ for ex in k["examples"]:
+ v = ex["values"]
+ if k["kind"] == "create_issue":
+ demo("create_issue", v, lambda v=v: app.create_issue(v))
+ else:
+ sets = {p: v[p] for p in k["parameters"][2:] if v.get(p) is not None}
+ demo(
+ k["kind"],
+ v,
+ lambda v=v, s=sets: app.save(
+ app.find(v["project"], v["subject"]), s
+ ),
+ )
+ argv = ["propose", *common, "--kinds", str(work / "kinds.yaml"), "--demos", str(work / "demos"),
+ "--out", str(work / "out"), "--actor-param", f"bot_user_id={BOT}"] # fmt: skip
+ assert gen.main(argv) == 0
+ report = json.loads((work / "out" / "report.json").read_text())
+ assert "audits" in report["kinds"]["edit_plan"]["log_candidates"], report
+ # The person's answers: the proposal, plus the audit table as a log (one
+ # row per changed field, so the count differs between demonstrations).
+ answers = {q["key"]: q["proposal"] for q in report["questions"]}
+ bk = answers["bookkeeping"]
+ bk = {"accept": True} if bk == "accept" else dict(bk)
+ bk["logs_add"] = ["audits"]
+ answers["bookkeeping"] = bk
+ (work / "answers.yaml").write_text(yaml.safe_dump(answers))
+ assert gen.main([*argv, "--answers", str(work / "answers.yaml")]) == 0
+ report = json.loads((work / "out" / "report.json").read_text())
+ return {"app": app, "out": work / "out", "report": report, "read": read}
+
+
+def _fresh(app: AppV, subject: str) -> int:
+ return app.create_issue(
+ {"project": "Billing Portal", "subject": subject, "status": "New"}
+ )
+
+
+# -- item 2: a kind whose demonstrations audited different numbers of fields ---
+
+
+def test_a_kind_auditing_one_or_two_fields_is_an_update(gp: dict[str, Any]) -> None:
+ """One demonstration changed two fields (two audit rows), the other one.
+ The issue is still the main record: a project id equal to an issue id
+ by chance doesn't hide the values the save put in the issue."""
+ k = gp["report"]["kinds"]["edit_plan"]
+ assert k["mode"] == "update" and not k["refused"], k
+ assert k["emitted"] > 0, k
+
+
+def test_correct_two_field_plan_edit_confirmed(gp: dict[str, Any]) -> None:
+ app = gp["app"]
+ iid = _fresh(app, "P1 two")
+ v = {"project": "Billing Portal", "subject": "P1 two", "done_ratio": 70,
+ "due_date": "2026-12-15"} # fmt: skip
+ out = _run(
+ gp,
+ "edit_plan",
+ v,
+ lambda: app.save(iid, {"done_ratio": 70, "due_date": "2026-12-15"}),
+ )
+ assert out.startswith("CONFIRMED"), out
+
+
+def test_correct_one_field_plan_edit_confirmed(gp: dict[str, Any]) -> None:
+ app = gp["app"]
+ iid = _fresh(app, "P2 one")
+ v = {"project": "Billing Portal", "subject": "P2 one", "done_ratio": 40,
+ "due_date": None} # fmt: skip
+ out = _run(gp, "edit_plan", v, lambda: app.save(iid, {"done_ratio": 40}))
+ assert out.startswith("CONFIRMED"), out
+
+
+def test_plan_edit_with_the_wrong_ratio_stops(gp: dict[str, Any]) -> None:
+ app = gp["app"]
+ iid = _fresh(app, "P3 wrong")
+ v = {"project": "Billing Portal", "subject": "P3 wrong", "done_ratio": 40,
+ "due_date": None} # fmt: skip
+ out = _run(gp, "edit_plan", v, lambda: app.save(iid, {"done_ratio": 60}))
+ assert not out.startswith("CONFIRMED"), out
+
+
+def test_plan_edit_that_also_changes_the_status_stops(gp: dict[str, Any]) -> None:
+ app = gp["app"]
+ iid = _fresh(app, "P4 extra")
+ v = {"project": "Billing Portal", "subject": "P4 extra", "done_ratio": 40,
+ "due_date": None} # fmt: skip
+ out = _run(
+ gp,
+ "edit_plan",
+ v,
+ lambda: app.save(iid, {"done_ratio": 40, "status": "Resolved"}),
+ )
+ assert not out.startswith("CONFIRMED"), out
+
+
+def test_plan_edit_that_loses_another_users_save_stops(gp: dict[str, Any]) -> None:
+ app = gp["app"]
+ iid = _fresh(app, "P5 lost")
+ v = {"project": "Billing Portal", "subject": "P5 lost", "done_ratio": 40,
+ "due_date": None} # fmt: skip
+
+ def write() -> None:
+ app.save(iid, {"status": "In Progress"}, user=2)
+ app.save(iid, {"done_ratio": 40, "status": "New"})
+
+ out = _run(gp, "edit_plan", v, write)
+ assert not out.startswith("CONFIRMED"), out
From 63b63a313c8aa4d08fb2efb076eef09b208aeed8 Mon Sep 17 00:00:00 2001
From: Richard Abrich
Date: Wed, 4 Nov 2026 20:03:21 -0800
Subject: [PATCH 08/21] identity: read a dialog target's band on the dialog
alone (OpenEMR duplicate dialog) (#1615)
* identity: read the band of a target inside a dialog on the dialog alone (#1347)
The recorder stores the box of the topmost dialog holding a clicked target
(PlaywrightBackend.dialog_region_at). The compiler reads that step's identity
band on the frame blanked outside the box and stores Anchor.dialog_region.
Replay blanks the live frame outside the live dialog the same way, and fails
the identity check closed when no dialog holds the resolved point.
Also: the throughput harness retries its form setup once after a navigation
race destroys the evaluate context (#1348).
* identity: address the dialog-scope review
Scope to modal dialogs only (dialog:modal, aria-modal, alertdialog, a shown
Bootstrap modal) and treat an opacity-0 dialog as gone. Require the box to
hold the probed point. Mask the extra identity looks' fresh frames with the
same dialog box; give the signal quorum the whole frame. Heal re-reads a dialog
target's band on the dialog and moves dialog_region with the point. The
render-scaled view maps the probe. The harness retry tolerates a frame that
detaches while it waits.
* identity: a dialog target's band is the whole dialog
The row of a dialog button often holds nothing but the other buttons, so on
OpenEMR's duplicate dialog the scoped row band was too short to arm identity
and clinical-write would refuse the save. Read every line of the dialog
instead (its result text and count included), at compile and replay, with no
row re-read, and skip the row census for a dialog target.
* throughput harness: --backend, to replay with the backend the bundle was recorded with
A bundle the demo's record command makes carries the OpenEMR adapter's form
field identity; replayed through the base Playwright backend, its first step
reads only the field label and halts.
* benchmark: identity at OpenEMR's duplicate dialog, before and after #1347
On the OCR band path the main bundle refused the confirm step at 8 of 8
form scroll positions behind the dialog; the dialog-scoped bundle verified at
all 8 and still refused a dialog that lists a possible duplicate. 26-item
worklists: 26/26 correct on both.
* Register the dialog identity measurement and add its capability row
* fix: preserve dialog row identity (untested WIP)
Signed-off-by: abrichr
* test: pin dialog row and mask refusal regressions
Signed-off-by: abrichr
* fix: keep dialog identity checks generic and typed
Signed-off-by: abrichr
* test: separate modal row text from button crops
Signed-off-by: abrichr
* fix: publish repair hold metadata atomically
Signed-off-by: abrichr
---------
Signed-off-by: abrichr
---
CAPABILITIES.md | 1 +
llms-full.txt | 1 +
.../dialog_identity_2026-10-28/README.md | 45 +++
.../batch-after-branch.json | 61 +++
.../batch-before-main.json | 61 +++
.../listed-duplicate.json | 16 +
.../scroll-ocr-tier.json | 58 +++
.../scroll-openemr-adapter.json | 58 +++
.../scroll_identity.py | 61 +++
.../benchmark/openemr_throughput/harness.py | 34 +-
.../backends/playwright_backend.py | 120 +++++-
.../flow/openadapt_flow/compiler/compile.py | 78 +++-
packages/flow/openadapt_flow/ir.py | 31 +-
packages/flow/openadapt_flow/recorder.py | 27 ++
packages/flow/openadapt_flow/repair/hold.py | 14 +-
packages/flow/openadapt_flow/runtime/heal.py | 42 ++-
.../flow/openadapt_flow/runtime/identity.py | 51 +++
.../openadapt_flow/runtime/render_scale.py | 13 +
.../flow/openadapt_flow/runtime/replayer.py | 110 +++++-
packages/flow/public-artifacts.json | 5 +
packages/flow/tests/test_bundle_schema_v2.py | 28 ++
.../test_dialog_identity_live_openemr.py | 103 +++++
.../flow/tests/test_dialog_row_identity.py | 354 ++++++++++++++++++
.../flow/tests/test_dialog_scoped_identity.py | 301 +++++++++++++++
packages/flow/tests/test_heal.py | 53 +++
packages/flow/tests/test_recorder.py | 20 +
packages/flow/tests/test_repair_hold.py | 81 ++++
27 files changed, 1798 insertions(+), 29 deletions(-)
create mode 100644 packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md
create mode 100644 packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json
create mode 100644 packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json
create mode 100644 packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json
create mode 100644 packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json
create mode 100644 packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json
create mode 100644 packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll_identity.py
create mode 100644 packages/flow/tests/test_dialog_identity_live_openemr.py
create mode 100644 packages/flow/tests/test_dialog_row_identity.py
create mode 100644 packages/flow/tests/test_dialog_scoped_identity.py
diff --git a/CAPABILITIES.md b/CAPABILITIES.md
index c934e587b1..eedf385b1a 100644
--- a/CAPABILITIES.md
+++ b/CAPABILITIES.md
@@ -230,6 +230,7 @@ improvising or retrying when something is off.
| One program that switches surfaces, tabs and popups mid-run | Released ([#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197)) | Real Chromium; Windows switch on a mock | [#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197) |
| Halt when a workflow qualified with page structure runs on a pixels-only surface | Released | Synthetic | `openadapt-flow qualify` |
| Stop before acting when another shown row has the same identity; when the rows carry an app-owned record id (a `data-*` id, the row link), act on the row with the recorded id; on a surface that cannot count the rows, stop before any step that leads into a write | Released ([#1504](https://github.com/OpenAdaptAI/OpenAdapt/pull/1504)) | Real browser (headless Chromium worklist with identical rows) | `runtime/identity_census.py`; `PlaywrightBackend.identity_candidates_at` |
+| Read a dialog target's identity on the topmost modal dialog alone, not the page behind it, and stop when no dialog holds the target | On main ([#1615](https://github.com/OpenAdaptAI/OpenAdapt/pull/1615)) | Real app (local OpenEMR, synthetic data): at the duplicate dialog with the form scrolled to 8 positions behind it, 0 of 8 false stops, against 8 of 8 before; a dialog listing a possible duplicate still stops | `PlaywrightBackend.dialog_region_at`, `Anchor.dialog_region`; [dialog_identity_2026-10-28](packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md) |
| Lift look-alike identity stops with a complete known-ID list | Released | Synthetic (rendered record list) | `openadapt-flow known-ids` |
| Take extra looks before stopping on a look-alike record ID: fresh frames, the known-ID list, and a select-and-copy of the ID where the surface allows it | Released ([#1333](https://github.com/OpenAdaptAI/OpenAdapt/pull/1333)) | Synthetic (dense record list in a browser) | `runtime.identity_copy_look` in deployment.yaml; [IDENTITY_LOOKS.md](packages/flow/benchmark/identity_looks/IDENTITY_LOOKS.md) |
| Check an icon-only click target's hover response (highlight, tooltip) before clicking on a pixel surface, and re-check the target and identity regions on a fresh frame right before every pixel click | Released ([#1334](https://github.com/OpenAdaptAI/OpenAdapt/pull/1334)) | Synthetic (in-process kiosk) | `Recorder(capture_hover=True)`; [hover_identity](packages/flow/benchmark/hover_identity/README.md) |
diff --git a/llms-full.txt b/llms-full.txt
index ec09e0ba23..ec3b480a19 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -633,6 +633,7 @@ improvising or retrying when something is off.
| One program that switches surfaces, tabs and popups mid-run | Released ([#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197)) | Real Chromium; Windows switch on a mock | [#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197) |
| Halt when a workflow qualified with page structure runs on a pixels-only surface | Released | Synthetic | `openadapt-flow qualify` |
| Stop before acting when another shown row has the same identity; when the rows carry an app-owned record id (a `data-*` id, the row link), act on the row with the recorded id; on a surface that cannot count the rows, stop before any step that leads into a write | Released ([#1504](https://github.com/OpenAdaptAI/OpenAdapt/pull/1504)) | Real browser (headless Chromium worklist with identical rows) | `runtime/identity_census.py`; `PlaywrightBackend.identity_candidates_at` |
+| Read a dialog target's identity on the topmost modal dialog alone, not the page behind it, and stop when no dialog holds the target | On main ([#1615](https://github.com/OpenAdaptAI/OpenAdapt/pull/1615)) | Real app (local OpenEMR, synthetic data): at the duplicate dialog with the form scrolled to 8 positions behind it, 0 of 8 false stops, against 8 of 8 before; a dialog listing a possible duplicate still stops | `PlaywrightBackend.dialog_region_at`, `Anchor.dialog_region`; [dialog_identity_2026-10-28](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md) |
| Lift look-alike identity stops with a complete known-ID list | Released | Synthetic (rendered record list) | `openadapt-flow known-ids` |
| Take extra looks before stopping on a look-alike record ID: fresh frames, the known-ID list, and a select-and-copy of the ID where the surface allows it | Released ([#1333](https://github.com/OpenAdaptAI/OpenAdapt/pull/1333)) | Synthetic (dense record list in a browser) | `runtime.identity_copy_look` in deployment.yaml; [IDENTITY_LOOKS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/identity_looks/IDENTITY_LOOKS.md) |
| Check an icon-only click target's hover response (highlight, tooltip) before clicking on a pixel surface, and re-check the target and identity regions on a fresh frame right before every pixel click | Released ([#1334](https://github.com/OpenAdaptAI/OpenAdapt/pull/1334)) | Synthetic (in-process kiosk) | `Recorder(capture_hover=True)`; [hover_identity](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/hover_identity/README.md) |
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md
new file mode 100644
index 0000000000..015a689d68
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/README.md
@@ -0,0 +1,45 @@
+# Identity at OpenEMR's duplicate dialog, before and after #1347
+
+Measured on 2026-10-28 on the pinned local OpenEMR 8.0.0.3 fixture, with
+synthetic patients only. One referral was recorded once with the #1347 engine,
+so the confirm click carries `dialog_region`. It was compiled twice with
+`scripts/openemr_local_demo.py`:
+- with `main` (490d0dc40) as the baseline;
+- with this branch, which reads the band on the dialog alone.
+
+The OCR band was the full-width strip around the button, so it read a label of
+the form behind the dialog. "Before" means `main` and "after" means this branch.
+
+## The identity check at the confirm step, form scrolled behind the dialog
+
+`scroll_identity.py` opens the real duplicate dialog with the New Patient form
+scrolled to several positions behind it. It runs the replayer's identity check
+for the confirm step of each bundle and saves nothing.
+
+| Path | Positions | `main` false stops | Branch false stops |
+|---|---|---|---|
+| OCR band (base Playwright backend, as in #1347) | 8 | 8 | 0 |
+| OpenEMR adapter's structured identity | 8 | 0 | 0 |
+
+- **Before.** On the OCR path, `main` read "Title: Cancel", "DOB: Cancel" or "License/ID: Cancel", whichever label sat beside the dialog, and refused every time. The recording had read "County:".
+- **After.** The branch read the dialog's own text and verified every time.
+- **Adapter path.** With the adapter, the structured tier decides, so neither bundle reads the band.
+
+Files: `scroll-ocr-tier.json` and `scroll-openemr-adapter.json`.
+
+## Fail closed on a listed duplicate
+
+`listed-duplicate.json`: the dialog for a patient who matches a seeded chart lists that chart. Both bundles refuse at both positions. The branch refuses because the dialog's listed row is not the empty result it recorded.
+
+## Worklist batches
+
+`batch-before-main.json` and `batch-after-branch.json` are throughput harness runs (`--items 26 --workers 1 --speed standard --backend openemr`).
+
+| Bundle | Correct | Wrong or extra charts | Stops |
+|---|---|---|---|
+| `main` | 26/26 | 0 | 0 |
+| Branch | 26/26 | 0 | 0 |
+
+The `engine_sha` in these files is the harness checkout, not the engine. The baseline ran `main`'s `openadapt_flow`.
+
+In these batches the form behind the dialog sat where it was recorded, so the baseline didn't hit the stop. The batches show that the branch holds no correct save. The scroll table above shows the stop itself.
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json
new file mode 100644
index 0000000000..bfa36a89c6
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json
@@ -0,0 +1,61 @@
+{
+ "tag": "after-branch",
+ "workers": 1,
+ "items": 26,
+ "window": 0,
+ "halt_at": null,
+ "profile": false,
+ "engine_sha": "4bb59503add4816e015a877dffe8f685527645d8",
+ "speed_profile": "standard",
+ "native_fill": false,
+ "page_quiet_ms": 100,
+ "ocr_threads": null,
+ "engine_dirty": false,
+ "bundle_digest": "93f8127d4af367500fc86ddd0bafa0d27b6e5764818f8999ab5c4abc56ec2973",
+ "baseline": "a1b14e0031d1",
+ "seed_status": 201,
+ "setup_s": 6.03,
+ "started": "2026-10-28 20:24:40",
+ "processes": false,
+ "wall_s": 492.285,
+ "pool_wall_s": 492.28,
+ "sign_in_s": {
+ "oa_clerk1": 2.24
+ },
+ "counts": {
+ "completed": 26
+ },
+ "stop": null,
+ "items_run": 26,
+ "saved_correct": 26,
+ "wrong_or_extra_charts": 0,
+ "not_saved": 0,
+ "new_charts_total": 26,
+ "entries_per_hour": 190.1,
+ "written_after_failed_readback": {},
+ "per_worker": [
+ {
+ "name": "oa_clerk1",
+ "items": 26,
+ "states": {
+ "completed": 26
+ },
+ "busy_s": 489.9
+ }
+ ],
+ "load1_mean": 23.04,
+ "load1_max": 90.04,
+ "ncpu": 12,
+ "gate": {
+ "threshold": 40.0,
+ "waited_s": 15.0,
+ "gave_up": false,
+ "load_at_start": [
+ 8.98681640625,
+ 15.39794921875,
+ 37.7783203125
+ ]
+ },
+ "valid": true,
+ "ended": "2026-10-28 20:33:09"
+}
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json
new file mode 100644
index 0000000000..72c4802b2a
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json
@@ -0,0 +1,61 @@
+{
+ "tag": "before-main",
+ "workers": 1,
+ "items": 26,
+ "window": 0,
+ "halt_at": null,
+ "profile": false,
+ "engine_sha": "4bb59503add4816e015a877dffe8f685527645d8",
+ "speed_profile": "standard",
+ "native_fill": false,
+ "page_quiet_ms": 100,
+ "ocr_threads": null,
+ "engine_dirty": false,
+ "bundle_digest": "6750b6dc15e5ddf1fb836c10e52f6e0577caf3cba89d39e9103049b8d3f86aa8",
+ "baseline": "a1b14e0031d1",
+ "seed_status": 201,
+ "setup_s": 7.79,
+ "started": "2026-10-28 20:15:00",
+ "processes": false,
+ "wall_s": 540.018,
+ "pool_wall_s": 540.009,
+ "sign_in_s": {
+ "oa_clerk1": 2.79
+ },
+ "counts": {
+ "completed": 26
+ },
+ "stop": null,
+ "items_run": 26,
+ "saved_correct": 26,
+ "wrong_or_extra_charts": 0,
+ "not_saved": 0,
+ "new_charts_total": 26,
+ "entries_per_hour": 173.3,
+ "written_after_failed_readback": {},
+ "per_worker": [
+ {
+ "name": "oa_clerk1",
+ "items": 26,
+ "states": {
+ "completed": 26
+ },
+ "busy_s": 537.1
+ }
+ ],
+ "load1_mean": 10.65,
+ "load1_max": 16.03,
+ "ncpu": 12,
+ "gate": {
+ "threshold": 40.0,
+ "waited_s": 15.0,
+ "gave_up": false,
+ "load_at_start": [
+ 15.7275390625,
+ 52.67236328125,
+ 65.37109375
+ ]
+ },
+ "valid": true,
+ "ended": "2026-10-28 20:24:17"
+}
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json
new file mode 100644
index 0000000000..c86a87cfd0
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json
@@ -0,0 +1,16 @@
+[
+ {
+ "scroll": 0,
+ "main": "mismatch",
+ "main_observed": "Who Cancel",
+ "branch": "mismatch",
+ "branch_observed": "Hits Name Preferred Name Birth Name DOB Birth Sex External ID Previous Names 2 D"
+ },
+ {
+ "scroll": 600,
+ "main": "mismatch",
+ "main_observed": "Cancel",
+ "branch": "mismatch",
+ "branch_observed": "Hits Name Preferred Name Birth Name DOB Birth Sex External ID Previous Names 2 D"
+ }
+]
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json
new file mode 100644
index 0000000000..0bdcfbcc03
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json
@@ -0,0 +1,58 @@
+[
+ {
+ "scroll": 0,
+ "main": "mismatch",
+ "main_observed": "Title: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 150,
+ "main": "mismatch",
+ "main_observed": "DOB: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 300,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 450,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 600,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 750,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 900,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ },
+ {
+ "scroll": 1100,
+ "main": "mismatch",
+ "main_observed": "License/ID: Cancel",
+ "branch": "verified",
+ "branch_observed": "Preferred Birth Birth External Previous Hits Name Name Name DOB Sex ID Names No "
+ }
+]
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json
new file mode 100644
index 0000000000..ed0658a267
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json
@@ -0,0 +1,58 @@
+[
+ {
+ "scroll": 0,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 150,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 300,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 450,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 600,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 750,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 900,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ },
+ {
+ "scroll": 1100,
+ "main": "verified",
+ "main_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New ",
+ "branch": "verified",
+ "branch_observed": "{\"form_path\":\"/interface/new/new_comprehensive.php\",\"label\":\"Confirm Create New "
+ }
+]
\ No newline at end of file
diff --git a/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll_identity.py b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll_identity.py
new file mode 100644
index 0000000000..76ae9e44af
--- /dev/null
+++ b/packages/flow/benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll_identity.py
@@ -0,0 +1,61 @@
+"""Identity verdict at OpenEMR's confirm step, form behind scrolled to N positions."""
+
+import json
+import os
+import sys
+from pathlib import Path
+
+from benchmark.openemr_throughput import harness
+from openadapt_flow.ir import Resolution, Workflow
+from openadapt_flow.runtime import Replayer
+
+demo = harness._demo_module()
+fx = harness.make_fixture(
+ Path(os.path.expanduser("~/oa/scratch/throughput/fixture/state"))
+)
+M = Path(os.path.expanduser("~/oa/scratch/real-app-edge-cases"))
+bundles = {
+ name: (M / f"bundle-{name}", Workflow.load(M / f"bundle-{name}"))
+ for name in ("main", "branch")
+}
+spec_cls = __import__(
+ "openadapt_flow.benchmark.openemr_local", fromlist=["SyntheticPatientSpec"]
+).SyntheticPatientSpec
+params = spec_cls().params()
+scrolls = [int(v) for v in sys.argv[1].split(",")]
+rows = []
+for y in scrolls:
+ backend, close = demo.OpenEMRPlaywrightBackend.launch(fx.ui_base_url, headless=True)
+ try:
+ demo._preauthenticate_browser(backend, fx)
+ page = backend.page
+ form = demo._form_frame(page)
+ form.locator("#form_fname").fill(os.environ.get("P_FNAME", "Wilhelmina"))
+ form.locator("#form_lname").fill(os.environ.get("P_LNAME", "Okonkwo-Hale"))
+ form.locator("#form_DOB").fill(os.environ.get("P_DOB", "1961-04-09"))
+ form.locator("#form_DOB").press("Escape")
+ form.locator("#form_sex").select_option(label="Female")
+ form.evaluate(f"() => window.scrollTo(0, {y})")
+ form.locator("#create").evaluate("el => el.click()")
+ point = demo._center(demo._confirm_create_locator(page))
+ png = backend.screenshot()
+ row = {"scroll": y}
+ for name, (bdir, wf) in bundles.items():
+ step = wf.steps[-1]
+ rep = Replayer(demo._BasePlaywrightBackend(page))
+ res = Resolution(
+ rung="structural", point=point, confidence=1.0, elapsed_ms=0.0
+ )
+ check = rep._verify_identity(step, res, png, params, wf, bdir)
+ row[name] = check.status
+ row[name + "_observed"] = check.observed[:80]
+ rows.append(row)
+ print(json.dumps(row), flush=True)
+ finally:
+ close()
+out = M / os.environ.get("PERTURB_OUT", "perturb.json")
+out.write_text(json.dumps(rows, indent=1))
+for name in ("main", "branch"):
+ print(
+ name, "false stops:", sum(r[name] != "verified" for r in rows), "of", len(rows)
+ )
diff --git a/packages/flow/benchmark/openemr_throughput/harness.py b/packages/flow/benchmark/openemr_throughput/harness.py
index fcf9a2e817..d67108689c 100644
--- a/packages/flow/benchmark/openemr_throughput/harness.py
+++ b/packages/flow/benchmark/openemr_throughput/harness.py
@@ -274,7 +274,29 @@ def __init__(self, page: Any) -> None:
self.page = page
def open_form(page: Any) -> None:
+ """Open the blank form, once more if a navigation raced the setup (#1348).
+
+ Sign-in or the previous item's chart can still be navigating the page
+ when a worker claims its next item; an ``evaluate`` then loses its
+ execution context. That is a setup race, not an engine failure: wait
+ for the page to load and run the setup again, once.
+ """
+ try:
+ _open_form(page)
+ except Exception as exc: # noqa: BLE001 - only the navigation race retries
+ if "Execution context was destroyed" not in str(exc):
+ raise
+ page.wait_for_load_state("load", timeout=60_000)
+ for frame in page.frames:
+ try:
+ frame.wait_for_load_state("load", timeout=60_000)
+ except Exception: # noqa: BLE001 - a frame may detach meanwhile
+ continue
+ _open_form(page)
+
+ def _open_form(page: Any) -> None:
"""Start each item where the recording started: no chart open, blank form."""
+ page.wait_for_load_state("load", timeout=60_000)
if page.evaluate(
"() => !!(window.app_view_model && app_view_model.application_data.patient())"
):
@@ -306,7 +328,14 @@ def run_item(page: Any, idx: int, row: dict[str, Any], item_dir: Path, pipeline:
if not session:
session.append(fixture.token_session("oracle"))
oracle = OpenEMRPatientOracle(fixture.api_base_url, session[0], spec)
- backend = demo._BasePlaywrightBackend(page)
+ # Replay with the backend the bundle was recorded with: a bundle the
+ # demo's ``record`` made carries the OpenEMR adapter's field identity.
+ backend_cls = (
+ demo.OpenEMRPlaywrightBackend
+ if opts.get("backend") == "openemr"
+ else demo._BasePlaywrightBackend
+ )
+ backend = backend_cls(page)
prof = None
if opts.get("profile"):
from benchmark.openemr_throughput.profiler import RunProfiler
@@ -398,6 +427,7 @@ def run(a: argparse.Namespace) -> dict[str, Any]:
"window": a.window, "profile": a.profile, "keep_pngs": a.keep_pngs,
"page_quiet_ms": a.page_quiet_ms, "ocr_threads": a.ocr_threads,
"speed": a.speed,
+ "backend": a.backend,
}
if a.processes:
from openadapt_flow.runtime.worklist_pool import ProcessWorker
@@ -495,6 +525,8 @@ def main() -> None:
r.add_argument("--window", type=int, default=0)
r.add_argument("--halt-at", type=int, default=None)
r.add_argument("--profile", action="store_true")
+ r.add_argument("--backend", choices=["base", "openemr"], default="base",
+ help="the browser backend the bundle was recorded with")
r.add_argument("--speed", choices=["standard", "fast"], default="standard",
help="named speed profile (openadapt_flow.runtime.speed)")
r.add_argument("--ocr-threads", type=int, default=None,
diff --git a/packages/flow/openadapt_flow/backends/playwright_backend.py b/packages/flow/openadapt_flow/backends/playwright_backend.py
index 90e629b093..6efeb3f683 100644
--- a/packages/flow/openadapt_flow/backends/playwright_backend.py
+++ b/packages/flow/openadapt_flow/backends/playwright_backend.py
@@ -1344,7 +1344,12 @@ def _fill_iso_date(target: Any, text: str) -> bool:
? Array.from(document.getElementsByTagName(level.tagName)).filter(
(candidate) => !tag || candidate.getElementsByTagName(tag).length > 0)
: Array.from(document.querySelectorAll('tr, [role="row"], li, [role="listitem"]'));
- const pool = new Set(siblings.concat(pageWide));
+ const dialog = el.closest(
+ 'dialog:modal, [aria-modal="true"], [role="alertdialog"], .modal.show'
+ );
+ const pool = new Set(siblings.concat(pageWide).filter(
+ (candidate) => !dialog || dialog.contains(candidate)
+ ));
const result = {
record_key: recordKey(row, el),
incomplete: pool.size > cap,
@@ -1675,6 +1680,82 @@ def _fill_iso_date(target: Any, text: str) -> bool:
return null;
}"""
+# The active modal dialog holding the element under a viewport point. Walks the
+# hit-tested chain from the top document down through same-origin frames; the
+# first dialog met is the outermost one, and hit testing already follows the
+# stacking order, so a dialog painted over another wins. The box is the
+# dialog's first element (from the dialog down toward the hit) that does not
+# cover the viewport, so a Bootstrap ``.modal`` overlay yields its
+# ``.modal-dialog``. Answers ``{status: "dialog", box}``, ``{status: "none"}``,
+# or ``{status: "unknown"}`` when a frame on the chain is cross-origin or
+# transformed and the chain cannot be followed.
+_DIALOG_AT_JS = r"""([x, y]) => {
+ // Modal dialogs only: a popover or date picker with role="dialog" leaves
+ // the page behind live, and its rows stay identity evidence.
+ const SELECTOR = 'dialog:modal, [aria-modal="true"], [role="alertdialog"], ' +
+ '.modal.show';
+ const visible = (el, win) => {
+ const style = win.getComputedStyle(el);
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
+ if (parseFloat(style.opacity) < 0.05) return false;
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+ };
+ let doc = document;
+ let win = window;
+ let ox = 0, oy = 0;
+ let px = x, py = y;
+ for (let depth = 0; depth < 9; depth++) {
+ const hit = doc.elementFromPoint(px, py);
+ if (!hit) return {status: 'none'};
+ const dialog = hit.closest(SELECTOR);
+ if (dialog && visible(dialog, win)) {
+ const chain = [];
+ for (let node = hit; node; node = node.parentElement) {
+ chain.push(node);
+ if (node === dialog) break;
+ }
+ const vw = win.innerWidth, vh = win.innerHeight;
+ let box = null;
+ for (let i = chain.length - 1; i >= 0; i--) {
+ const r = chain[i].getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) continue;
+ if (r.width >= 0.95 * vw && r.height >= 0.95 * vh) continue;
+ box = r;
+ break;
+ }
+ if (!box) box = dialog.getBoundingClientRect();
+ const left = Math.max(0, box.left), top = Math.max(0, box.top);
+ const right = Math.min(vw, box.right), bottom = Math.min(vh, box.bottom);
+ if (right <= left || bottom <= top) return {status: 'unknown'};
+ return {status: 'dialog', box: [
+ Math.floor(left + ox), Math.floor(top + oy),
+ Math.ceil(right - left), Math.ceil(bottom - top),
+ ]};
+ }
+ const tag = hit.localName;
+ if (tag !== 'iframe' && tag !== 'frame') return {status: 'none'};
+ let child;
+ try { child = hit.contentDocument; } catch (_) { child = null; }
+ if (!child || !hit.contentWindow) return {status: 'unknown'};
+ const r = hit.getBoundingClientRect();
+ if (!(hit.offsetWidth > 0 && hit.offsetHeight > 0)) return {status: 'unknown'};
+ if (Math.abs(r.width / hit.offsetWidth - 1) > 0.01 ||
+ Math.abs(r.height / hit.offsetHeight - 1) > 0.01) {
+ return {status: 'unknown'};
+ }
+ const style = win.getComputedStyle(hit);
+ const dx = r.left + hit.clientLeft + (parseFloat(style.paddingLeft) || 0);
+ const dy = r.top + hit.clientTop + (parseFloat(style.paddingTop) || 0);
+ ox += dx; oy += dy;
+ px -= dx; py -= dy;
+ doc = child;
+ win = hit.contentWindow;
+ }
+ return {status: 'unknown'};
+}"""
+
+
_PROJECT_FRAME_POINT_JS = r"""(el, point) => {
const [px, py] = point;
if (document.elementFromPoint(px, py) !== el) return null;
@@ -2367,6 +2448,43 @@ def structured_text_at(self, x: int, y: int) -> Optional[str]:
return None
return result or None
+ def dialog_region_at(
+ self, x: int, y: int
+ ) -> tuple[str, Optional[tuple[int, int, int, int]]]:
+ """The box of the active dialog holding viewport pixel (x, y).
+
+ Returns ``("dialog", (x, y, w, h))`` when the element under the point
+ sits in an open modal dialog (a modal ``dialog``, ``aria-modal``, an
+ ``alertdialog``, or a shown Bootstrap modal), the outermost one along the frame
+ chain; ``("none", None)`` when the chain was followed and no dialog
+ holds the point; ``("unknown", None)`` when it could not be followed
+ (a cross-origin or transformed frame, an evaluation error). Callers
+ that need a dialog treat ``unknown`` as a failed check. Never raises.
+ """
+ try:
+ result = self.page.evaluate(_DIALOG_AT_JS, [int(x), int(y)])
+ except Exception:
+ return ("unknown", None)
+ if not isinstance(result, dict):
+ return ("unknown", None)
+ status = result.get("status")
+ if status == "none":
+ return ("none", None)
+ box = result.get("box")
+ if (
+ status == "dialog"
+ and isinstance(box, list)
+ and len(box) == 4
+ and all(isinstance(v, (int, float)) for v in box)
+ and box[2] > 0
+ and box[3] > 0
+ ):
+ bx, by, bw, bh = (int(v) for v in box)
+ if not (bx <= int(x) < bx + bw and by <= int(y) < by + bh):
+ return ("unknown", None)
+ return ("dialog", (bx, by, bw, bh))
+ return ("unknown", None)
+
def identity_candidates_at(self, x: int, y: int) -> Optional[dict[str, Any]]:
"""Census of the rows that show the same record identity as (x, y).
diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py
index 5a423898ca..e4ce15e8f0 100644
--- a/packages/flow/openadapt_flow/compiler/compile.py
+++ b/packages/flow/openadapt_flow/compiler/compile.py
@@ -83,6 +83,7 @@
context_region_from_lines,
coverage,
identifier_text_from_lines,
+ mask_outside,
)
from openadapt_flow.vision import imgops
from openadapt_flow.vision.hashing import phash_distance, phash_png
@@ -1599,6 +1600,26 @@ def _clamp_to_frame(region: Region, frame_size: tuple[int, int]) -> Optional[Reg
return (x0, y0, x1 - x0, y1 - y0)
+def _event_dialog_region(
+ event: dict[str, Any], frame_size: tuple[int, int]
+) -> Optional[Region]:
+ """The recorded dialog box holding the click target, clamped to the frame.
+
+ The recorder stores ``dialog_region`` on a pointer event whose target sat
+ in an open dialog (``PlaywrightBackend.dialog_region_at``). A malformed
+ value or one off the frame is ignored: the band then stays full width.
+ """
+ value = event.get("dialog_region")
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
+ return None
+ if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value):
+ return None
+ x, y, w, h = (int(v) for v in value)
+ if w <= 0 or h <= 0:
+ return None
+ return _clamp_to_frame((x, y, w, h), frame_size)
+
+
def _marked_identifier_region(value: object, *, source: str) -> Optional[Region]:
"""Validate an explicitly marked identifier region from the recording.
@@ -1687,6 +1708,8 @@ def _emit_identifier_crop(
crop_region: Region,
click: Point,
reference_date: Optional[date],
+ scope: Optional[Region] = None,
+ dialog_row: bool = True,
) -> tuple[Optional[str], Optional[Region], Optional[str]]:
"""Emit the pixel identifier crop for one click step, or say why not.
@@ -1738,10 +1761,11 @@ def _emit_identifier_crop(
band_box = context_region_from_lines(
frame_lines,
exclude_region=crop_region,
- band=band_region(click, crop_region[3], frame_size),
- point=click,
+ band=scope or band_region(click, crop_region[3], frame_size),
+ point=None if scope is not None and not dialog_row else click,
min_confidence=MIN_OCR_CONFIDENCE,
reference_date=reference_date,
+ scope=scope,
)
region = _clamp_to_frame(band_box, frame_size) if band_box else None
if region is None:
@@ -2487,19 +2511,51 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
# (a table row's discriminative name column sits outside the
# 160x64 template — see runtime.identity). Verified against the
# live band before every click at replay time.
+ # A target inside a dialog: read its identity band on the frame
+ # with everything outside the dialog blanked, so labels on the
+ # page behind it are not frozen into the identity (#1347).
+ dialog_region = _event_dialog_region(
+ event, (frame.shape[1], frame.shape[0])
+ )
+ identity_lines = frame_lines
+ if dialog_region is not None:
+ masked = mask_outside(before_png, dialog_region)
+ identity_lines = (
+ cached_lines(i, "dialog", masked) if masked is not None else []
+ )
+ # Keep the clicked row inside the dialog. Only a positively
+ # identified standalone control may fall back to the whole dialog
+ # when its row has too little text to arm identity.
+ dialog_row = True
+ context_band = dialog_region or band_region(
+ click, crop_region[3], (frame.shape[1], frame.shape[0])
+ )
context_text = context_from_lines(
- frame_lines,
+ identity_lines,
exclude_region=crop_region,
- band=band_region(
- click, crop_region[3], (frame.shape[1], frame.shape[0])
- ),
+ band=context_band,
# Row refinement: record only the click point's OWN text
# row, matching what replay-time verification reads (the
# 64px band spans 2-3 rows of a dense table).
point=click,
min_confidence=MIN_OCR_CONFIDENCE,
reference_date=reference_date,
+ scope=dialog_region,
)
+ if (
+ context_text is None
+ and dialog_region is not None
+ and event.get("dialog_row") is False
+ ):
+ dialog_row = False
+ context_text = context_from_lines(
+ identity_lines,
+ exclude_region=crop_region,
+ band=context_band,
+ min_confidence=MIN_OCR_CONFIDENCE,
+ reference_date=reference_date,
+ scope=dialog_region,
+ )
# Structured identity (DOM / a11y text of the clicked row),
# captured by the recorder when the recording backend exposed it
# (openadapt_flow.backend.IdentityBackend). Stored alongside the
@@ -2555,10 +2611,12 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
),
context_text=context_text,
structured_identity=structured_identity,
- frame_lines=frame_lines,
+ frame_lines=identity_lines,
crop_region=crop_region,
click=click,
reference_date=reference_date,
+ scope=dialog_region,
+ dialog_row=dialog_row,
)
)
# The OCR fallback must compare like-for-like evidence. Derive the
@@ -2570,7 +2628,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
cropped_context: Optional[str] = None
if identifier_region is not None:
cropped_context = identifier_text_from_lines(
- frame_lines,
+ identity_lines,
region=identifier_region,
min_confidence=MIN_OCR_CONFIDENCE,
reference_date=reference_date,
@@ -2632,6 +2690,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
landmarks=anchor_landmarks,
identifier_crop=identifier_crop_rel,
identifier_region=identifier_region,
+ dialog_region=dialog_region,
+ dialog_row=dialog_row if dialog_region is not None else None,
)
drag_end_anchor: Optional[Anchor] = None
if kind == "drag":
@@ -2687,7 +2747,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
unarmed_reason: Optional[str] = None
if not identity_armed:
unarmed_reason = _identity_unarmed_reason(
- frame_lines,
+ identity_lines,
band=band_region(
click,
crop_region[3],
diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py
index 494fa4722f..b42973ba60 100644
--- a/packages/flow/openadapt_flow/ir.py
+++ b/packages/flow/openadapt_flow/ir.py
@@ -545,6 +545,27 @@ class Anchor(BaseModel):
" tiers compare like-for-like. Set iff `identifier_crop` is set."
),
)
+ dialog_region: Optional[Region] = Field(
+ default=None,
+ description=(
+ "Box of the active dialog (the topmost modal) that held the target"
+ " in the recorded frame, when it sat in one. The context band was"
+ " cut to it, so text of the page behind the dialog is not identity"
+ " evidence. Replay cuts the live band to the live dialog's box,"
+ " and fails the identity check closed when no dialog holds the"
+ " resolved point. None outside a dialog and on bundles compiled"
+ " before this capability."
+ ),
+ )
+ dialog_row: Optional[bool] = Field(
+ default=None,
+ description=(
+ "True reads the target's own row inside dialog_region. False permits"
+ " the whole dialog when teach time positively found a standalone"
+ " control with no usable row text; replay must confirm no record"
+ " row again. None conservatively uses the target's row."
+ ),
+ )
form_identity: Optional[FormIdentity] = Field(
default=None,
description=(
@@ -563,6 +584,10 @@ class Anchor(BaseModel):
def _serialize_compatible(self, handler: Any) -> dict[str, Any]:
"""Leave unset census metadata out, so older bundle bytes stay equal."""
data: dict[str, Any] = handler(self)
+ if self.dialog_region is None:
+ data.pop("dialog_region", None)
+ if self.dialog_row is None:
+ data.pop("dialog_row", None)
if self.record_key is None:
data.pop("record_key", None)
if self.structured_row is None:
@@ -4151,11 +4176,13 @@ class IdentityCheck(BaseModel):
"look-alike identifier (runtime.identity_looks), in order."
),
)
- look_halt: Optional[Literal["identity_look_changed_screen"]] = Field(
+ look_halt: Optional[
+ Literal["identity_look_changed_screen", "identity_dialog_mask_failed"]
+ ] = Field(
default=None,
description=(
"Set when an extra look changed the screen outside the "
- "identifier it read. The step halts."
+ "identifier it read, or could not mask its dialog. The step halts."
),
)
candidate_count: Optional[int] = Field(
diff --git a/packages/flow/openadapt_flow/recorder.py b/packages/flow/openadapt_flow/recorder.py
index 896fc0b013..50200123bb 100644
--- a/packages/flow/openadapt_flow/recorder.py
+++ b/packages/flow/openadapt_flow/recorder.py
@@ -769,6 +769,17 @@ def _record(self, event: dict[str, Any], act: Callable[[], None]) -> None:
# scrolls out of a virtualized list, the text check
# alone would pass on it: replay halts before a write.
event = {**event, "structured_ambiguous": True}
+ # The active dialog holding the target, when it sits in one: the
+ # compiler cuts the identity band to its box, so a label of the
+ # page behind the dialog is not frozen into the identity.
+ dialog = self._dialog_region_at(int(event["x"]), int(event["y"]))
+ if dialog is not None:
+ event = {**event, "dialog_region": list(dialog)}
+ # A whole-dialog fallback is valid only for a control that
+ # positively has no record row, never for unreadable row text.
+ census = self._identity_census_at(int(event["x"]), int(event["y"]))
+ if census is not None:
+ event = {**event, "dialog_row": census.get("row") is not False}
# The part of the form the target sits in (a legend, an accordion
# or tab header, a heading), for the program's named sections.
# Display only: the compiler never puts it on a step.
@@ -1141,6 +1152,22 @@ def _structured_identity_at(self, x: int, y: int) -> Optional[str]:
except Exception:
return None
+ def _dialog_region_at(self, x: int, y: int) -> Optional[tuple[int, ...]]:
+ """Box of the active dialog holding (x, y), if the backend can say.
+
+ Backends MAY expose ``dialog_region_at`` (the Playwright backend
+ does). None when the point is in no dialog, the backend cannot tell,
+ or the probe failed: the band then stays full width, as before.
+ """
+ getter = getattr(self._backend, "dialog_region_at", None)
+ if getter is None:
+ return None
+ try:
+ status, box = getter(int(x), int(y))
+ except Exception:
+ return None
+ return tuple(box) if status == "dialog" and box else None
+
def _identity_census_at(self, x: int, y: int) -> Optional[dict[str, Any]]:
"""The row census under (x, y), if the backend exposes one.
diff --git a/packages/flow/openadapt_flow/repair/hold.py b/packages/flow/openadapt_flow/repair/hold.py
index 5fb85ad699..07fad0125f 100644
--- a/packages/flow/openadapt_flow/repair/hold.py
+++ b/packages/flow/openadapt_flow/repair/hold.py
@@ -100,6 +100,8 @@ def hold_for_repair(
ended = "timeout"
stopping: list[int] = []
previous = None
+ staged_hold: Path | None = None
+ published = False
if threading.current_thread() is threading.main_thread():
# The parent ending the hold (SIGTERM) must still clean up and let the
# caller close the browser, so turn it into a normal end of the hold.
@@ -111,7 +113,10 @@ def hold_for_repair(
os.chmod(sock_path, 0o600)
server.listen(1)
deadline = time.monotonic() + timeout_s
- fd = os.open(hold_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ fd, staged_name = tempfile.mkstemp(
+ prefix=f".{HOLD_FILE}.", suffix=".tmp", dir=run_dir
+ )
+ staged_hold = Path(staged_name)
with os.fdopen(fd, "w") as handle:
json.dump(
{
@@ -123,6 +128,8 @@ def hold_for_repair(
},
handle,
)
+ os.replace(staged_hold, hold_file)
+ published = True
while True:
remaining = deadline - time.monotonic()
if stopping:
@@ -164,7 +171,10 @@ def hold_for_repair(
if previous is not None:
signal.signal(signal.SIGTERM, previous)
server.close()
- hold_file.unlink(missing_ok=True)
+ if staged_hold is not None:
+ staged_hold.unlink(missing_ok=True)
+ if published:
+ hold_file.unlink(missing_ok=True)
shutil.rmtree(private, ignore_errors=True)
return ended
diff --git a/packages/flow/openadapt_flow/runtime/heal.py b/packages/flow/openadapt_flow/runtime/heal.py
index 4d3f9c9a38..2b0ae05c74 100644
--- a/packages/flow/openadapt_flow/runtime/heal.py
+++ b/packages/flow/openadapt_flow/runtime/heal.py
@@ -109,6 +109,8 @@ def _recontext(
frame: tuple[int, int],
*,
identifier_region: Region | None = None,
+ dialog_region: Region | None = None,
+ dialog_row: bool = True,
) -> str | None:
"""Re-derive the anchor's identity context band from the live frame.
@@ -139,11 +141,12 @@ def _recontext(
return identity_mod.context_from_lines(
lines,
exclude_region=region,
- band=identity_mod.band_region(click_point, region[3], frame),
+ band=dialog_region or identity_mod.band_region(click_point, region[3], frame),
# Same row refinement the compiler applies: record only the click
# point's own text row, matching what verification reads back.
- point=click_point,
+ point=None if dialog_region is not None and not dialog_row else click_point,
reference_date=date.today(),
+ scope=dialog_region,
)
@@ -203,6 +206,15 @@ def build_heal_event(
iw,
ih,
)
+ translated_dialog_region = None
+ if old_anchor.dialog_region is not None:
+ dx, dy, dw, dh = old_anchor.dialog_region
+ translated_dialog_region = (
+ dx + resolution.point[0] - old_anchor.click_point[0],
+ dy + resolution.point[1] - old_anchor.click_point[1],
+ dw,
+ dh,
+ )
if (
old_anchor.context_text is None
and old_anchor.identity_template is not None
@@ -213,13 +225,24 @@ def build_heal_event(
# identity evidence. Do not add live plaintext to the healed anchor.
new_context = None
else:
- new_context = _recontext(
- vision,
- frame_png,
- new_region,
- resolution.point,
- frame,
- identifier_region=translated_identifier_region,
+ context_png: bytes | None = frame_png
+ if translated_dialog_region is not None:
+ # A target in a dialog keeps its band read on the dialog alone, as
+ # the compiler read it; replay masks the live frame the same way.
+ context_png = identity_mod.mask_outside(frame_png, translated_dialog_region)
+ new_context = (
+ _recontext(
+ vision,
+ context_png,
+ new_region,
+ resolution.point,
+ frame,
+ identifier_region=translated_identifier_region,
+ dialog_region=translated_dialog_region,
+ dialog_row=old_anchor.dialog_row is not False,
+ )
+ if context_png is not None
+ else None
)
new_anchor = old_anchor.model_copy(
update={
@@ -228,6 +251,7 @@ def build_heal_event(
"ocr_text": new_text if new_text is not None else old_anchor.ocr_text,
"context_text": new_context,
"identifier_region": translated_identifier_region,
+ "dialog_region": translated_dialog_region,
}
)
event = HealEvent(
diff --git a/packages/flow/openadapt_flow/runtime/identity.py b/packages/flow/openadapt_flow/runtime/identity.py
index ed4f680a71..b151351b1d 100644
--- a/packages/flow/openadapt_flow/runtime/identity.py
+++ b/packages/flow/openadapt_flow/runtime/identity.py
@@ -401,6 +401,20 @@ def regions_intersect(a: Region, b: Region) -> bool:
_intersects = regions_intersect
+def centered_in(region: Region, scope: Region) -> bool:
+ """Whether ``region``'s center lies inside ``scope``.
+
+ Scopes the identity band to the active dialog: a line belongs to the
+ dialog when its center is inside the dialog's box, so a form label beside
+ the dialog is not identity evidence. A line the dialog's edge only clips
+ is judged by where most of it sits.
+ """
+ x, y, w, h = region
+ sx, sy, sw, sh = scope
+ cx, cy = x + w // 2, y + h // 2
+ return sx <= cx < sx + sw and sy <= cy < sy + sh
+
+
def lines_near_point(lines: Iterable[Any], point_y: int) -> list[Any]:
"""Filter OCR lines to the single text row containing ``point_y``.
@@ -473,6 +487,7 @@ def context_from_lines(
point: Optional[Point] = None,
min_confidence: float = 0.5,
reference_date: Optional[date] = None,
+ scope: Optional[Region] = None,
) -> Optional[str]:
"""Extract the context-band text from full-frame OCR lines.
@@ -497,6 +512,8 @@ def context_from_lines(
reference_date: The recording date, enabling the near/far date
split; when None every date-bearing line is dropped (heal-time
re-extraction has no recording date).
+ scope: The active dialog's box when the target sits in one; lines
+ centered outside it (the page behind the dialog) are dropped.
Returns:
The joined band text, or None when the surviving text is too short
@@ -509,6 +526,7 @@ def context_from_lines(
point=point,
min_confidence=min_confidence,
reference_date=reference_date,
+ scope=scope,
)
if not kept:
return None
@@ -563,6 +581,7 @@ def _kept_context_lines(
point: Optional[Point],
min_confidence: float,
reference_date: Optional[date],
+ scope: Optional[Region] = None,
) -> list[tuple[int, Any, str]]:
"""The confident, in-band, non-excluded, non-volatile identity lines.
@@ -580,6 +599,8 @@ def _kept_context_lines(
center_y = ly + lh // 2
if not (band_y <= center_y < band_y + band_h):
continue
+ if scope is not None and not centered_in(line.region, scope):
+ continue
if _intersects(line.region, exclude_region):
continue
if is_volatile_line(text, reference_date=reference_date):
@@ -599,6 +620,7 @@ def context_region_from_lines(
point: Optional[Point] = None,
min_confidence: float = 0.5,
reference_date: Optional[date] = None,
+ scope: Optional[Region] = None,
) -> Optional[Region]:
"""Bounding box of the identity lines :func:`context_from_lines` keeps.
@@ -618,6 +640,7 @@ def context_region_from_lines(
point=point,
min_confidence=min_confidence,
reference_date=reference_date,
+ scope=scope,
)
if not kept:
return None
@@ -2528,6 +2551,34 @@ def crop_region(frame_png: bytes, region: Region) -> Optional[bytes]:
return buf.tobytes() if ok else None
+def mask_outside(frame_png: bytes, region: Region) -> Optional[bytes]:
+ """Blank every pixel of a PNG frame outside ``region`` (x, y, w, h).
+
+ The identity band of a target inside a dialog is read on this frame, at
+ record and at replay time, so the page behind the dialog cannot reach the
+ OCR at all: a label beside the dialog is neither read nor merged into a
+ line with the dialog's own text. The fill is flat white, which OCR reads
+ as no text. Returns None when the frame cannot be decoded or the region
+ is empty.
+ """
+ import cv2
+ import numpy as np
+
+ img = cv2.imdecode(np.frombuffer(frame_png, np.uint8), cv2.IMREAD_COLOR)
+ if img is None:
+ return None
+ h, w = img.shape[:2]
+ x0, y0 = max(0, int(region[0])), max(0, int(region[1]))
+ x1 = min(w, int(region[0]) + int(region[2]))
+ y1 = min(h, int(region[1]) + int(region[3]))
+ if x1 <= x0 or y1 <= y0:
+ return None
+ out = np.full_like(img, 255)
+ out[y0:y1, x0:x1] = img[y0:y1, x0:x1]
+ ok, buf = cv2.imencode(".png", out)
+ return buf.tobytes() if ok else None
+
+
def upscale_crop(frame_png: bytes, region: Region, factor: int = 2) -> Optional[bytes]:
"""Crop ``region`` from a frame and upscale it (cubic) for a re-OCR.
diff --git a/packages/flow/openadapt_flow/runtime/render_scale.py b/packages/flow/openadapt_flow/runtime/render_scale.py
index a3f6818cb3..f011cd490d 100644
--- a/packages/flow/openadapt_flow/runtime/render_scale.py
+++ b/packages/flow/openadapt_flow/runtime/render_scale.py
@@ -692,6 +692,18 @@ def method(
return method
+def _dialog_method(name: str) -> Callable[..., Any]:
+ """Probe the live dialog at the live point; return its box in the view."""
+
+ def method(self: RenderScaledBackend, x: int, y: int) -> Any:
+ status, box = getattr(self._inner, name)(*self._transform.to_live(x, y))
+ if status != "dialog" or box is None:
+ return status, None
+ return status, self._transform.region_to_recorded(box)
+
+ return method
+
+
def _passthrough(name: str) -> property:
return property(lambda self: getattr(self._inner, name))
@@ -718,6 +730,7 @@ def _passthrough(name: str) -> property:
"select_option": _hash_method,
"select_option_guarded": _hash_method,
"arm_remote_frame_contract": _regions_method,
+ "dialog_region_at": _dialog_method,
"take_dialogs": _passthrough,
"arm_dialog": _passthrough,
"transport": _passthrough,
diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py
index ef03ef4ca8..0e9e10f3d3 100644
--- a/packages/flow/openadapt_flow/runtime/replayer.py
+++ b/packages/flow/openadapt_flow/runtime/replayer.py
@@ -11000,10 +11000,15 @@ def _identity_gate_error(
"on the fresh frame — refusing to act; run aborted"
)
elif check.look_halt is not None:
+ reason = (
+ "the dialog could not be masked"
+ if check.look_halt == "identity_dialog_mask_failed"
+ else "the screen changed outside the identifier"
+ )
error = (
f"Step '{step.id}' ({step.intent}) took an extra look at a "
- "look-alike identifier and the screen changed outside the "
- f"identifier ({check.look_halt}) — refusing to act"
+ f"look-alike identifier and {reason} "
+ f"({check.look_halt}) — refusing to act"
)
result.safety_halt = True
elif check.known_id_halt is not None:
@@ -15882,6 +15887,43 @@ def _compare_direct_signal_text(
parameter_names,
)
+ def _dialog_scoped_frame(
+ self, resolution: Resolution, before_png: bytes
+ ) -> Union[bytes, IdentityCheck]:
+ """The step's frame with everything outside the live dialog blanked.
+
+ Returns a ``mismatch`` :class:`IdentityCheck` instead when no dialog
+ holds the resolved point, the backend cannot locate one, or the frame
+ cannot be masked: a target recorded inside a dialog is not the same
+ target outside one.
+ """
+ probe = getattr(self.backend, "dialog_region_at", None)
+ status, box = "unknown", None
+ if callable(probe):
+ try:
+ status, box = probe(int(resolution.point[0]), int(resolution.point[1]))
+ except Exception:
+ status, box = "unknown", None
+ masked = (
+ identity_mod.mask_outside(before_png, box)
+ if status == "dialog" and box is not None
+ else None
+ )
+ if masked is not None:
+ self._identity_dialog_box = box
+ return masked
+ observed = (
+ "no dialog holds the target"
+ if status == "none"
+ else "the dialog holding the target could not be located"
+ )
+ return IdentityCheck(
+ status="mismatch",
+ mode="context",
+ expected="the target inside the dialog it was recorded in",
+ observed=observed,
+ )
+
@timed_span("identity")
def _verify_identity(
self,
@@ -15935,6 +15977,32 @@ def _verify_identity(
"""
anchor = step.anchor
assert anchor is not None
+ # The live dialog box this check reads in, for the extra looks it may
+ # take on fresh frames (see _look_again). None outside a dialog.
+ self._identity_dialog_box = None
+ unscoped_png = before_png
+ if anchor.dialog_region is not None:
+ # The target was recorded inside a dialog and its identity band
+ # was read on the dialog alone. Read the live band the same way,
+ # on the live topmost dialog, so the page behind it is not
+ # evidence (#1347). No dialog at the point, or one that cannot be
+ # located, fails the check closed.
+ scoped = self._dialog_scoped_frame(resolution, before_png)
+ if isinstance(scoped, IdentityCheck):
+ return scoped
+ before_png = scoped
+ if anchor.dialog_row is False:
+ # Whole-dialog text cannot identify a row's Select button.
+ # Reconfirm the recorded non-row role before any tier runs.
+ if (
+ self._structured_census(step, resolution.point).verdict
+ != "not_a_row"
+ ):
+ return IdentityCheck(
+ status="unreadable",
+ mode="context",
+ observed="the dialog control's non-row role could not be confirmed",
+ )
project = getattr(workflow, "qualification", None)
if project is not None:
identity_policy = project.identity_policies.get(step.id)
@@ -15942,10 +16010,12 @@ def _verify_identity(
identity_policy is not None
and identity_policy.enforcement.value == "signal_quorum"
):
+ # Quorum signals name their own regions (a banner outside
+ # the dialog is one), so they read the whole frame.
quorum = self._verify_signal_quorum(
step=step,
resolution=resolution,
- before_png=before_png,
+ before_png=unscoped_png,
params=runtime_params_for_gui(params),
workflow=workflow,
bundle_dir=bundle_dir,
@@ -16137,13 +16207,22 @@ def _ocr_census(
tmpl = anchor.identity_template
if not (anchor.context_text or (tmpl is not None and tmpl.tokens)):
return census_mod.CensusDecision("cannot_count")
+ if anchor.dialog_row is False and anchor.dialog_region is not None:
+ # Count through the live row probe; the dialog itself proves
+ # nothing about whether its target belongs to a record row.
+ return self._structured_census(step, resolution.point)
today = date.today()
+ dialog_box = self.__dict__.get("_identity_dialog_box")
try:
lines = [
line
for line in self.vision.ocr(before_png, region=None)
if line.text.strip()
and not identity_mod.is_volatile_line(line.text, reference_date=today)
+ and (
+ dialog_box is None
+ or identity_mod.centered_in(line.region, dialog_box)
+ )
]
except Exception:
return census_mod.CensusDecision("cannot_count")
@@ -16679,6 +16758,11 @@ def _verify_identity_ocr(
band = identity_mod.band_region(
resolution.point, anchor.region[3], self.backend.viewport
)
+ # Dialog pixels are scoped by _verify_identity. Row refinement still
+ # applies unless compile positively identified a standalone control.
+ dialog_box: Optional[Region] = self.__dict__.get("_identity_dialog_box")
+ if dialog_box is not None:
+ band = dialog_box
# The recorded band was extracted EXCLUDING the target's own crop
# (labels are mutable evidence the ladder heals through) and
# excluding volatile lines. The live band must mirror both, or
@@ -16728,7 +16812,8 @@ def attempt(
line.text, reference_date=today
)
]
- lines = identity_mod.lines_near_point(lines, point_y)
+ if dialog_box is None or anchor.dialog_row is not False:
+ lines = identity_mod.lines_near_point(lines, point_y)
if row_lines is not None:
row_lines.extend(lines)
observed = " ".join(line.text.strip() for line in lines)
@@ -16766,7 +16851,9 @@ def native_attempt(
# translated identifier region. OCRing the crop itself can
# change segmentation/glyph recognition and false-halt a
# healthy screen.
- None if identifier_region is not None else live_region,
+ None
+ if identifier_region is not None or dialog_box is not None
+ else live_region,
resolution.point[1],
exclude,
scoped_identifier=identifier_region is not None,
@@ -16860,6 +16947,7 @@ def with_looks(final: IdentityCheck) -> IdentityCheck:
check.status == "verified"
or identity_read_contradicts(check)
or identifier_region is not None
+ or dialog_box is not None
):
# A read of a different record is final: the row re-read below
# must never overrule it into "verified".
@@ -17361,6 +17449,18 @@ def _look_again(
for _ in range(looks_mod.FRAME_LOOKS):
time.sleep(self.poll_interval_s)
png = self.backend.screenshot()
+ box = self.__dict__.get("_identity_dialog_box")
+ if box is not None:
+ # Read the fresh frame in the same dialog as the first look.
+ masked = identity_mod.mask_outside(png, box)
+ if masked is None:
+ return first.model_copy(
+ update={
+ "status": "unreadable",
+ "look_halt": "identity_dialog_mask_failed",
+ }
+ )
+ png = masked
frames.append((read(png), sha256_bytes(png)))
if frames[-1][0].status == "mismatch":
break # final; no further looks
diff --git a/packages/flow/public-artifacts.json b/packages/flow/public-artifacts.json
index 9efad21e89..2b23af02aa 100644
--- a/packages/flow/public-artifacts.json
+++ b/packages/flow/public-artifacts.json
@@ -421,6 +421,11 @@
"benchmark/openemr_local/verify.yaml",
"benchmark/openemr_referral_timing/figures.json",
"benchmark/openemr_referral_timing/runs.csv",
+ "benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-after-branch.json",
+ "benchmark/openemr_throughput/dialog_identity_2026-10-28/batch-before-main.json",
+ "benchmark/openemr_throughput/dialog_identity_2026-10-28/listed-duplicate.json",
+ "benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-ocr-tier.json",
+ "benchmark/openemr_throughput/dialog_identity_2026-10-28/scroll-openemr-adapter.json",
"benchmark/openimis_claims/deployment.eligibility.yaml",
"benchmark/openimis_claims/environment.lock.json",
"benchmark/openimis_claims/submit_binding_probe.json",
diff --git a/packages/flow/tests/test_bundle_schema_v2.py b/packages/flow/tests/test_bundle_schema_v2.py
index 20953da05e..ae254c499e 100644
--- a/packages/flow/tests/test_bundle_schema_v2.py
+++ b/packages/flow/tests/test_bundle_schema_v2.py
@@ -1092,3 +1092,31 @@ def test_a_read_spec_and_grade_floor_are_sealed_content(tmp_path):
)
content = bv._workflow_content(read)
assert content["steps"][0]["read"]["field"]["name"] == "amount"
+
+
+@pytest.mark.parametrize("shape", ["linear", "program"])
+def test_dialog_row_evidence_preserves_frozen_legacy_seal_and_is_tamper_evident(
+ tmp_path, shape
+):
+ b = _write_bundle_dir(tmp_path)
+ wf = _structural_workflow(shape)
+ old_digest = _write_synthetic_pre_field_bundle(b, wf, shape, encrypted=False)
+ assert old_digest == _SYNTHETIC_LEGACY_DIGESTS[shape]
+ raw = json.loads((b / "workflow.json").read_text())
+ assert "dialog_row" not in _anchor_payload(raw, shape)
+ loaded = Workflow.load(b, verify_integrity=True)
+ anchor = (
+ loaded.steps[0].anchor
+ if shape == "linear"
+ else loaded.program.states["s1"].step.anchor
+ )
+ assert anchor.dialog_row is None
+ for evidence in (True, False):
+ anchor.dialog_row = evidence
+ assert (
+ bv.compute_content_digest(loaded, loaded.manifest.file_hashes) != old_digest
+ )
+ _anchor_payload(raw, shape)["dialog_row"] = evidence
+ (b / "workflow.json").write_text(json.dumps(raw))
+ with pytest.raises(bv.BundleIntegrityError):
+ Workflow.load(b, verify_integrity=True)
diff --git a/packages/flow/tests/test_dialog_identity_live_openemr.py b/packages/flow/tests/test_dialog_identity_live_openemr.py
new file mode 100644
index 0000000000..6832360c89
--- /dev/null
+++ b/packages/flow/tests/test_dialog_identity_live_openemr.py
@@ -0,0 +1,103 @@
+"""Live check of #1347 on a real OpenEMR: the duplicate dialog's identity band.
+
+OpenEMR's duplicate check opens "Confirm Create New Patient" in a modal over
+the New Patient form. The full-width OCR band around that button also read a
+label of the form behind the modal, which one depended on how far the form was
+scrolled, so a correct save was refused every 9 to 17 items. This test opens
+the real dialog with the form scrolled to several positions and checks that the
+band read on the dialog alone (the frame blanked outside
+``PlaywrightBackend.dialog_region_at``) is the same every time and holds no
+form label.
+
+Bring up the throughput fixture first (see
+``benchmark/openemr_throughput/README.md``), then::
+
+ OPENEMR_LIVE_STATE=~/oa/scratch/throughput/fixture/state \\
+ THROUGHPUT_UI_PORT=9911 THROUGHPUT_API_PORT=9910 \\
+ OPENADAPT_FLOW_BROWSER=bundled \\
+ PYTHONPATH=../types:. python -m pytest tests/test_dialog_identity_live_openemr.py -v
+
+Skipped unless ``OPENEMR_LIVE_STATE`` is set. It saves nothing: each dialog is
+left unconfirmed and its browser closed.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+
+pytestmark = pytest.mark.skipif(
+ not os.environ.get("OPENEMR_LIVE_STATE"),
+ reason="live OpenEMR: set OPENEMR_LIVE_STATE to the fixture state directory",
+)
+
+# Form scroll positions behind the dialog. 0 leaves "County:" beside the
+# dialog's button row in the pinned image; the others move other labels there.
+SCROLLS = (0, 250, 600, 900)
+
+
+def _open_dialog(demo, fixture, scroll_y: int):
+ backend, close = demo.OpenEMRPlaywrightBackend.launch(
+ fixture.ui_base_url, headless=True
+ )
+ demo._preauthenticate_browser(backend, fixture)
+ page = backend.page
+ form = demo._form_frame(page)
+ form.locator("#form_fname").fill("Wilhelmina")
+ form.locator("#form_lname").fill("Okonkwo-Hale")
+ form.locator("#form_DOB").fill("1961-04-09")
+ form.locator("#form_DOB").press("Escape") # close the date picker
+ form.locator("#form_sex").select_option(label="Female")
+ form.locator("#create").scroll_into_view_if_needed()
+ form.evaluate(f"() => window.scrollTo(0, {int(scroll_y)})")
+ form.locator("#create").evaluate("el => el.click()")
+ confirm = demo._confirm_create_locator(page)
+ return backend, close, demo._center(confirm)
+
+
+def test_duplicate_dialog_band_ignores_the_form_behind() -> None:
+ from benchmark.openemr_throughput import harness
+ from openadapt_flow.runtime import identity as identity_mod
+ from openadapt_flow.vision.ocr import ocr
+
+ demo = harness._demo_module()
+ fixture = harness.make_fixture(Path(os.environ["OPENEMR_LIVE_STATE"]).expanduser())
+ full_bands: list[str] = []
+ dialog_bands: list[str] = []
+ for scroll_y in SCROLLS:
+ backend, close, point = _open_dialog(demo, fixture, scroll_y)
+ try:
+ status, box = backend.dialog_region_at(*point)
+ assert status == "dialog", (scroll_y, status)
+ assert box is not None
+ frame = backend.screenshot()
+ viewport = backend.viewport
+ band = identity_mod.band_region(point, 64, viewport)
+ # The button's own crop is excluded, as the compiler excludes it.
+ exclude = (point[0] - 80, point[1] - 32, 160, 64)
+
+ def read(png: bytes, scope=None) -> str:
+ return (
+ identity_mod.context_from_lines(
+ ocr(png),
+ exclude_region=exclude,
+ band=band,
+ point=point,
+ scope=scope,
+ )
+ or ""
+ )
+
+ full_bands.append(read(frame))
+ masked = identity_mod.mask_outside(frame, box)
+ assert masked is not None
+ dialog_bands.append(read(masked, box))
+ finally:
+ close()
+ print("full-width bands:", full_bands)
+ print("dialog bands:", dialog_bands)
+ assert len(set(dialog_bands)) == 1, dialog_bands
+ for label in ("County", "Emergency", "Contact", "Phone", "Address"):
+ assert label not in dialog_bands[0], dialog_bands[0]
diff --git a/packages/flow/tests/test_dialog_row_identity.py b/packages/flow/tests/test_dialog_row_identity.py
new file mode 100644
index 0000000000..8a4cb9c026
--- /dev/null
+++ b/packages/flow/tests/test_dialog_row_identity.py
@@ -0,0 +1,354 @@
+"""A modal scopes identity to its contents without merging its record rows."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+
+import cv2
+import numpy as np
+import pytest
+
+from openadapt_flow.compiler import compile_recording
+from openadapt_flow.ir import (
+ ActionKind,
+ Anchor,
+ IdentityCheck,
+ Resolution,
+ Step,
+ Workflow,
+)
+from openadapt_flow.runtime import identity as identity_mod
+from openadapt_flow.runtime.replayer import Replayer
+
+DIALOG = (200, 50, 600, 300)
+
+
+@dataclass
+class Line:
+ text: str
+ region: tuple[int, int, int, int]
+ confidence: float = 0.99
+
+
+def frame():
+ ok, encoded = cv2.imencode(".png", np.zeros((400, 1000, 3), np.uint8))
+ assert ok
+ return encoded.tobytes()
+
+
+def resolution(y):
+ return Resolution(rung="template", point=(730, y), confidence=1, elapsed_ms=1)
+
+
+def step(context, *, dialog_row=None):
+ return Step(
+ id="select",
+ intent="select the recorded record",
+ action=ActionKind.CLICK,
+ anchor=Anchor(
+ template="unused.png",
+ region=(650, 118, 160, 64),
+ click_point=(730, 150),
+ context_text=context,
+ dialog_region=DIALOG,
+ **({"dialog_row": dialog_row} if dialog_row is not None else {}),
+ ),
+ )
+
+
+class Backend:
+ viewport = (1000, 400)
+
+ def __init__(self, census=None):
+ self.census = census
+
+ def dialog_region_at(self, x, y):
+ return "dialog", DIALOG
+
+ def identity_candidates_at(self, x, y):
+ return self.census
+
+ def screenshot(self):
+ return frame()
+
+
+class Vision:
+ def __init__(self, lines):
+ self.lines = lines
+
+ def ocr(self, png, region=None):
+ return self.lines
+
+
+def check(lines, anchor_step, *, y=150, census=None):
+ replayer = Replayer(Backend(census), vision=Vision(lines), poll_interval_s=0)
+ return replayer._verify_identity(
+ anchor_step, resolution(y), frame(), {}, Workflow(name="dialog")
+ )
+
+
+def test_grok_wrong_row_probe_keeps_the_clicked_row_inside_dialog():
+ # Independent regression for the review probe: every visible token is the
+ # same when resolving Jane's button one row down to Ann's button.
+ lines = [
+ Line("Select patient", (220, 60, 160, 20)),
+ Line("Jane Lindqvist", (220, 140, 160, 20)),
+ Line("Room 7 East", (450, 140, 120, 20)),
+ Line("Select", (700, 140, 60, 20)),
+ Line("Ann Wuttke", (220, 240, 160, 20)),
+ Line("Room 9 West", (450, 240, 120, 20)),
+ Line("Select", (700, 240, 60, 20)),
+ Line("Cancel", (700, 310, 60, 20)),
+ ]
+ recorded = identity_mod.context_from_lines(
+ lines,
+ exclude_region=(650, 118, 160, 64),
+ band=DIALOG,
+ point=(730, 150),
+ scope=DIALOG,
+ )
+ assert recorded == "Jane Lindqvist Room 7 East"
+ assert check(lines, step(recorded)).status == "verified"
+ wrong = check(lines, step(recorded), y=250)
+ assert wrong.status == "mismatch", wrong
+ assert "Ann Wuttke" in wrong.observed
+ assert "Jane Lindqvist" not in wrong.observed
+
+
+def test_legacy_whole_dialog_context_cannot_verify_the_wrong_row():
+ # Bundles compiled before row refinement have the entire modal's text.
+ # Replaying one must fail closed rather than accept another row's button.
+ lines = [
+ Line("Select patient", (220, 60, 160, 20)),
+ Line("Jane Lindqvist Room 7 East", (220, 140, 350, 20)),
+ Line("Select", (700, 140, 60, 20)),
+ Line("Ann Wuttke Room 9 West", (220, 240, 350, 20)),
+ Line("Select", (700, 240, 60, 20)),
+ ]
+ legacy_context = identity_mod.context_from_lines(
+ lines, exclude_region=(650, 118, 160, 64), band=DIALOG, scope=DIALOG
+ )
+ assert legacy_context is not None
+ wrong = check(lines, step(legacy_context), y=250)
+ assert wrong.status != "verified", wrong
+
+
+def test_dialog_census_stops_identical_rows():
+ lines = [Line("Jane Lindqvist Room 7 East", (220, y, 350, 20)) for y in (140, 240)]
+ result = check(lines, step("Jane Lindqvist Room 7 East"))
+ assert result.status == "abstain"
+ assert result.ambiguity_halt == "identical_candidates"
+ assert result.candidate_count == 2
+
+
+def test_dialog_census_ignores_rows_behind_the_modal():
+ lines = [
+ Line("Jane Lindqvist Room 7 East", (220, 140, 350, 20)),
+ Line("Jane Lindqvist Room 7 East", (10, 365, 350, 20)),
+ ]
+ result = check(lines, step("Jane Lindqvist Room 7 East"))
+ assert result.status == "verified"
+ assert result.candidate_count == 1
+
+
+@pytest.mark.parametrize(
+ "census",
+ [None, {"row": True, "uncountable": True}, {"candidates": [{"self": True}]}],
+)
+def test_whole_dialog_requires_positive_live_nonrow_census(census):
+ result = check(
+ [Line("No matching records were found", (220, 90, 350, 20))],
+ step("No matching records were found", dialog_row=False),
+ census=census,
+ )
+ assert result.status == "unreadable"
+
+
+def test_standalone_confirmation_can_read_dialog_result():
+ result = check(
+ [Line("No matching records were found", (220, 90, 350, 20))],
+ step("No matching records were found", dialog_row=False),
+ census={"row": False},
+ )
+ assert result.status == "verified"
+ assert result.census_status == "not_a_row"
+
+
+def test_fresh_look_mask_failure_never_reads_unscoped_frame(monkeypatch):
+ replayer = Replayer(Backend(), vision=Vision([]), poll_interval_s=0)
+ replayer._identity_dialog_box = DIALOG
+ monkeypatch.setattr(identity_mod, "mask_outside", lambda *args: None)
+ reads = []
+ first = IdentityCheck(
+ status="abstain", abstain_reason="glyph_confusable_identifier"
+ )
+ result = replayer._look_again(
+ step("C0X1234 Jane Lindqvist"),
+ first,
+ None,
+ lambda png: reads.append(png) or IdentityCheck(status="verified"),
+ {},
+ Workflow(name="dialog"),
+ None,
+ frame(),
+ )
+ assert reads == []
+ assert result.status == "unreadable"
+ assert result.look_halt == "identity_dialog_mask_failed"
+
+
+def test_unset_dialog_row_preserves_old_anchor_serialization():
+ assert "dialog_row" not in step("Jane Lindqvist").anchor.model_dump()
+
+
+@pytest.fixture
+def live_dialog_page():
+ from playwright.sync_api import sync_playwright
+
+ from openadapt_flow.backends.playwright_backend import PlaywrightBackend
+
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(headless=True)
+ page = browser.new_page(viewport={"width": 1000, "height": 500})
+ yield page, PlaywrightBackend(page)
+ browser.close()
+
+
+def _compile_page(page, backend, target, tmp_path, *, known_nonrow=False):
+ box = page.locator(target).bounding_box()
+ point = (int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2))
+ status, dialog = backend.dialog_region_at(*point)
+ assert status == "dialog"
+ png = backend.screenshot()
+ recording = tmp_path / "recording"
+ (recording / "frames").mkdir(parents=True)
+ for suffix in ("before", "after"):
+ (recording / "frames" / f"0000_{suffix}.png").write_bytes(png)
+ event = {
+ "i": 0,
+ "kind": "click",
+ "x": point[0],
+ "y": point[1],
+ "t": 1.0,
+ "dialog_region": list(dialog),
+ }
+ if known_nonrow:
+ census = backend.identity_candidates_at(*point)
+ assert census == {"row": False, "top_level": True}
+ event["dialog_row"] = False
+ (recording / "events.jsonl").write_text(json.dumps(event) + "\n")
+ (recording / "meta.json").write_text(
+ json.dumps(
+ {
+ "id": "dialog-row-regression",
+ "created_at": "2026-11-04T00:00:00+00:00",
+ "viewport": [1000, 500],
+ "params": {},
+ }
+ )
+ )
+ workflow = compile_recording(recording, tmp_path / "bundle", name="dialog-row")
+ anchor_step = next(s for s in workflow.steps if s.anchor is not None)
+ return workflow, anchor_step, point
+
+
+@pytest.mark.parametrize("duplicate", [False, True])
+def test_chromium_compile_and_ocr_refuse_wrong_dialog_row(
+ live_dialog_page, tmp_path, duplicate
+):
+ page, backend = live_dialog_page
+ second = "Jane Lindqvist Room 7 East" if duplicate else "Ann Wuttke Room 9 West"
+ page.set_content(
+ """
+
+ """)
+ box = page.locator("#first").bounding_box()
+ census = backend.identity_candidates_at(
+ int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2)
+ )
+ assert len(census["candidates"]) == 2, census
diff --git a/packages/flow/tests/test_dialog_scoped_identity.py b/packages/flow/tests/test_dialog_scoped_identity.py
new file mode 100644
index 0000000000..bd20398de4
--- /dev/null
+++ b/packages/flow/tests/test_dialog_scoped_identity.py
@@ -0,0 +1,301 @@
+"""The identity band of a target inside a dialog is read on the dialog alone.
+
+OpenEMR's "Confirm Create New Patient" sits in a Bootstrap modal over the New
+Patient form. The OCR identity band is a full-width strip, so it also read a
+form label beside the modal: "County:" in one run, "Emergency Contact:" in
+another, depending on where the form behind happened to be scrolled. The check
+then refused a correct save every 9 to 17 items (#1347).
+
+The recorder now stores the box of the topmost dialog holding the target, the
+compiler reads the band on the frame with everything outside that box blanked,
+and replay does the same on the live dialog. No dialog at the point fails the
+check closed.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+
+import cv2
+import numpy as np
+import pytest
+
+from openadapt_flow.compiler.compile import _event_dialog_region
+from openadapt_flow.ir import Anchor, IdentityCheck
+from openadapt_flow.runtime import identity as identity_mod
+from openadapt_flow.runtime.replayer import Replayer
+
+# A form behind a Bootstrap-style modal: the overlay covers the viewport and
+# the visible box is its .modal-dialog. The dialog's buttons live in a
+# same-origin iframe, as OpenEMR's dlgopen dialogs do.
+_PAGE_HTML = """
+
+