#!/usr/bin/env bash
#
# shcp-update — the SHCP update apply engine (UPD-1 skeleton).
#
# Standalone, self-contained bash program shipped as the `shcp-updater` deb
# and installed at /usr/sbin/shcp-update. It runs OUTSIDE the panel runtime
# (plan AD-1): the panel requests work by writing a validated request file
# and starting the fixed systemd unit shcp-update-apply.service — it can
# never pass arbitrary argv (SC-317).
#
# Verbs (one line each; `shcp-update --help` carries the full text, and
# usage() is the ONE place that text lives — this list is kept in step with
# main()'s case arms by tests/test-updater-reconcile-check.sh):
#   check                  read-only preflight (--blockers / --pending / --reboot); structured JSON
#   apply                  run the state machine (request file / security / reinstall)
#   resume                 continue an interrupted or reboot-parked run
#   rollback <run-id>      SC-071: confirms when interactive
#   repair-series-pointer  restore a failed series run's package-manager pointer
#   status                 progress of the latest run
#   self-test              dependency / writability probe (JSON)
#   maintenance            operator escape hatch for the maintenance flag
#   reconcile-config       UPD-14 drift: --check (read-only) / --apply (additive fixes)
#   converge-installer     SC-692: run the paired installer's shcp-converge (always JSON)
#   verify-initramfs       SC-783: audit (--repair: regenerate) every installed kernel's initramfs
#   capabilities           what this engine implements, for its callers (JSON, no lock)
#   webmail                activate the installed shcp-webmail payload, or recover a run (WM-6)
#   ledger                 the update ledger: --validate FILE (no root, no lock, no state) | --apply | --check | --hold-check | --adopt PATH
#   fix-db-ownership       re-chown the panel DB + sidecars, symlink-safe (SC-663).
#         The ONE verb that writes WITHOUT the run lock, deliberately: it is
#         shcp-update-drift.service's ExecStartPost and may overlap an apply's
#         DB swap. Safe because db_fix_ownership refuses any target that is not
#         a lone regular file, chowns -h, and exits 0 — it can never redirect a
#         chown or fail the unit, and a swap it races only leaves the file the
#         apply's own snapshot/db stage re-owns anyway.
# Global options (accepted BEFORE the verb as well as after it): --json, -h/--help.
#
# State machine (plan §4.2): self-update → preflight → snapshot → apt →
# panel → db → restart → health → finalize. UPD-2 implements the apt and
# restart bodies (OS package upgrade per scope + §4.4 restart/reboot detection)
# behind the OS-family seam (osf_*); UPD-3 implements the panel body (blue/green
# release install inside the layout the 1.0 installer creates); UPD-4 implements
# the snapshot and db bodies (SQLite online backup + integrity gates + the
# schema-upgrade seam) and the restore helper rollback calls; UPD-5 implements
# the health stage (§4.2-7), the rollback verb (§4.5) and the auto-rollback
# wiring stage 8 describes, plus release-dir retention in finalize. The stage
# runner, journal and status.json contracts are unchanged
# (fixtures/update-contract/ in shcp-master).
#
# Every run is journaled at ${STATE_DIR}/runs/<run-id>/journal.json with
# atomic tmp+rename writes; `resume` re-enters at the first stage without a
# stage_done record (AD-2). status.json (§4.9) is rewritten on every stage
# transition for the panel poller / `status --follow`.
#
# Security checkpoints honored here:
#   SC-317 — request file strictly validated (allow-listed
#     keys, regex-validated values, root:root 0600); no eval, no unquoted
#     expansion of request values anywhere.
#   SC-069 — executed under the hardened shcp-update-apply.service.
#   SC-071 — destructive verbs (rollback) confirm when invoked interactively.
#
# Environment overrides (test seams — production uses the defaults):
#   SHCP_UPDATE_STATE_DIR   default /var/lib/shcp/update
#   SHCP_UPDATE_LOCK        default /run/shcp/shcp-update.lock
#   SHCP_UPDATE_DB          default /var/lib/shcp/db/shcp.db — the LIVE panel
#                           database: the settings source, the snapshot source
#                           (UPD-4) and the only file a rollback overwrites.
#                           SC-663: relocated from the top level into the
#                           panel-owned db/ child (the parent is now root-owned)
#   SHCP_UPDATE_EXPECT_UID/GID  required owner of request.json (default 0/0)
#   SHCP_UPDATE_FORCE_WORKSET   pretend preflight found work (dev/tests;
#                               real apt detection also lands in UPD-2)
#   SHCP_UPDATE_CRASH_STAGE     kill -9 self at the named stage (crash drills)
#   SHCP_UPDATE_REBOOT_AT_STAGE park for a deliberate reboot AFTER the named
#                               stage (cross-reboot-resume drills; extension
#                               point for the OS-upgrade epic)
#   SHCP_UPDATE_REBOOT_CMD      how to reboot (default `systemctl reboot`; a test
#                               stub stands in so the suite never reboots)
#   SHCP_UPDATE_REBOOT_STALE_AFTER  seconds before `check` escalates an
#                               awaiting_reboot run to a blocker (default 1800)
#   SHCP_UPDATE_REBOOT_MAX      max park→reboot cycles per run (default 6)
#   SHCP_UPDATE_OS_FAMILY       force deb|rpm instead of probing /etc/os-release
#   SHCP_UPDATE_OS_RELEASE      path of the os-release file probed (tests)
#   SHCP_UPDATE_REBOOT_FILE     path of the deb reboot-required marker (tests)
#   SHCP_UPDATE_NEEDRESTART_KSTA  force needrestart's kernel verdict (0-3) on
#                               deb instead of running `needrestart -b` (tests)
#   SHCP_UPDATE_RUNNING_KERNEL  the running kernel release instead of `uname -r`
#                               (tests; rollback never removes its package, deb)
#   SHCP_DNF_CACHE_DIR          default /var/cache/dnf — the rpm index-age probe
#   SHCP_MANAGED_RESTART_TIMEOUT  wall-clock bound, seconds, on EVERY systemctl
#                               start/restart stage_restart issues (default 120)
#   SHCP_UPDATE_PANEL_LINK      default /opt/shcp          (UPD-3 layout, §4.12)
#   SHCP_UPDATE_RELEASES_DIR    default /opt/shcp-releases
#   SHCP_UPDATE_PANEL_VAR       default /var/lib/shcp/panel-state
#   SHCP_UPDATE_PANEL_ENV       default /etc/shcp/panel.env
#   SHCP_UPDATE_MANIFEST_URL    default https://repo.shcp.dev/releases/…json
#   SHCP_UPDATE_KEYRING         default /usr/share/keyrings/shcp-release-keyring.gpg
#   SHCP_UPDATE_RELEASE_FPR     pinned release PRIMARY fingerprint
#   SHCP_UPDATE_VERSION_FLOOR   default /var/lib/shcp/.shcp-version-floor
#   SHCP_UPDATE_MANIFEST_GENERATED_AT  default
#                               /var/lib/shcp/.shcp-manifest-generated-at — the
#                               SC-472 freshness floor
#   SHCP_UPDATE_SERIES_MIN_FREE_KB  series-run disk headroom floor (KiB, default
#                               2097152 = 2 GiB); series preflight blocker
#   SHCP_UPDATE_LICENSE_STATE   series preflight seam: overrides the panel's
#                               recorded license state (never a blocker)
#   SHCP_UPDATE_LAST_BACKUP_AT  series preflight seam: overrides the recorded
#                               last account-backup timestamp (freshness WARNING)
#   SHCP_UPDATE_BACKUP_MAX_AGE  how old that backup may be before the WARNING
#                               fires (seconds, default 604800 = 7 days)
#   SHCP_UPDATE_SHCPD_BIN       default /usr/sbin/shcpd
#   SHCP_UPDATE_DB_BACKUP_DIR   default /var/lib/shcp/db-backups/pre-update
#                               (UPD-4 snapshot tree, SC-272)
#   SHCP_UPDATE_DB_OWNER        default shcp:shcp — owner forced onto the panel
#                               DB, its WAL/SHM sidecars and every snapshot
#   SHCP_UPDATE_CONFIG_ROOT     default / — prefix of the §4.2-2 config-tar
#                               source paths (/etc/shcp*, apt sources/pins,
#                               systemd overrides)
#   SHCP_UPDATE_PG_FACTS        default /etc/shcp/postgresql.json — the
#                               installer's PostgreSQL facts file; names the
#                               server unit (pgsql_facts_unit)
#   SHCP_UPDATE_PG_MARKER       default /etc/shcp/features/postgresql — the
#                               admin's request for PostgreSQL
#   SHCP_UPDATE_PG_CLUSTER      default /etc/shcp/postgresql/cluster — the
#                               installer's record that a cluster exists
#                               (both read by pgsql_converge_pending)
#   SHCP_UPDATE_HEALTH_URL      the §4.2-7 liveness probe. Default
#                               https://127.0.0.1:<panel.port>/up — the panel
#                               listens on shcpd's TLS port, NOT on :80 (that is
#                               Apache, i.e. TENANT content). Set it to the
#                               empty string to skip the probe entirely.
#   SHCP_UPDATE_FORCE_HEALTH    healthy|unhealthy — force the health verdict
#                               (drills, and the suites that predate the real
#                               stage). Always recorded as "forced": true.
#   SHCP_APT_FETCH_ATTEMPTS     how many times an apt-get whose only failure was
#                               a download is run (default 4, shcp-updater#174)
#   SHCP_APT_FETCH_BACKOFF      backoff unit between those runs, seconds x
#                               attempt (default 5); 0 in tests
#   SHCP_UPDATE_HEALTH_BACKOFF  seconds between health attempts (default 45, so
#                               3 attempts span the plan's 90 s window); 0 in
#                               tests so a grace drill does not sleep.
#   SHCP_UPDATE_DECL_ENGINE     UPD-14 engine-half declaration; default
#                               /usr/lib/shcp-update/required-state.d/00-engine.json
#                               (ships in THIS .deb, so it reaches a box whose
#                               update timers are missing — which is the box the
#                               drift check exists for)
#   SHCP_UPDATE_DECL_RELEASE    UPD-14 release-half declaration; default
#                               ${PANEL_LINK}/config/system/required-state.json
#   SHCP_UPDATE_RECONCILE_EXEMPT  operator's per-box exemption record; default
#                               /etc/shcp-update/reconcile-exempt (root:root)
#   SHCP_UPDATE_APACHE_SITES    default resolved from OS_FAMILY —
#                               /etc/apache2/sites-available (deb) or
#                               /etc/httpd/conf.d (rpm); an explicit value wins
#   SHCP_UPDATE_SHCPD_UNIT      default shcpd — the unit whose Environment= and
#                               EnvironmentFile= the env predicate consults
#   SHCP_UPDATE_BOOT_DIR        default /boot — kernels + initramfs images audited
#                               by verify-initramfs / initramfs_gate
#   SHCP_UPDATE_MODULES_DIR     default /usr/lib/modules
#   SHCP_UPDATE_MOUNTINFO       default /proc/self/mountinfo — how the audit
#                               learns the module tree is hidden from it
#   SHCP_UPDATE_SELF            default /usr/sbin/shcp-update — what the audit
#                               runs in a transient unit when it is
#
# The script is source-able (BASH_SOURCE guard at the bottom) so the bash
# test suite can exercise individual helpers without a process boundary.

set -euo pipefail
export LC_ALL=C

# --- paths -------------------------------------------------------------------
SHCP_UPDATE_STATE_DIR="${SHCP_UPDATE_STATE_DIR:-/var/lib/shcp/update}"
SHCP_UPDATE_LOCK="${SHCP_UPDATE_LOCK:-/run/shcp/shcp-update.lock}"
# SC-500: the run lock moved out of world-writable /run/lock into root-owned
# /run/shcp, where an unprivileged account cannot pre-create or hold it (the
# same move shcp-config made -- flock(2) needs no write access, so a 0644 lock
# in a 1777 dir, or a foreign/symlinked squat, denies every root run).
#
# A best-effort LEGACY peer in /run/lock used to be taken alongside this lock so
# a pre-#1754 panel's read-only UPD-4 probe (UpdaterLock) still saw a holder
# during a run. It was removed once the deployed fleet minimum panel reached
# >= v0.0.62: base#1754 re-pointed UpdaterLock at /run/shcp, and no deployed
# panel < v0.0.62 (the versions that still probe /run/lock) remains, so nothing
# reads the peer any more (shcp-master#2152, SC-318).
SHCP_UPDATE_DB="${SHCP_UPDATE_DB:-/var/lib/shcp/db/shcp.db}"
SHCP_UPDATE_EXPECT_UID="${SHCP_UPDATE_EXPECT_UID:-0}"
SHCP_UPDATE_EXPECT_GID="${SHCP_UPDATE_EXPECT_GID:-0}"

# Every apt call below that TAKES A LOCK passes this. apt's default is 0, i.e.
# fail immediately, so any concurrent apt — an operator's `apt install`, a live
# apt-daily on a host that still has unattended-upgrades, cloud-init on a fresh
# boot — turns a routine run into a hard stage failure with a misleading reason.
# The installer has passed it since its own lock-timeout work (shcp.sh:583) and
# uses the same env var, so a host tuned for one is tuned for both.
#
# deb only: dnf and yum already block on their own lock.
SHCP_APT_LOCK_TIMEOUT="${SHCP_APT_LOCK_TIMEOUT:-300}"
APT_LOCK_OPT=(-o "DPkg::Lock::Timeout=${SHCP_APT_LOCK_TIMEOUT}")

# Every apt LISTING (`apt-get -s --with-new-pkgs upgrade`/`dist-upgrade`) and every apt APPLY
# (apt_get_fetch) passes this. Ubuntu (26.04 among the 1.0 targets) phases
# updates: apt's resolver holds a phased package back on a pseudo-random subset
# of machines (APT::Machine-ID), and `apt-get -s upgrade` honours that, so the
# engine on the held-back subset never even LISTS the package — it is neither
# applied nor reported, and the fleet's security posture differs box by box for
# no reason an operator can see (SC-319). Always-Include-Phased-Updates makes
# every box see, and apply, the same set on the same night. Unknown to Debian's
# apt, which ignores an unrecognised -o. deb only, like APT_LOCK_OPT.
APT_PHASED_OPT=(-o APT::Get::Always-Include-Phased-Updates=true)

# How many times apt_get_fetch runs a command whose only failure was a download,
# and the backoff unit between runs (5, 10, 15 s by default). Same defaults as
# the installer's APT_FETCH_ATTEMPTS / APT_FETCH_BACKOFF_SECS (installer#946).
SHCP_APT_FETCH_ATTEMPTS="${SHCP_APT_FETCH_ATTEMPTS:-4}"
SHCP_APT_FETCH_BACKOFF="${SHCP_APT_FETCH_BACKOFF:-5}"

# How old the package index may be before a FAILED refresh becomes a refusal
# rather than a shrug. 7 days.
SHCP_APT_INDEX_MAX_AGE="${SHCP_APT_INDEX_MAX_AGE:-604800}"

# SC-changelog-argv-e2big: the per-package changelog text stage_apt hands to jq
# via `--arg` travels as ONE execve() argument, and Linux caps a single
# argument at 131072 bytes (MAX_ARG_STRLEN) regardless of ARG_MAX — a limit
# jq's own argv has no way to raise. Measured live (UPD-11 drill,
# demo.shcp.dev): openssl's real changelog alone runs ~113KB, so two or three
# queued security packages routinely exceed it, and jq's exec then fails
# E2BIG, which journal_update turns into `die` and kills the whole apt stage.
# The text is best-effort/display-only (see stage_apt), so cap it well under
# the kernel limit rather than let a changelog nobody is required to finish
# reading silently stop security patching (SC-319).
SHCP_CHANGELOG_MAX_BYTES="${SHCP_CHANGELOG_MAX_BYTES:-65536}"

# SC-319: how stale `last_security_success` may get before `check` raises it
# as an advisory. Mirrors UpdateCheckCommand::SECURITY_STALE_SECONDS on the
# panel side (shcp-base) — same threshold, same rule, two surfaces.
SHCP_SECURITY_STALE_SECONDS="${SHCP_SECURITY_STALE_SECONDS:-259200}"

# EP-2 (#138): how long the versions-manifest fetch may keep failing before
# `check` raises the manifest_fetch_failing advisory. A SEPARATE knob from
# SHCP_SECURITY_STALE_SECONDS so "behind on security patches" and "cannot see
# the update manifest at all" stay independently tunable and independently
# reported — the two conditions have different causes and different fixes.
SHCP_MANIFEST_FETCH_STALE_SECONDS="${SHCP_MANIFEST_FETCH_STALE_SECONDS:-259200}"

RUNS_DIR="${SHCP_UPDATE_STATE_DIR}/runs"
REQUEST_FILE="${SHCP_UPDATE_STATE_DIR}/request.json"
MAINT_FLAG="${SHCP_UPDATE_STATE_DIR}/maintenance.json"

# SC-050 auth-log protection targets. Tests redirect these into a private tree;
# shipped units set none of the overrides, so production always uses the fixed
# root-owned paths.
SHCP_AUTH_LOG="${SHCP_UPDATE_AUTH_LOG:-/var/log/shcp/auth.log}"
SHCP_AUTH_LOG_DIR="${SHCP_UPDATE_AUTH_LOG_DIR:-$(dirname -- "${SHCP_AUTH_LOG}")}"
SHCP_AUTH_LOG_GUARD="${SHCP_UPDATE_AUTH_LOG_GUARD:-/usr/libexec/shcp/shcp-authlog-rotate}"
SHCP_AUTH_LOGROTATE_CONF="${SHCP_UPDATE_AUTH_LOGROTATE_CONF:-/etc/logrotate.d/shcp-authlog}"
SHCP_AUTH_LOGROTATE_DROPIN="${SHCP_UPDATE_AUTH_LOGROTATE_DROPIN:-/etc/systemd/system/logrotate.service.d/shcp-authlog.conf}"

# The awaiting-reboot marker (UPD cross-reboot resume, shcp-build#97 /
# SC-532). Its PRESENCE is the ConditionPathExists= gate
# for shcp-update-resume.service: written only by park_for_reboot, cleared by a
# resume. Derived from STATE_DIR so the bash suite sandboxes it the same way it
# sandboxes MAINT_FLAG; the systemd unit hardcodes the production absolute path
# /var/lib/shcp/update/awaiting-reboot, which is exactly what this resolves to at
# the default STATE_DIR. Owned root:root 0600 like maintenance.json — the panel
# never reads it. NEVER trusted by content alone: the resume path re-validates
# owner/mode (path_uid0_not_writable) because /var/lib/shcp is shcp-owned.
AWAITING_REBOOT_MARKER="${SHCP_UPDATE_STATE_DIR}/awaiting-reboot"

# How the engine reboots when a stage parks a run for a deliberate reboot, and
# the test seam that stands in for it. Default systemctl; a test overrides it
# with a stub so the suite never actually reboots the runner.
SHCP_UPDATE_REBOOT_CMD="${SHCP_UPDATE_REBOOT_CMD:-systemctl reboot}"

# The kernel's per-boot random UUID, regenerated on every boot. park_for_reboot
# records it in the journal so `resume --at-boot` can PROVE the box actually
# rebooted before continuing a parked run: the boot unit is gated on a marker
# file, not on a real reboot, so a marker that survives (a same-boot re-fire, a
# replay, a manual `systemctl start` of the resume unit) would otherwise let a
# run "resume" without the reboot its parked stage demanded. Path override, the
# same seam shape as SHCP_UPDATE_REBOOT_FILE, so the suite points it at a temp
# file and rewrites its contents to model same-boot vs new-boot.
BOOT_ID_FILE="${SHCP_UPDATE_BOOT_ID_FILE:-/proc/sys/kernel/random/boot_id}"

# How long an awaiting_reboot run may sit before `check` escalates it from an
# expected pause to a blocker (the box rebooted and never resumed, or never
# rebooted at all). 1800s: a security reboot + boot is well under a minute even
# on the slow arm64/EL legs; a distro upgrade is the outlier and the OS-upgrade
# epic raises this. Distinct from stale_running_journal, which has NO age gate
# because a crashed run should surface immediately.
SHCP_UPDATE_REBOOT_STALE_AFTER="${SHCP_UPDATE_REBOOT_STALE_AFTER:-1800}"

# Ceiling on park→reboot→resume→park cycles for ONE run, so a caller that
# re-requests a reboot without making progress cannot reboot-loop a box. A
# multi-reboot distro upgrade needs only a handful; 6 is generous.
SHCP_UPDATE_REBOOT_MAX="${SHCP_UPDATE_REBOOT_MAX:-6}"

# Test seam mirroring SHCP_UPDATE_CRASH_STAGE: request a park-for-reboot AFTER
# the named stage completes. Inert unless set; no shipped unit sets it, and it is
# not panel-reachable (SC-317). This is also the documented extension point the
# OS-upgrade epic (ONB-7) drives instead of adding a production caller here.
# (read directly from the environment in run_stages; declared here for the reader)

# Stage order is the §4.2 contract. stages_total in status.json derives from
# this array — do not reorder without a plan-document change first.
STAGES=(self-update preflight snapshot apt panel db republish-vhosts restart health republish-pools finalize)

JOURNAL_SCHEMA=2
CURRENT_RUN_ID=""
RESUMED_FROM_SELF_UPDATE=0
JSON_OUTPUT=0
JSON_FD=1

# json_claim_stdout — under --json, stdout carries exactly one JSON document and
# nothing else. apt, dnf, dpkg triggers and needrestart write to whatever stdout
# they inherit, and no list of call sites stays complete, so a verb that runs
# them claims the descriptor itself: the caller's stdout is kept on JSON_FD and
# fd 1 points at stderr for the rest of the process. The emitters write >&JSON_FD.
json_claim_stdout() {
	(( JSON_OUTPUT == 1 && JSON_FD == 1 )) || return 0
	exec {JSON_FD}>&1 1>&2
}

log() { printf '[shcp-update] %s\n' "$*" >&2; }
die() { printf '[shcp-update] FATAL: %s\n' "$*" >&2; exit 1; }
# die_busy — a refusal that is TRANSIENT: another engine (or an operator console
# command) holds a lock and the caller only has to retry once it exits. Exit 75
# (EX_TEMPFAIL) so systemd units and the panel can tell "try again later" from
# "an operator has to look" (exit 1). cmd_repair_series_pointer set the precedent;
# every lock refusal now shares it, so a timer firing into a live run is never
# reported as a failed update.
die_busy() { printf '[shcp-update] BUSY: %s\n' "$*" >&2; exit 75; }

# lock_refused_exit — after a failed acquire_lock: BUSY (exit 75, retry later)
# when a lock is merely held by someone else, but a hard refusal (exit 1) when
# the lock could not be taken at all — an SC-500 owner/mode/symlink refusal that
# an operator has to look at; retrying that every timer tick would never end.
lock_refused_exit() {
	[[ -n "$LOCK_BUSY_REASON" ]] && die_busy "$LOCK_BUSY_REASON"
	die "could not take the run lock ${SHCP_UPDATE_LOCK}: refused (SC-500) — see the engine log for the owner/mode check that failed"
}

now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }

# current_boot_id — the running kernel's boot_id, whitespace stripped, or empty
# when the source cannot be read. Empty means "cannot confirm": the resume gate
# treats an unknown CURRENT id as unverifiable and lets the resume proceed
# (never strand a legitimately parked run over a kernel read error). An
# unrecorded PARKED id is the opposite case — park_for_reboot always records
# one, so its absence is a journal this engine did not park — and `resume
# --at-boot` refuses it (cmd_resume).
current_boot_id() { tr -d '[:space:]' < "$BOOT_ID_FILE" 2>/dev/null || true; }

# --- run log -----------------------------------------------------------------
# Everything noteworthy for a run is appended to runs/<id>/log in addition to
# stderr, so `status --follow` / the admin UI journal tail have raw detail.
engine_log() {
	local msg="$*"
	log "$msg"
	if [[ -n "$CURRENT_RUN_ID" && -d "${RUNS_DIR}/${CURRENT_RUN_ID}" ]]; then
		printf '%s %s\n' "$(now_utc)" "$msg" >>"${RUNS_DIR}/${CURRENT_RUN_ID}/log"
	fi
}

# --- atomic JSON writes ------------------------------------------------------
# tmp+rename in the SAME directory (rename is only atomic within a
# filesystem). A kill -9 mid-write leaves the tmp file behind and the
# previous document intact — the journal atomicity test hammers this.
#
# mktemp creates 0600 and rename preserves it, so the DEFAULT is root-only.
# That is what every document under the state dir wants — journals, the
# request file, the maintenance flag, snapshots — with exactly one exception:
# the §4.9 progress doc, which the panel has to read as `shcp`
# (SC-416). Callers opt into that by passing a mode.
#
# The mode is applied to the tmp file BEFORE the rename, never after. Widening
# afterwards leaves a window in which the new inode is already visible at the
# destination but still 0600, so a poll landing in that window gets EACCES and
# the UI reports a dead run for no reason.
#
# EVERY step is checked and a failure removes the temp and returns 1. `set -e`
# is no substitute: it is inert inside every stage body (the runner invokes them
# as `if ! "$fn"`), so an unchecked `cat` that hit ENOSPC mid-document used to
# fall through to `mv -f` and rename a TRUNCATED file over journal.json or
# status.json — the one failure the tmp+rename scheme exists to prevent. The
# previous document must survive any failure here; only the caller decides
# whether a failed write is fatal (journal_update: it is).
atomic_write() {
	local dest="$1"
	local mode="${2:-}"
	local tmp
	tmp="$(mktemp "${dest}.tmp.XXXXXX")" || return 1
	if ! cat >"$tmp"; then
		rm -f -- "$tmp"
		return 1
	fi
	if [[ -n "$mode" ]] && ! chmod "$mode" "$tmp"; then
		rm -f -- "$tmp"
		return 1
	fi
	if ! mv -f "$tmp" "$dest"; then
		rm -f -- "$tmp"
		return 1
	fi
}

# atomic_write_durable <dest> — atomic_write plus a real crash-durability
# barrier. Callers use this only for one-way recovery latches: reporting success
# before the file and its directory entry are stable would make a reboot repeat
# a destructive operation. Every error is fatal to the write; only our temp is
# removed.
atomic_write_durable() {
	local dest="$1" tmp dir
	dir="${dest%/*}"
	tmp="$(mktemp "${dest}.tmp.XXXXXX")" || return 1
	if ! cat >"$tmp" || ! chmod 0600 "$tmp" || ! sync "$tmp" || ! mv -f "$tmp" "$dest"; then
		rm -f -- "$tmp"
		return 1
	fi
	if ! sync "$dir"; then
		# The rename is visible in this boot even though its persistence was not
		# proved. Revoke that visible proof so an immediate retry cannot accept it
		# and restart writers on the strength of a barrier that explicitly failed.
		rm -f -- "$dest"
		sync "$dir" >/dev/null 2>&1 || true
		return 1
	fi
}

# selinux_restore <path>... — reassert each path's file_contexts label.
#
# atomic_write publishes through mktemp+rename: the temp is created in the
# destination directory and inherits ITS type (var_lib_t under
# /var/lib/shcp/update), and rename(2) carries the source label across, so the
# published file lands var_lib_t regardless of any fcontext spec. shcpd's
# SELinux module (shcp-build packagers/shcpd/selinux/shcpd.fc) declares
# maintenance.json and runs/*/status.json as shcpd_update_state_t — the ONLY two
# files under update/ the confined web daemon is granted read on — but that
# label is only ever applied by restorecon, which nothing ran on them. The
# result on EL10 Enforcing is `shcpd_t getattr` denied on the var_lib_t file
# (shcp-build#512): shcpd cannot see the maintenance gate SC-318 relies on.
#
# TARGETED, never blanket: the same tree holds the run journal, the request echo
# and the DB snapshots, all of which MUST stay var_lib_t / private
# (SC-416/SC-635). Only the caller's explicit shcpd-readable files are relabeled
# here, never the directory. No-op where restorecon is absent (deb/AppArmor) or
# SELinux is disabled (restorecon exits 0 doing nothing). Best-effort: the file
# content is already durably written, so a relabel shortfall is logged, never
# fatal — matching db_fix_ownership.
selinux_restore() {
	command -v restorecon >/dev/null 2>&1 || return 0
	local p
	for p in "$@"; do
		[[ -e "$p" ]] || continue
		restorecon "$p" 2>/dev/null || engine_log "selinux_restore: restorecon ${p} failed"
	done
	return 0
}

# --- run id ------------------------------------------------------------------
# <UTC timestamp>-<6 hex chars from /dev/urandom>, e.g. 20260714-031500-4f2a9c.
# Matches the journal fixtures; randomness at runtime is fine (this is an
# identifier, not a secret).
#
# Every picker of "the newest run" sorts these ids, so an id must sort after
# every run already on disk. The timestamp does that from one second to the
# next; within a second the random suffix would not, so a run created in the
# same second as the newest one takes that run's suffix plus one instead.
new_run_id() {
	local ts last="" d
	ts="$(date -u +%Y%m%d-%H%M%S)"
	for d in "$RUNS_DIR"/"$ts"-??????; do
		d="${d##*/}"
		run_id_valid "$d" && last="${d##*-}"
	done
	if [[ -n "$last" && "$last" != ffffff ]]; then
		printf '%s-%06x' "$ts" "$(( 16#$last + 1 ))"
		return 0
	fi
	printf '%s-%s' "$ts" "$(od -An -N3 -tx1 /dev/urandom | tr -d ' \n')"
}

run_id_valid() { [[ "$1" =~ ^[0-9]{8}-[0-9]{6}-[0-9a-f]{6}$ ]]; }

journal_path() { printf '%s/%s/journal.json' "$RUNS_DIR" "$1"; }
status_path()  { printf '%s/%s/status.json'  "$RUNS_DIR" "$1"; }

# --- journal -----------------------------------------------------------------
# journal_init <run_id> <trigger> <scope> <request_json> [reinstall] [admission_json]
# Creates runs/<id>/ and the initial journal document (§5.4 shape).
#
# The state dir and the two mkdir'd dirs are chmod'd 0711 rather than left to
# the ambient umask; run_tree_trusted refuses any of them group/other-writable.
# 0644 on status.json buys nothing if the panel cannot TRAVERSE to it, and the
# only reason a default umask works is that no updater unit sets UMask=. Adding
# `UMask=0077` to shcp-update-apply.service — an obvious future hardening —
# would make these 0700 and silently kill live progress with every test still
# green. The permission the panel needs is stated here instead of inherited.
#
# 0711 is traverse-WITHOUT-list on purpose: the panel always opens a run id it
# already knows, so nothing needs to enumerate this tree
# (SC-416). Matches the deb postinst.
journal_init() {
	local run_id="$1" trigger="$2" scope="$3" request_json="$4" reinstall="${5:-false}"
	local admission_json="${6:-null}" extra_json="${7:-null}"
	# Bind privileged recovery to the machine that created the run.  These values
	# are descriptive for ordinary consumers and authoritative only after the
	# root-owned run-tree gate has passed.
	local host_family="" host_arch=""
	if [[ "$scope" == "series" ]]; then
		osf_detect_family
		host_family="$OS_FAMILY"
		host_arch="$(uname -m)"
	fi
	mkdir -p "${RUNS_DIR}/${run_id}"
	chmod 0711 "$SHCP_UPDATE_STATE_DIR" "$RUNS_DIR" "${RUNS_DIR}/${run_id}" 2>/dev/null || true
	# The run log is appended with `>>`, which honours the process umask — 0644
	# under root's default. 0711 on the dirs blocks enumeration but not a read
	# of a world-readable file by a local user who guesses the run id, and on a
	# shared-hosting box those users are untrusted tenants. Create it 0600 up
	# front; only status.json is meant to be readable
	# (SC-416).
	: > "${RUNS_DIR}/${run_id}/log"
	chmod 0600 "${RUNS_DIR}/${run_id}/log" 2>/dev/null || true
	jq -n \
		--arg run_id "$run_id" \
		--argjson schema "$JOURNAL_SCHEMA" \
		--arg trigger "$trigger" \
		--arg scope "$scope" \
		--argjson request "$request_json" \
		--argjson reinstall "$reinstall" \
		--arg host_family "$host_family" \
		--arg host_arch "$host_arch" \
		--argjson admission "$admission_json" \
		--argjson extra "$extra_json" \
		--arg now "$(now_utc)" \
		'{run_id: $run_id, schema: $schema, trigger: $trigger, scope: $scope,
		  channel: "current",
		  host: (if $host_family == "" then null else {os_family: $host_family, arch: $host_arch} end),
		  request: $request,
		  admission: $admission,
		  reinstall: $reinstall,
		  stages: [],
		  packages: {before: {}, after: {}, changed: []},
		  panel: null,
		  health: null,
		  reboot_required: false,
		  restart_required: [],
		  status: "running",
		  error: null,
		  started_at: $now,
		  finished_at: null} | if $extra == null then . else . + $extra end' \
		| atomic_write "$(journal_path "$run_id")" \
		|| die "journal write failed for ${run_id} (${RUNS_DIR}/${run_id}): refusing to start a run it cannot record"
}

# journal_update <run_id> <jq-program> [jq args...] — atomic read-modify-write.
#
# Both halves are fatal, not just the jq half. The journal is the ONLY record of
# what a run did and what a resume or rollback may undo; a stage that carried on
# after its stage_done/terminal write silently failed to land would let the box
# diverge from the record that is supposed to describe it (SC-420 fails closed
# on a journal it cannot read — the same rule applies to one it cannot write).
journal_update() {
	local run_id="$1" prog="$2"
	shift 2
	local jf out
	jf="$(journal_path "$run_id")"
	out="$(jq "$@" "$prog" "$jf")" || die "journal update failed for ${run_id}"
	printf '%s\n' "$out" | atomic_write "$jf" \
		|| die "journal write failed for ${run_id} (${jf}): the previous document is intact, the update was not applied"
}

# journal_update_durable — journal_update with atomic_write_durable's fsync
# barrier. Only for a one-way latch that a reboot must find on disk: the
# awaiting_reboot flip in park_for_reboot, and journal_stage_done. A plain rename
# may still be sitting in the page cache when `systemctl reboot` follows it a few
# milliseconds later, and a box that comes back with status=running and no
# parked record would have rebooted for nothing the boot unit can act on.
journal_update_durable() {
	local run_id="$1" prog="$2"
	shift 2
	local jf out
	jf="$(journal_path "$run_id")"
	out="$(jq "$@" "$prog" "$jf")" || die "journal update failed for ${run_id}"
	printf '%s\n' "$out" | atomic_write_durable "$jf" \
		|| die "durable journal write failed for ${run_id} (${jf}): the previous document is intact, the update was not applied"
}

# journal_update_bigjson <run_id> <var> <jq-prog-referencing-$var[0]> -- read a
# possibly-LARGE JSON value from stdin and bind it in <prog> as $<var>[0]. The
# value goes through a temp file with --slurpfile, never a jq command-line
# argument: a full-box package baseline is ~160 KiB on EL10 (662 packages), and a
# single argv string over MAX_ARG_STRLEN (128 KiB) makes jq's exec fail E2BIG,
# which killed every full-box `apply` in stage_preflight at `.packages.before`.
# --slurpfile wraps the file's document(s) in an array, so callers dereference
# $<var>[0]; an empty stream yields [] and $<var>[0] is null, matching what an
# absent baseline should record. (shcp-updater rpm full-stack drill, EL series.)
journal_update_bigjson() {
	local run_id="$1" var="$2" prog="$3"
	local tmp
	tmp="$(mktemp)" || die "journal update: mktemp failed for ${run_id}"
	cat >"$tmp"
	journal_update "$run_id" "$prog" --slurpfile "$var" "$tmp"
	rm -f "$tmp"
}

# journal_stage_start <run_id> <stage> — appends a stage record unless one is
# already open (resume re-entry keeps the original started_at; the stage body
# must be idempotent, AD-2).
journal_stage_start() {
	local run_id="$1" stage="$2"
	local jf
	jf="$(journal_path "$run_id")"
	if jq -e --arg s "$stage" '.stages[] | select(.stage == $s)' "$jf" >/dev/null; then
		return 0
	fi
	journal_update "$run_id" \
		'.stages += [{stage: $s, started_at: $now}]' \
		--arg s "$stage" --arg now "$(now_utc)"
}

# journal_stage_done <run_id> <stage> <result_json> — durable, because resume
# re-enters the first stage it cannot find done ON DISK. Power-cut drill
# (SenternalTech/shcp-master#3337, Debian 13 ext4): reset during `panel`, and the
# journal came back with `snapshot` still open, so `resume` re-ran snapshot. That
# stage rewrites ${run}.db from the LIVE database, so had the lost window reached
# `db`, the run's pre-update snapshot would have become the migrated database and
# a rollback would restore the wrong one. One fsync per stage is the price.
journal_stage_done() {
	local run_id="$1" stage="$2" result_json="$3"
	journal_update_durable "$run_id" \
		'.stages |= map(if .stage == $s and (.done_at // null) == null
			then . + {done_at: $now, result: $res} else . end)' \
		--arg s "$stage" --arg now "$(now_utc)" --argjson res "$result_json"
}

# journal_pending_stage <run_id> — echoes the first stage in STAGES without a
# stage_done record (the resume re-entry point), or nothing when all done.
journal_pending_stage() {
	local run_id="$1" jf stage
	jf="$(journal_path "$run_id")"
	for stage in "${STAGES[@]}"; do
		if ! jq -e --arg s "$stage" \
				'.stages[] | select(.stage == $s) | select((.done_at // null) != null)' \
				"$jf" >/dev/null; then
			printf '%s\n' "$stage"
			return 0
		fi
	done
	return 0
}

# journal_finish <run_id> <status> [error]
journal_finish() {
	local run_id="$1" status="$2" error="${3:-}"
	journal_update "$run_id" \
		'.status = $st
		 | .error = (if $err == "" then null else $err end)
		 | .finished_at = $now' \
		--arg st "$status" --arg err "$error" --arg now "$(now_utc)"
}

# journal_rollback_finish <run_id> <result_json> <status> [error] — close the
# OPEN rollback record (done_at + result) AND write the run's terminal status in
# ONE rename. These used to be journal_stage_done followed by journal_finish: two
# jq+rename cycles, and the apply and its rollback share one TimeoutStartSec, so
# a SIGKILL between them left a COMPLETED rollback record under status
# `running` — a state nothing ever leaves. resume said "all stages already
# done" forever, check reported stale_running_journal forever, and the manual
# verb refused with "already rolled back". Every writer of a completed rollback
# record (rollback_run's tail, cmd_rollback's refusal path) goes through here,
# so the record and the status can never disagree on disk. journal_reassert_
# rollback_terminal below heals journals an older engine left in that state.
journal_rollback_finish() {
	local run_id="$1" result_json="$2" status="$3" error="${4:-}"
	journal_update "$run_id" \
		'.stages |= map(if .stage == "rollback" and (.done_at // null) == null
			then . + {done_at: $now, result: $res} else . end)
		 | .status = $st
		 | .error = (if $err == "" then null else $err end)
		 | .finished_at = $now' \
		--arg now "$(now_utc)" --argjson res "$result_json" \
		--arg st "$status" --arg err "$error"
}

# journal_reassert_rollback_terminal <run_id> — self-heal for the window
# journal_rollback_finish closed: a journal still `running` that carries a
# COMPLETED rollback record. The record's outcome IS the terminal status the
# interrupted engine was about to write (rollback_run decided it before the
# record was closed), so re-assert it from the record rather than leaving the
# run in a state no verb can act on. Reads the record's own reason so the
# panel importer sees why. Returns 0 whether or not there was anything to heal;
# callers are run_stages (every resume passes through it) and auto_rollback's
# journal latch. A journal whose status is already terminal is never touched.
journal_reassert_rollback_terminal() {
	local run_id="$1" jf prior outcome reason
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" ]] || return 0
	[[ "$(jq -r '.status // empty' "$jf" 2>/dev/null)" == "running" ]] || return 0
	prior="$(rollback_last_record "$run_id")"
	[[ -n "$prior" ]] || return 0
	outcome="$(jq -r '.result.outcome // empty' <<<"$prior")"
	case "$outcome" in
		rolled_back|rolled_back_partial|failed) ;;
		*) return 0 ;;   # a record with no recognisable verdict is not evidence
	esac
	reason="$(jq -r '.result.reason // empty' <<<"$prior")"
	engine_log "run ${run_id}: journaled running but carries a completed rollback record (${outcome}) — re-asserting the terminal status an interrupted finish did not write"
	journal_finish "$run_id" "$outcome" \
		"${reason:-rollback ${outcome}} (terminal status re-asserted from the completed rollback record after an interrupted finish)"
	return 0
}

# --- status.json (§4.9) ------------------------------------------------------
# status_write <run_id> <stage> <stages_done> <message> <detail>
#
# The ONLY document under the state dir the panel may read (0644, and the
# shcpd AppArmor profile grants this exact path). Keep it that way: it carries
# scheduling scalars only — run id, stage, counters, a translation KEY, a
# percentage, a timestamp. No package lists, no error text, no paths, no
# operator identity. Anything richer belongs in the journal, which stays
# root-only, and reaches the UI through the imported SystemUpdateRun row
# (SC-416).
status_write() {
	local run_id="$1" stage="$2" done_n="$3" message="$4" detail="$5"
	local total=${#STAGES[@]} pct
	pct=$(( done_n * 100 / total ))
	jq -n \
		--arg run_id "$run_id" \
		--arg stage "$stage" \
		--argjson stages_done "$done_n" \
		--argjson stages_total "$total" \
		--arg message "$message" \
		--arg detail "$detail" \
		--argjson pct_estimate "$pct" \
		--arg now "$(now_utc)" \
		'{run_id: $run_id, stage: $stage, stages_done: $stages_done,
		  stages_total: $stages_total, message: $message, detail: $detail,
		  pct_estimate: $pct_estimate, updated_at: $now}' \
		| atomic_write "$(status_path "$run_id")" 0644
	# status.json also lands var_lib_t via atomic_write; relabel so shcpd may
	# read the UPD-7 progress banner on EL10 Enforcing (shcp-build#512).
	selinux_restore "$(status_path "$run_id")"
}

# --- request-file validation (§4.3, SC-317) ----------------
# Strict allow-list: keys ⊆ {scope, trigger, requested_by,
# target_panel_version, series, command_uuid}; regex-validated values;
# root:root 0600; regular file (no symlink). Sets REQ_SCOPE / REQ_TRIGGER /
# REQ_REQUESTED_BY / REQ_TARGET_VERSION / REQ_SERIES / REQ_JSON on success.
# On failure sets REQ_ERROR and returns 1. Values are only ever compared /
# stored — never eval'd, never expanded unquoted.
# command_uuid (base#306) is an OPAQUE ECHO: the panel's audit-row uuid,
# validated as an anchored lowercase UUID and carried verbatim — inside
# REQ_JSON only, it selects no behavior and never reaches argv — into the
# journal's embedded request so the panel can close its ACCEPTED row.
REQ_SCOPE="" REQ_TRIGGER="" REQ_REQUESTED_BY="" REQ_TARGET_VERSION="" REQ_SERIES=""
REQ_JSON="" REQ_ERROR=""

# Reinstall is a CLI MODE, not a request-file field — the §4.3 schema stays
# frozen and the panel still cannot ask for it by writing a file (SC-317).
# Deliberately declared outside the REQ_* block above so validate_request_file's
# reset cannot clear it.
REQ_REINSTALL=0

validate_request_file() {
	local f="$1"
	REQ_SCOPE="" REQ_TRIGGER="" REQ_REQUESTED_BY="" REQ_TARGET_VERSION="" REQ_SERIES=""
	REQ_JSON="" REQ_ERROR=""

	if [[ ! -e "$f" ]]; then REQ_ERROR="request file missing: ${f}"; return 1; fi
	if [[ -L "$f" || ! -f "$f" ]]; then REQ_ERROR="request file is not a regular file"; return 1; fi

	local st_uid st_gid st_mode
	st_uid="$(stat -c %u "$f")" || { REQ_ERROR="cannot stat request file"; return 1; }
	st_gid="$(stat -c %g "$f")"
	st_mode="$(stat -c %a "$f")"
	if [[ "$st_uid" != "$SHCP_UPDATE_EXPECT_UID" || "$st_gid" != "$SHCP_UPDATE_EXPECT_GID" ]]; then
		REQ_ERROR="request file must be owned ${SHCP_UPDATE_EXPECT_UID}:${SHCP_UPDATE_EXPECT_GID} (got ${st_uid}:${st_gid})"
		return 1
	fi
	if [[ "$st_mode" != "600" ]]; then
		REQ_ERROR="request file must be mode 0600 (got ${st_mode})"
		return 1
	fi

	if ! jq -e 'type == "object"' "$f" >/dev/null 2>&1; then
		REQ_ERROR="request file is not a JSON object"
		return 1
	fi

	local unknown
	unknown="$(jq -r '[keys_unsorted[]
		| select(IN("scope","trigger","requested_by","target_panel_version","series","command_uuid") | not)]
		| join(",")' "$f")"
	if [[ -n "$unknown" ]]; then
		REQ_ERROR="request file carries unknown key(s): ${unknown}"
		return 1
	fi

	local scope trigger requested_by target_version series command_uuid
	scope="$(jq -r '.scope // empty' "$f")"
	trigger="$(jq -r '.trigger // empty' "$f")"
	requested_by="$(jq -r '.requested_by // empty' "$f")"
	target_version="$(jq -r '.target_panel_version // empty' "$f")"
	series="$(jq -r '.series // empty' "$f")"
	# Presence-gated, not `// empty`: that idiom swallows null/false and lets ""
	# through to the -n short-circuit, silently DROPPING a key the panel wrote
	# and believes was accepted — the correlation would evaporate with the
	# engine reporting success. Present means a non-empty string or rejection.
	command_uuid=""
	if jq -e 'has("command_uuid")' "$f" >/dev/null; then
		if ! jq -e '.command_uuid | type == "string" and . != ""' "$f" >/dev/null; then
			REQ_ERROR="invalid command_uuid (present but not a non-empty string)"
			return 1
		fi
		command_uuid="$(jq -r '.command_uuid' "$f")"
	fi

	if [[ ! "$scope" =~ ^(packages|panel|all|security|series)$ ]]; then
		REQ_ERROR="invalid scope '${scope}' (want packages|panel|all|security|series)"
		return 1
	fi
	if [[ ! "$trigger" =~ ^(auto|manual)$ ]]; then
		REQ_ERROR="invalid trigger '${trigger}' (want auto|manual)"
		return 1
	fi
	# Canonical JSON integer only: `^[0-9]+$` admitted "007", which the --argjson
	# re-serialisation below rejects as invalid JSON — leaving REQ_JSON EMPTY
	# while the function still returned 0, so the run journaled a null request.
	# The grammar here must be exactly what --argjson accepts.
	if [[ ! "$requested_by" =~ ^(0|[1-9][0-9]*)$ ]]; then
		REQ_ERROR="invalid requested_by '${requested_by}' (want a canonical non-negative integer)"
		return 1
	fi
	if [[ -n "$target_version" && ! "$target_version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
		REQ_ERROR="invalid target_panel_version '${target_version}'"
		return 1
	fi
	if [[ -n "$series" && ! "$series" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
		REQ_ERROR="invalid series '${series}'"
		return 1
	fi
	# Lowercase only: the panel mints toRfc4122() which is lowercase, and an
	# echo field is rejected on any deviation, never case-folded (base#306).
	if [[ -n "$command_uuid" && ! "$command_uuid" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
		REQ_ERROR="invalid command_uuid '${command_uuid}'"
		return 1
	fi
	# A pinned series makes the run a cross-series jump (is_series_run), so it is
	# accepted ONLY under scope=series and the checks below. Any other scope with
	# a series field would be a series jump that skipped them — including an
	# automatic one (break-it finding, updater#178). The panel never sends one.
	if [[ -n "$series" && "$scope" != "series" ]]; then
		REQ_ERROR="series is only valid with scope 'series' (got scope '${scope}')"
		return 1
	fi
	# scope=series (base#1500): the request the panel's approve-series action is
	# the ONLY writer of (UpdateScope::SERIES + seriesApproved, AD-10). It runs as
	# scope=all — packages AND the panel — against the approved series, so it is
	# held to exactly what that action sends: a manual trigger (a series jump is
	# never automatic, AD-10), a MAJOR.MINOR series, and the exact offered version,
	# which must belong to that series. Anything less is refused here rather than
	# degrading into a routine run the operator never approved.
	if [[ "$scope" == "series" ]]; then
		if [[ "$trigger" != "manual" ]]; then
			REQ_ERROR="scope 'series' requires trigger 'manual' (AD-10: a series upgrade is never automatic)"
			return 1
		fi
		if [[ ! "$series" =~ ^[0-9]+\.[0-9]+$ ]]; then
			REQ_ERROR="scope 'series' requires series as MAJOR.MINOR (got '${series}')"
			return 1
		fi
		if [[ ! "$target_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "${target_version%.*}" != "$series" ]]; then
			REQ_ERROR="scope 'series' requires target_panel_version in series ${series} (got '${target_version}')"
			return 1
		fi
	fi

	REQ_SCOPE="$scope"
	REQ_TRIGGER="$trigger"
	REQ_REQUESTED_BY="$requested_by"
	REQ_TARGET_VERSION="$target_version"
	REQ_SERIES="$series"
	# No REQ_COMMAND_UUID global: nothing in the engine consumes the uuid — its
	# one job is to ride REQ_JSON into the journal. A global that exists only
	# to be reset would be ceremony impersonating a contract.
	# Re-serialize from the validated fields only — the journal's request copy
	# is REBUILT, never the raw file body, so nothing unvalidated propagates.
	# The jq exit is checked, and the globals are cleared again on failure: a
	# validator that returns 0 with an empty REQ_JSON is the defect above in
	# another coat, whatever the field that triggers it next.
	if ! REQ_JSON="$(jq -n \
		--arg scope "$scope" --arg trigger "$trigger" \
		--argjson requested_by "$requested_by" \
		--arg tv "$target_version" --arg se "$series" \
		--arg cu "$command_uuid" \
		'{scope: $scope, trigger: $trigger, requested_by: $requested_by}
		 + (if $tv == "" then {} else {target_panel_version: $tv} end)
		 + (if $se == "" then {} else {series: $se} end)
		 + (if $cu == "" then {} else {command_uuid: $cu} end)' 2>/dev/null)" \
			|| [[ -z "$REQ_JSON" ]]; then
		REQ_SCOPE="" REQ_TRIGGER="" REQ_REQUESTED_BY="" REQ_TARGET_VERSION="" REQ_SERIES=""
		REQ_JSON=""
		REQ_ERROR="request could not be re-serialised from its validated fields"
		return 1
	fi
	return 0
}

# --- settings (AD-6: fail toward defaults) ------------------------------------
# Read-only access to the panel's system_settings table (key TEXT PK, value
# JSON TEXT). Keys passed here are ENGINE-INTERNAL constants (update.*) —
# never external input — so the literal interpolation below is not an
# injection surface; the read-only handle plus fail-toward-default behavior
# is the actual contract (a broken/absent DB must never stop security
# patching, SC-319).
settings_get() {
	local key="$1" default="$2" raw parsed
	[[ -f "$SHCP_UPDATE_DB" ]] || { printf '%s' "$default"; return 0; }
	raw="$(sqlite3 -readonly "$SHCP_UPDATE_DB" \
		"SELECT value FROM system_settings WHERE key = '${key}';" 2>/dev/null)" \
		|| { printf '%s' "$default"; return 0; }
	[[ -n "$raw" ]] || { printf '%s' "$default"; return 0; }
	parsed="$(jq -r 'if type == "string" or type == "number" or type == "boolean"
		then tostring else empty end' <<<"$raw" 2>/dev/null || true)"
	if [[ -n "$parsed" ]]; then printf '%s' "$parsed"; else printf '%s' "$default"; fi
}

# Resolve one routine lane without confusing an absent row (the new-install
# default) with an unreadable/malformed settings store (fail closed). Values in
# system_settings are JSON text. The canonical row is authoritative whenever it
# is present: a malformed canonical value is manual and never falls back to a
# more permissive legacy row.
ROUTINE_SETTING_STATE="" ROUTINE_SETTING_TYPE="" ROUTINE_SETTING_VALUE=""
routine_setting_read() {
	local key="$1" row raw
	ROUTINE_SETTING_STATE="error" ROUTINE_SETTING_TYPE="" ROUTINE_SETTING_VALUE=""
	[[ -f "$SHCP_UPDATE_DB" ]] || return 0
	# Prefix the row so SQL NULL / empty text remain distinguishable from no row.
	# Routine enum values cannot contain '|'; malformed values need no recovery.
	row="$(sqlite3 -readonly -noheader "$SHCP_UPDATE_DB" \
		"SELECT 'present|' || COALESCE(value, '') FROM system_settings WHERE key = '${key}' LIMIT 1;" 2>/dev/null)" \
		|| return 0
	if [[ "$row" != present\|* ]]; then
		ROUTINE_SETTING_STATE="absent"
		return 0
	fi
	raw="${row#present|}"
	ROUTINE_SETTING_STATE="present"
	ROUTINE_SETTING_TYPE="$(jq -r 'type' <<<"$raw" 2>/dev/null || true)"
	ROUTINE_SETTING_VALUE="$(jq -r 'if type == "string" or type == "boolean" then tostring else empty end' \
		<<<"$raw" 2>/dev/null || true)"
}

routine_policy_resolve() {
	local lane="$1" canonical canonical_type legacy_enabled_state legacy_enabled_type legacy_enabled
	local legacy_scope_state legacy_scope_type legacy_scope
	routine_setting_read "update.routine.${lane}_policy"
	canonical="$ROUTINE_SETTING_VALUE" canonical_type="$ROUTINE_SETTING_TYPE"
	case "$ROUTINE_SETTING_STATE" in
		present)
			if [[ "$canonical_type" == string ]]; then
				case "$canonical" in automatic|manual|disabled) printf '%s' "$canonical" ;; *) printf 'manual' ;; esac
			else
				printf 'manual'
			fi
			return 0 ;;
		error) printf 'manual'; return 0 ;;
	esac

	routine_setting_read "update.auto.enabled"
	legacy_enabled_state="$ROUTINE_SETTING_STATE" legacy_enabled_type="$ROUTINE_SETTING_TYPE" legacy_enabled="$ROUTINE_SETTING_VALUE"
	routine_setting_read "update.auto.scope"
	legacy_scope_state="$ROUTINE_SETTING_STATE" legacy_scope_type="$ROUTINE_SETTING_TYPE" legacy_scope="$ROUTINE_SETTING_VALUE"
	if [[ "$legacy_enabled_state" == absent && "$legacy_scope_state" == absent ]]; then
		printf 'automatic'
		return 0
	fi
	if [[ "$legacy_enabled_state" != present || "$legacy_scope_state" != present ]]; then
		printf 'manual'
		return 0
	fi
	if [[ "$legacy_enabled_type" != boolean || "$legacy_scope_type" != string ]]; then
		printf 'manual'
		return 0
	fi
	case "$legacy_enabled" in
		false) printf 'manual'; return 0 ;;
		true) : ;;
		*) printf 'manual'; return 0 ;;
	esac
	case "$legacy_scope" in
		security) printf 'manual' ;;
		packages) [[ "$lane" == packages ]] && printf 'automatic' || printf 'manual' ;;
		all) printf 'automatic' ;;
		*) printf 'manual' ;;
	esac
}

ROUTINE_ADMISSION_ERROR=""
routine_policy_admit() {
	local scope="$1" trigger="$2" packages_policy panel_policy now
	ROUTINE_ADMISSION_ERROR=""
	case "$scope" in
		security)
			jq -nc --arg now "$(now_utc)" '{kind:"security", admitted_at:$now}'
			return 0 ;;
		series)
			if [[ "$trigger" != manual ]]; then
				ROUTINE_ADMISSION_ERROR="series upgrades require an explicit manual trigger"
				printf '%s' "$ROUTINE_ADMISSION_ERROR"
				return 1
			fi
			jq -nc --arg now "$(now_utc)" '{kind:"series", trigger:"manual", admitted_at:$now}'
			return 0 ;;
		packages|panel|all) : ;;
		*) ROUTINE_ADMISSION_ERROR="unsupported routine scope '${scope}'"; printf '%s' "$ROUTINE_ADMISSION_ERROR"; return 1 ;;
	esac

	packages_policy="$(routine_policy_resolve packages)"
	panel_policy="$(routine_policy_resolve panel)"
	case "$trigger" in
		auto)
			if [[ ( "$scope" == packages || "$scope" == all ) && "$packages_policy" != automatic ]]; then
				ROUTINE_ADMISSION_ERROR="scheduled package updates require policy automatic (effective ${packages_policy})"
				printf '%s' "$ROUTINE_ADMISSION_ERROR"
				return 1
			fi
			if [[ ( "$scope" == panel || "$scope" == all ) && "$panel_policy" != automatic ]]; then
				ROUTINE_ADMISSION_ERROR="scheduled panel updates require policy automatic (effective ${panel_policy})"
				printf '%s' "$ROUTINE_ADMISSION_ERROR"
				return 1
			fi ;;
		manual)
			if [[ ( "$scope" == packages || "$scope" == all ) && "$packages_policy" == disabled ]]; then
				ROUTINE_ADMISSION_ERROR="manual package updates are disabled"
				printf '%s' "$ROUTINE_ADMISSION_ERROR"
				return 1
			fi
			if [[ ( "$scope" == panel || "$scope" == all ) && "$panel_policy" == disabled ]]; then
				ROUTINE_ADMISSION_ERROR="manual panel updates are disabled"
				printf '%s' "$ROUTINE_ADMISSION_ERROR"
				return 1
			fi ;;
		*) ROUTINE_ADMISSION_ERROR="unsupported trigger '${trigger}'"; printf '%s' "$ROUTINE_ADMISSION_ERROR"; return 1 ;;
	esac
	now="$(now_utc)"
	jq -nc --arg now "$now" --arg trigger "$trigger" --arg scope "$scope" \
		--arg packages "$packages_policy" --arg panel "$panel_policy" \
		'{kind:"routine", admitted_at:$now, trigger:$trigger, scope:$scope,
		  packages_policy:$packages, panel_policy:$panel}'
}

# A pre-admission crash (including an interrupted run from an older engine) must
# re-check current policy. Once present, this immutable snapshot governs the
# entire transaction; resume never changes authorization between stages.
ensure_run_policy_admitted() {
	local run_id="$1" jf scope trigger admission
	jf="$(journal_path "$run_id")"
	if jq -e '.admission | type == "object"' "$jf" >/dev/null 2>&1; then
		return 0
	fi
	scope="$(jq -r '.scope // empty' "$jf")"
	trigger="$(jq -r '.trigger // .request.trigger // empty' "$jf")"
	if ! admission="$(routine_policy_admit "$scope" "$trigger")"; then
		die "run ${run_id} policy admission refused: ${admission}"
	fi
	journal_update "$run_id" '.admission = $admission' --argjson admission "$admission"
}

# --- maintenance flag (AD-8) ---------------------------------------------------
# Write the maintenance flag. $2 is the phase marker: emitted ONLY when
# non-empty. Today the sole value is "schema-pending" — the panel (shcp-base,
# base#1152) hard-gates GET reads to 503 while that marker is present, because
# between the panel symlink flip (stage_panel) and schema:update (stage_db) the
# NEW panel code runs against the OLD DB and a read of a not-yet-added column
# would otherwise 500. Dropping the marker (empty phase) lifts that GET-gate;
# the flag itself stays up for the UPD-7 banner until finalize.
#
# The panel reads the recorded time from key `timestamp` (RFC3339 UTC); the
# committed golden fixture on the base side pins these exact bytes, key order
# run_id, timestamp, phase. SC-update-schema-pending-window-safe.
#
# The ORIGINAL timestamp is preserved across a rewrite (a run writes the flag at
# flip and again after schema:update) so staleness/TTL measure from when the
# window opened, not from each rewrite.
maintenance_write() {
	local run_id="$1" phase="${2-}"
	local now=""
	if [[ -f "$MAINT_FLAG" ]]; then
		now="$(jq -r '.timestamp // empty' "$MAINT_FLAG" 2>/dev/null || true)"
	fi
	[[ -n "$now" ]] || now="$(now_utc)"
	# -c (compact) so the bytes are the single-line form the panel's committed
	# golden fixture pins verbatim; key order run_id, timestamp[, phase].
	if [[ -n "$phase" ]]; then
		jq -nc --arg run_id "$run_id" --arg now "$now" --arg phase "$phase" \
			'{run_id: $run_id, timestamp: $now, phase: $phase}' | atomic_write "$MAINT_FLAG"
	else
		jq -nc --arg run_id "$run_id" --arg now "$now" \
			'{run_id: $run_id, timestamp: $now}' | atomic_write "$MAINT_FLAG"
	fi
	# The flag lands var_lib_t through atomic_write's rename; relabel it so
	# shcpd may read the SC-318 maintenance gate on EL10 Enforcing (shcp-build#512).
	selinux_restore "$MAINT_FLAG"
}

maintenance_clear() { rm -f "$MAINT_FLAG"; }

# Only remove the flag if it belongs to OUR run (or the operator forces) —
# a concurrent operator-set flag must survive an unrelated run's cleanup.
maintenance_clear_if_mine() {
	local run_id="$1"
	[[ -f "$MAINT_FLAG" ]] || return 0
	local owner
	owner="$(jq -r '.run_id // empty' "$MAINT_FLAG" 2>/dev/null || true)"
	if [[ "$owner" == "$run_id" ]]; then
		maintenance_clear
	fi
}

# --- lock ---------------------------------------------------------------------
# A test run already redirects its updater run lock; keep its lifecycle lock in
# that same sandbox unless explicitly provided. Production always uses /run/shcp.
if [[ -z "${SHCP_RELEASE_LIFECYCLE_LOCK:-}" && "$SHCP_UPDATE_LOCK" != /run/shcp/shcp-update.lock ]]; then
	SHCP_RELEASE_LIFECYCLE_LOCK="$(dirname "$SHCP_UPDATE_LOCK")/release-lifecycle.lock"
	SHCP_RELEASE_LIFECYCLE_EXPECT_UID="${SHCP_RELEASE_LIFECYCLE_EXPECT_UID:-$EUID}"
	SHCP_RELEASE_LIFECYCLE_EXPECT_GID="${SHCP_RELEASE_LIFECYCLE_EXPECT_GID:-${GROUPS[0]}}"
	# The root-only shape has no shcp group; a non-root sandbox owns the lock with
	# its own gid instead of gid 0 (shcp-installer#1033).
	SHCP_RELEASE_LIFECYCLE_EXPECT_ROOT_GID="${SHCP_RELEASE_LIFECYCLE_EXPECT_ROOT_GID:-${GROUPS[0]}}"
fi
# SC-355 / SC-538: the operator CLI holds this inode shared through PHP exit. Writers
# must not rename/recreate/prune a release until they hold it exclusively.
# Never unlink the lock: an unlinked, still-held inode stops coordinating.
SHCP_RELEASE_LIFECYCLE_FD=""

# Both supported OS families ship these util-linux executables at these paths.
# Data written for the console wrapper must use the OS seam, never PATH lookup.
OS_SETPRIV="${OS_SETPRIV:-/usr/bin/setpriv}"
OS_FLOCK="${OS_FLOCK:-/usr/bin/flock}"
SHCP_CONSOLE_WRAPPER="${SHCP_CONSOLE_WRAPPER:-/usr/sbin/shcp}"

console_identity_required() {
	[[ -f "$SHCP_CONSOLE_WRAPPER" ]] \
		&& grep -qx 'SHCP_CONSOLE_IDENTITY_SCHEMA=1' "$SHCP_CONSOLE_WRAPPER"
}

# True unless an installed console wrapper predates SHCP_CONSOLE_LOCK_SCHEMA=2. An
# absent wrapper counts as up to date, so a fresh install and every current box
# provisions the lifecycle lock root:root 0600; a box still carrying an older
# wrapper keeps the group-readable root:shcp 0640 shape (that wrapper opens it as
# the shcp account) until the wrapper is replaced and the next run tightens the
# same inode in place (shcp-installer#1033, SC-538, SC-500).
#
# A wrapper that EXISTS but cannot be proven legacy -- not a plain file, a
# symlink (never trust an indirection here), or grep cannot read it (a raw exit
# code of 2, not "no match") -- never earns the looser shape. Only grep exit 1,
# a readable wrapper that genuinely lacks the marker, is legacy; anything else
# fails closed to root-only, the tighter shape (shcp-installer#1049/#1096,
# SC-538). The bare `grep ||` conflated exit 2 with "no match" and silently
# widened the lock to 0640.
console_lock_root_only() {
	local wrapper="$SHCP_CONSOLE_WRAPPER" rc
	[[ -e "$wrapper" ]] || return 0
	[[ -f "$wrapper" && ! -L "$wrapper" && -r "$wrapper" ]] || return 0
	grep -qx 'SHCP_CONSOLE_LOCK_SCHEMA=2' -- "$wrapper"
	rc=$?
	(( rc == 0 || rc == 1 )) || return 0
	return "$rc"
}

# Validate only data: no Composer, kernel or executable from the target runs.
# Canonical encoding rejects duplicate JSON keys as well as malformed records.
console_identity_release_valid() {
	local release="$1" metadata="${1}/config/console-identities.json" artifact
	for artifact in "$metadata" "$release/bin/command-identity" "$release/src/Console/CommandIdentityManifest.php"; do
		[[ -f "$artifact" && ! -L "$artifact" && -s "$artifact" ]] || return 1
	done
	[[ $(stat -c %s -- "$metadata") -le 1048576 ]] || return 1
	# jq normally accepts a stream of JSON documents; PHP decodes exactly one.
	# Slurp first so independently valid concatenated documents cannot pass.
	jq -se '
		length == 1 and (.[0] |
		type == "object" and keys_unsorted == ["schema", "commands"] and .schema == 1
		and (.commands | type == "array" and length > 0)
		and all(.commands[];
			type == "object" and keys_unsorted == ["name", "aliases", "identity"]
			and (.name | type == "string" and test("^[a-zA-Z0-9_][a-zA-Z0-9_:-]*$") and (test("[^a-zA-Z0-9_:-]") | not))
			and (.aliases | type == "array" and all(.[]; type == "string" and test("^[a-zA-Z0-9_][a-zA-Z0-9_:-]*$") and (test("[^a-zA-Z0-9_:-]") | not)))
			and (.identity == "panel" or .identity == "root" or .identity == "manual"))
		and ([.commands[] | .name, .aliases[]] | length == (unique | length)))
	' "$metadata" >/dev/null 2>&1 || return 1
	jq -c . "$metadata" | cmp -s - "$metadata"
}

console_identity_release_gate() {
	console_identity_required || return 0
	console_identity_release_valid "$1" && return 0
	engine_log "console identity: target $1 lacks valid schema 1 metadata/resolver; keep the current release or install a compatible target (SC-355)"
	return 1
}

# console_identity_target_preflight <target> — 0 compatible, 1 incompatible,
# 2 the target tarball could not be fetched or verified. The callers keep 2
# apart: with repo.shcp.dev unreachable mid-download a run used to report
# "incompatible with the active console identity wrapper", which sends the
# operator after a version problem that does not exist (#3337).
console_identity_target_preflight() {
	local target="$1" version tarball probe member
	console_identity_required || return 0
	version="$(jq -r '.version' <<<"$target")"
	probe="$(mktemp -d)" || return 2
	# Fetch to the run's shared candidate path so the stage_panel sites after this
	# reuse the transfer (shcp-updater#136); only the extracted members below are
	# throwaway. With no run context, or a run dir panel_candidate_cache_path will
	# not vouch for, the tarball stays in the private probe dir instead.
	if ! tarball="$(panel_candidate_cache_path "$version")"; then
		tarball="$probe/panel.tar.gz"
	fi
	if ! artifact_fetch_verify "$(jq -r '.url' <<<"$target")" "$(jq -r '.sig_url' <<<"$target")" \
			"$(jq -r '.sha256' <<<"$target")" "$tarball"; then
		rm -rf "$probe"
		return 2
	fi
	mkdir -p "$probe/release/config" "$probe/release/bin" "$probe/release/src/Console"
	# Extract known members to stdout rather than trusting archive pathnames.
	# GNU tar emits no bytes for a symlink member; the nonempty-file gate rejects it.
	# Read the inode artifact_fetch_verify checked, not the shared cache path.
	for member in config/console-identities.json bin/command-identity src/Console/CommandIdentityManifest.php; do
		if ! tar -xOzf "$ARTIFACT_HELD" "$member" > "$probe/release/$member" 2>/dev/null; then
			tar -xOzf "$ARTIFACT_HELD" "./$member" > "$probe/release/$member" 2>/dev/null || true
		fi
	done
	artifact_close
	local rc=0
	console_identity_release_gate "$probe/release" || rc=1
	rm -rf "$probe"
	[[ $rc == 0 ]] || engine_log "preflight: panel ${version} is incompatible with the active console wrapper"
	return "$rc"
}

# console_identity_refusal <rc> — the stage reason for a non-zero
# console_identity_target_preflight.
console_identity_refusal() {
	if [[ $1 == 2 ]]; then
		printf 'target release could not be downloaded or verified; check that the package repository is reachable, then retry'
	else
		printf 'target release is incompatible with the active console identity wrapper'
	fi
}

console_environment_install() {
	local config_dir="${SHCP_CONSOLE_CONFIG_DIR:-/etc/shcp}"
	local tmpfiles_dir="${SHCP_CONSOLE_TMPFILES_DIR:-/usr/lib/tmpfiles.d}"
	local executable resolved path mode tmp
	# Prove tools and both complete directory chains before creating any files.
	for executable in "$OS_SETPRIV" "$OS_FLOCK"; do
		[[ "$executable" == /* && "$executable" != *[!a-zA-Z0-9_./-]* && -f "$executable" && -x "$executable" ]] || return 1
		resolved="$(readlink -f -- "$executable")" || return 1
		path="$resolved"
		while :; do
			[[ $(stat -c %u -- "$path") == 0 ]] || return 1
			mode="$(stat -c %a -- "$path")" || return 1
			(( (8#$mode & 0022) == 0 )) || return 1
			[[ "$path" == / ]] && break
			path="$(dirname "$path")"
		done
	done
	for path in "$config_dir" "$tmpfiles_dir"; do
		[[ "$path" == /* ]] || return 1
		while :; do
			[[ ! -L "$path" ]] || return 1
			if [[ -e "$path" ]]; then
				[[ -d "$path" && $(stat -c %u -- "$path") == 0 ]] || return 1
				mode="$(stat -c %a -- "$path")" || return 1
				(( (8#$mode & 0022) == 0 )) || return 1
			fi
			[[ "$path" == / ]] && break
			path="$(dirname "$path")"
		done
	done
	install -d -m 0755 -o root -g root -- "$config_dir" "$tmpfiles_dir" || return 1
	tmp="$(mktemp "$config_dir/.console.env.XXXXXX")" || return 1
	if ! printf 'OS_SETPRIV=%s\nOS_FLOCK=%s\n' "$OS_SETPRIV" "$OS_FLOCK" > "$tmp" \
		|| ! chmod 0644 "$tmp" || ! mv -fT -- "$tmp" "$config_dir/console.env"; then
		rm -f -- "$tmp"; return 1
	fi
	tmp="$(mktemp "$tmpfiles_dir/.shcp-console.XXXXXX")" || return 1
	# f creates only when absent (f+ would replace the coordinating inode), but it
	# does reset an existing inode's mode/owner, so it tightens a legacy 0640 lock
	# in place once the root-only wrapper is installed (shcp-installer#1033).
	local lock_line
	if console_lock_root_only; then
		lock_line='f /run/shcp/release-lifecycle.lock 0600 root root -'
	else
		lock_line='f /run/shcp/release-lifecycle.lock 0640 root shcp -'
	fi
	if ! printf 'd /run/shcp 0755 root root -\n%s\n' "$lock_line" > "$tmp" \
		|| ! chmod 0644 "$tmp" || ! mv -fT -- "$tmp" "$tmpfiles_dir/shcp-console.conf"; then
		rm -f -- "$tmp"; return 1
	fi
	shcp_release_lifecycle_prepare
}

shcp_release_lifecycle_prepare() {
    local lock="${SHCP_RELEASE_LIFECYCLE_LOCK:-/run/shcp/release-lifecycle.lock}"
    local expected_uid="${SHCP_RELEASE_LIFECYCLE_EXPECT_UID:-0}" expected_gid
    local parent path mode lock_mode actual shcp_gid=""
    shcp_gid="$(getent group shcp | cut -d: -f3)" || shcp_gid=""
    [[ "$shcp_gid" =~ ^[0-9]+$ && "$shcp_gid" != 0 ]] || shcp_gid=""
    if console_lock_root_only; then
        expected_gid="${SHCP_RELEASE_LIFECYCLE_EXPECT_ROOT_GID:-0}"
        lock_mode=600
    elif [[ -n ${SHCP_RELEASE_LIFECYCLE_EXPECT_GID:-} ]]; then
        expected_gid="$SHCP_RELEASE_LIFECYCLE_EXPECT_GID"
        lock_mode=640
    elif [[ -n "$shcp_gid" ]]; then
        expected_gid="$shcp_gid"
        lock_mode=640
    else
        expected_gid=0
        lock_mode=600
    fi
    parent="$(dirname "$lock")"
    [[ "$lock" == /* && ! -L "$parent" && ! -L "$lock" ]] || return 1
    if [[ ! -e "$parent" ]]; then
        mkdir -m 0755 -- "$parent" || return 1
    fi
    # The immediate parent must be private to root. Its ancestors may include
    # root-owned sticky /tmp in isolated tests, but never an untrusted directory.
    path="$parent"
    while :; do
        [[ -d "$path" && ! -L "$path" ]] || return 1
        [[ $(stat -c %u -- "$path") == "$expected_uid" || $(stat -c %u -- "$path") == 0 ]] || return 1
        mode="$(stat -c %a -- "$path")" || return 1
        if (( (8#$mode & 0022) != 0 )); then
            [[ "$path" != "$parent" ]] && (( (8#$mode & 01000) != 0 )) || return 1
        fi
        [[ "$path" == / ]] && break
        path="$(dirname "$path")"
    done
    if [[ ! -e "$lock" ]]; then
        # noclobber makes simultaneous first creation harmless; validate the
        # winner below. Opening an existing file must never truncate it.
        (umask 077; set -o noclobber; : > "$lock") 2>/dev/null || [[ -f "$lock" ]] || return 1
    fi
    [[ -f "$lock" && ! -L "$lock" ]] || return 1
    actual="$(stat -c '%u:%g:%a:%h' -- "$lock")" || return 1
    # A fresh install acquires the lock before the package creates shcp. Keep
    # that inode private until its trusted reader group becomes available.
    # Even read-only access permits LOCK_EX, so never expose it to tenants.
    # Convert only shapes the installer/updater themselves leave: a just-created
    # root:root 0600, a root:shcp 0600 from a fresh install that had the group,
    # and (when tightening to root-only) the root:shcp 0640 an older run left.
    # Never adopt an arbitrary owner or mode.
    case "$actual" in
        "$expected_uid:$expected_gid:$lock_mode:1") ;;
        "$expected_uid:0:600:1" \
        | "$expected_uid:$expected_gid:600:1" \
        | "$expected_uid:$expected_gid:640:1" \
        | "$expected_uid:${shcp_gid:-x}:600:1" \
        | "$expected_uid:${shcp_gid:-x}:640:1")
            chgrp "$expected_gid" -- "$lock" && chmod "$lock_mode" -- "$lock" || return 1 ;;
    esac
    [[ $(stat -c '%u:%g:%a:%h' -- "$lock") == "$expected_uid:$expected_gid:$lock_mode:1" ]]
}

shcp_release_lifecycle_acquire() {
    local lock="${SHCP_RELEASE_LIFECYCLE_LOCK:-/run/shcp/release-lifecycle.lock}"
    local wait="${SHCP_RELEASE_LIFECYCLE_WAIT:-30}" identity
    [[ -z "$SHCP_RELEASE_LIFECYCLE_FD" && "$wait" =~ ^[0-9]+$ ]] || return 1
    shcp_release_lifecycle_prepare || { printf 'Unsafe release lifecycle lock: %s\n' "$lock" >&2; return 1; }
    exec {SHCP_RELEASE_LIFECYCLE_FD}<>"$lock" || return 1
    if ! identity="$(stat -Lc '%d:%i' -- "/proc/self/fd/$SHCP_RELEASE_LIFECYCLE_FD")" \
        || [[ "$identity" != "$(stat -c '%d:%i' -- "$lock")" ]] \
        || ! "$OS_FLOCK" -x -w "$wait" "$SHCP_RELEASE_LIFECYCLE_FD"; then
        # Name THIS lock. Callers used to report every acquire_lock failure as
        # "another shcp-update run holds <run lock>", so a 30 s wait on an
        # operator's `shcp` console command was diagnosed as a stuck engine.
        LOCK_BUSY_REASON="the release lifecycle lock ${lock} is held by an operator console command (waited ${wait}s); retry after it exits"
        printf 'Release is busy with an operator command; retry after it exits (%s).\n' "$lock" >&2
        exec {SHCP_RELEASE_LIFECYCLE_FD}>&-
        SHCP_RELEASE_LIFECYCLE_FD=""
        return 1
    fi
}

# shcp_release_lifecycle_held — 0 when an install or update holds the lifecycle
# lock exclusively right now. A shared probe, dropped at once: console commands
# hold it shared and never make this true.
shcp_release_lifecycle_held() {
    local lock="${SHCP_RELEASE_LIFECYCLE_LOCK:-/run/shcp/release-lifecycle.lock}" fd rc=1
    shcp_release_lifecycle_prepare || return 1
    exec {fd}<"$lock" || return 1
    "$OS_FLOCK" -s -n "$fd" || rc=0
    exec {fd}>&-
    return "$rc"
}

shcp_release_lifecycle_release() {
    [[ -n "$SHCP_RELEASE_LIFECYCLE_FD" ]] || return 0
    local rc=0
    "$OS_FLOCK" -u "$SHCP_RELEASE_LIFECYCLE_FD" || rc=1
    exec {SHCP_RELEASE_LIFECYCLE_FD}>&-
    SHCP_RELEASE_LIFECYCLE_FD=""
    return "$rc"
}


LOCK_FD=""

# run_lock_file_private <lock> -- ported from the installer's shcp_lock_file_private
# (SC-500). The lock must be a regular file of this uid at mode 0600 (created that
# way when absent). flock(2) needs no write access, so a 0644 lock in a root-owned
# directory can still be opened read-only and held by any local account, which
# stalls every root run exactly as a squatted /run/lock path did.
run_lock_file_private() {
	local lock="$1" me
	me="$(id -u)"
	if [[ ! -e "$lock" && ! -L "$lock" ]]; then
		( umask 077; : >>"$lock" ) 2>/dev/null || { engine_log "cannot create run lock ${lock}"; return 1; }
	fi
	if [[ -L "$lock" || ! -f "$lock" ]] || [[ "$(stat -c '%u' -- "$lock" 2>/dev/null)" != "$me" ]]; then
		engine_log "run lock ${lock} is not a regular file of uid ${me}; refusing (SC-500)"
		return 1
	fi
	[[ "$(stat -c '%a' -- "$lock" 2>/dev/null)" == 600 ]] || chmod 0600 -- "$lock" \
		|| { engine_log "cannot make run lock ${lock} private"; return 1; }
}

# The run lock lives in root-owned /run/shcp, never world-writable /run/lock
# (SC-500): refuse a lock dir that is not root-owned and private, a symlinked dir
# or lock, and a lock that is not a root-owned regular file at 0600; open for
# append so a run never truncates the path. flock(2) needs no write access, which
# is why the owner/mode checks matter even though we only flock the descriptor.
# Why acquire_lock refused, for the caller's message: the run lock and the
# release lifecycle lock are different locks held by different parties (a
# second engine vs an operator console command), and the remedy differs. Empty
# after a refusal means the lock could not be TAKEN at all (an SC-500 owner/mode
# refusal above) — not busy, an operator has to look.
LOCK_BUSY_REASON=""
acquire_lock() {
	acquire_run_lock || return 1
	# Every mutating verb already takes this run lock. Also exclude operator
	# console execution before any package, release or rollback changes (SC-355).
	if ! shcp_release_lifecycle_acquire; then
		exec {LOCK_FD}>&-
		LOCK_FD=""
		[[ -n "$LOCK_BUSY_REASON" ]] \
			|| LOCK_BUSY_REASON="the release lifecycle lock ${SHCP_RELEASE_LIFECYCLE_LOCK:-/run/shcp/release-lifecycle.lock} could not be prepared or taken"
		return 1
	fi
	return 0
}

# acquire_run_lock — the run lock alone. Only a verb that never touches the
# panel release, packages or units takes it without the lifecycle lock: the
# webmail verbs, which take the lifecycle lock themselves only once they have
# a transaction or a recovery to run (webmail_lifecycle).
acquire_run_lock() {
	local dir mode owner me
	LOCK_BUSY_REASON=""
	me="$(id -u)"
	dir="$(dirname -- "$SHCP_UPDATE_LOCK")"
	if [[ -L "$dir" ]]; then
		engine_log "run lock dir ${dir} is a symlink; refusing (SC-500)"
		return 1
	fi
	if [[ ! -d "$dir" ]]; then
		install -d -m 0755 -- "$dir" || { engine_log "cannot create run lock dir ${dir}"; return 1; }
		chown 0:0 -- "$dir" 2>/dev/null || true
	fi
	mode="$(stat -c '%a' -- "$dir" 2>/dev/null)" || return 1
	owner="$(stat -c '%u' -- "$dir" 2>/dev/null)" || return 1
	if (( (8#$mode & 8#022) != 0 )); then
		engine_log "run lock dir ${dir} is group- or world-writable (mode ${mode}); refusing (SC-500)"
		return 1
	fi
	if [[ "$owner" != 0 && "$owner" != "$me" ]]; then
		engine_log "run lock dir ${dir} is owned by uid ${owner}; refusing (SC-500)"
		return 1
	fi
	if [[ -L "$SHCP_UPDATE_LOCK" ]]; then
		engine_log "run lock ${SHCP_UPDATE_LOCK} is a symlink; refusing (SC-500)"
		return 1
	fi
	run_lock_file_private "$SHCP_UPDATE_LOCK" || return 1
	exec {LOCK_FD}>>"$SHCP_UPDATE_LOCK"
	if ! flock -n "$LOCK_FD"; then
		exec {LOCK_FD}>&-
		LOCK_FD=""
		LOCK_BUSY_REASON="another shcp-update run holds the run lock ${SHCP_UPDATE_LOCK}"
		return 1
	fi
	return 0
}

# lock_is_held — read-only probe used by check --blockers. Three answers:
#   0  held: a shared no-wait flock was refused, so a process holds it exclusively
#   1  free
#   2  UNKNOWN: the lock file could not be opened at all (a non-root caller, a
#      missing /run/lock subdir). This used to read as "held", which made a
#      non-root `check` report lock_held AND suppress stale_running_journal for
#      a run that nothing was actually running. Unknown is reported as unknown;
#      cmd_check treats it as not held and says so.
lock_is_held() {
	# Read-only probe used by check --blockers: try a shared no-wait lock in a
	# subshell; failure means another process holds the exclusive lock. umask 077
	# so a lock this probe conjures into existence is private (0600), not a
	# world-readable file an unprivileged account could then hold shared (SC-500).
	# 2 = cannot tell: the lock file could not be opened at all (a non-root
	# caller, a missing dir); callers must not read that as "held".
	if ! ( umask 077; exec 9>>"$SHCP_UPDATE_LOCK" ) 2>/dev/null; then
		return 2
	fi
	if ( umask 077; exec 9>>"$SHCP_UPDATE_LOCK" 2>/dev/null && flock -n -s 9 ); then
		return 1
	fi
	return 0
}

# --- stage bodies --------------------------------------------------------------
# Stub contract for Wave B: each stage body is a function `stage_<name>`
# (dashes → underscores) that runs IN THE MAIN SHELL (no command
# substitution — self-update needs `exec`, panel needs the maintenance
# flag, preflight sets WORKSET_EMPTY), sets STAGE_RESULT to a JSON result
# object and returns non-zero on failure. The runner records stage_started
# before and stage_done (with STAGE_RESULT) after; failures journal
# status=failed and abort. UPD-2/3/4/5 replace the stub bodies ONLY —
# runner, journal, status and resume semantics stay untouched.

STAGE_RESULT='{}'
WORKSET_EMPTY=1
# The panel release preflight resolved for this run (compact JSON), or empty.
# Set in preflight so the work-set decision and the panel stage agree.
PANEL_CANDIDATE=""
# UPD-5. Declared here so they exist under `set -u` for a resumed process that
# re-enters past the stage that would have set them.
#   HEALTH_STATUS       healthy|unhealthy — the §4.2-7 verdict (also recoverable
#                       from the journal's .health.status).
#   AUTO_ROLLBACK_FIRED the process-local half of the single-entry guard
#                       (SC-377); the journal record is
#                       the half that survives a kill -9.
#   ROLLBACK_OUTCOME    rolled_back | rolled_back_partial | failed, or empty
#                       when no rollback was performed in this process.
#   HEALTH_JOURNAL_KEY  which top-level journal key stage_health writes its
#                       result to. `health` for the run's own verdict;
#                       rollback_run swaps it to `rollback_health` for its
#                       post-rollback re-measurement so the verdict that
#                       triggered the rollback is not overwritten by it.
#   HEALTH_REGRESSION   true|false — whether an `unhealthy` verdict includes a
#                       check THIS run broke (recoverable from .health.regression;
#                       absent reads true). false means every failing check was
#                       already failing at the preflight baseline: the run is
#                       still unhealthy, but a rollback cannot fix it, so none
#                       fires (shcp-updater#181).
HEALTH_STATUS=""
HEALTH_REGRESSION=true
AUTO_ROLLBACK_FIRED=0
ROLLBACK_OUTCOME=""
HEALTH_JOURNAL_KEY="health"
# Cross-reboot resume (shcp-build#97). A stage sets this to 1 to ask run_stages
# to park the run for a deliberate reboot once the stage completes. Declared here
# so it exists under `set -u`; no production stage sets it yet (the OS-upgrade
# epic and the SHCP_UPDATE_REBOOT_AT_STAGE drill seam are the callers).
REBOOT_AND_RESUME_REQUESTED=0
# UPD-9 series-run signal. ONE fact the stages branch on: does this run cross the
# panel minor series? Set once in run_stages (after the request state is
# recovered, so a resumed process reads the same answer) and read by the
# series-gated stages, all of which run BEFORE the panel flip. See is_series_run.
SERIES_RUN=0

# --- OS-family package seam (UPD-2, AD-11) ------------------------------------
# EVERY apt/dpkg/dnf/rpm call lives behind an osf_* function, dispatched on the
# OS family. The rest of the engine never names a package manager — so the EL
# backend is a drop-in and the deb path can be exercised on any host via the
# test shims. AD-11 minimum for EL: baseline + security scope + restart
# detection; the deb backend is complete. Package names crossing this boundary
# are validated `^[a-z0-9][a-z0-9.+-]*$` (SC-002) and only ever passed as array
# elements — never word-split, never eval'd.
OS_FAMILY=""

osf_detect_family() {
	if [[ -n "${SHCP_UPDATE_OS_FAMILY:-}" ]]; then
		OS_FAMILY="$SHCP_UPDATE_OS_FAMILY"
	else
		# Fail CLOSED on an unknown or unreadable os-release. This used to default
		# to deb, so a box the engine has never been qualified on (or one whose
		# os-release was damaged) ran the apt arms blind — and every osf_* verb
		# that dispatches on the family would then have been reasoning about the
		# wrong package manager, with the rollback direction the most exposed.
		# The 1.0 targets are exactly RHEL 10 and its rebuilds (ID=rhel/almalinux/
		# rocky/centos, ID_LIKE carrying rhel/centos/fedora), Debian 13/14 and
		# Ubuntu 26.04 (ID_LIKE=debian). SHCP_UPDATE_OS_FAMILY remains the
		# operator's explicit override for a known-compatible derivative.
		local osr="${SHCP_UPDATE_OS_RELEASE:-/etc/os-release}" id="" id_like=""
		if [[ -r "$osr" ]]; then
			id="$(awk -F= '$1=="ID"{gsub(/"/,"",$2); print tolower($2)}' "$osr" 2>/dev/null || true)"
			id_like="$(awk -F= '$1=="ID_LIKE"{gsub(/"/,"",$2); print tolower($2)}' "$osr" 2>/dev/null || true)"
		fi
		case " ${id} ${id_like} " in
			*" debian "*|*" ubuntu "*) OS_FAMILY="deb" ;;
			*" rhel "*|*" fedora "*|*" centos "*|*" almalinux "*|*" rocky "*) OS_FAMILY="rpm" ;;
			*) die "unsupported OS: cannot resolve the package family from ${osr} (ID='${id}' ID_LIKE='${id_like}'); supported: RHEL/AlmaLinux/Rocky/CentOS Stream 10 (rpm), Debian 13/14 and Ubuntu 26.04 (deb). Refusing rather than guessing — set SHCP_UPDATE_OS_FAMILY=deb|rpm only for a known-compatible derivative" ;;
		esac
	fi
	case "$OS_FAMILY" in deb|rpm) ;; *) die "unsupported OS family: ${OS_FAMILY}" ;; esac
	reconcile_resolve_apache_paths
}

# osf_pkg_db_present — 0 when the family's installed-package database tool is
# on PATH. The baseline gate below distinguishes "no package manager to ask"
# (a bare dev host: proceed with an empty baseline) from "the package manager
# answered nothing" (refuse: see stage_preflight).
osf_pkg_db_present() {
	case "$OS_FAMILY" in
		deb) command -v dpkg-query >/dev/null 2>&1 ;;
		rpm) command -v rpm >/dev/null 2>&1 ;;
		*) return 1 ;;
	esac
}

# A package name, validated per family (SC-002). Both patterns are anchored and
# contain NO shell metacharacters, so a name from the package manager can never
# inject argv into osf_apply. deb: lowercase per Debian policy + optional
# multiarch `:arch`. rpm: RPM names are mixed-case and may contain `_`.
osf_valid_pkg() {
	case "$OS_FAMILY" in
		rpm) [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]] ;;
		*)   [[ "$1" =~ ^[a-z0-9][a-z0-9.+-]*(:[a-z0-9]+)?$ ]] ;;
	esac
}

# osf_baseline — installed packages as `name<TAB>version` lines (journal
# packages.before/after are built from this). Non-zero when the package
# database could not be read — NEVER `|| true` here: a swallowed dpkg-query
# failure yields an empty before-baseline, journal_changed_json then marks every
# installed package {from:null,to:ver} ("this run installed it"), and
# rollback_run's undo for that shape is `apt-get remove` of each one, i.e. of
# the whole box (SC-791).
osf_baseline() {
	case "$OS_FAMILY" in
		deb) dpkg-query -W -f='${Package}\t${Version}\n' 2>/dev/null \
			|| { engine_log "package baseline: dpkg-query failed (exit $?)"; return 1; } ;;
		rpm)
			local installonly
			if ! installonly="$(dnf -q repoquery --installed --installonly --qf '%{name}-%{epoch}:%{version}-%{release}.%{arch}' 2>/dev/null)"; then
				engine_log "package baseline: could not classify installonly RPM instances"
				return 1
			fi
			rpm -qa --qf '%{EPOCHNUM}\t%{NAME}\t%{VERSION}\t%{RELEASE}\t%{ARCH}\t%{NAME}-%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\t%{SOURCERPM}\n' 2>/dev/null \
				| while IFS=$'\t' read -r epoch name version release arch nevra source_rpm; do
					[[ -n "$name" && -n "$nevra" ]] || continue
					local io=false
					grep -Fqx -- "$nevra" <<<"$installonly" && io=true
					# SOURCERPM is the authoritative build identity shared by native and
					# noarch subpackages.  Strip only its source-architecture suffix; an
					# absent/placeholder value deliberately remains empty so rollback
					# treats the package as a singleton rather than guessing a group.
					[[ "$source_rpm" == "(none)" ]] && source_rpm=""
					source_rpm="${source_rpm%.src.rpm}"
					printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$epoch" "$name" "$version" "$release" "$arch" "$nevra" "$io" "$source_rpm"
				done ;;
	esac
}

# osf_refresh_index — refresh the package index. Returns non-zero instead of
# dying so each CALLER picks the severity, and leaves INDEX_REFRESH_DETAIL naming
# what failed. Stage 0 and preflight record an incomplete refresh and carry on with
# what DID refresh: SC-319's security run "fails toward patching", and one dead
# third-party source must not stop a pending security update that
# unattended-upgrades would apply. Only the series stage, whose index has to
# reflect a suite it just switched to, treats a failure as fatal.
#
# APT::Update::Error-Mode=any (apt >= 2.1.16; every supported deb target ships a
# newer apt): without it apt's update exits 0 when a source cannot be fetched
# ("W: Failed to fetch ... Some index files failed to download"), so a box whose
# repos were unreachable looked refreshed, journaled noop from stale lists and
# stamped last_security_success, feeding the watchdog a false proof (found live,
# shcp-installer#742). apt still refreshes every reachable source before exiting
# 100, so carrying on with the result is sound. dnf's makecache already fails on an
# unreachable repo (skip_if_unavailable defaults to False and shcp.repo does not
# set it). An apt too old to know the key ignores an unknown -o.
#
# --- apt fetches: no delayed retry (shcp-updater#174, SC-686) --
# apt 3.0.x (Debian 13) and 2.7.x (Ubuntu 24.04) can spin in pkgAcquire::Run and
# eat all RAM. A transiently failed download is re-queued with a retry delay
# (Acquire::Retries, default 3). If that host's pipeline of 10 is still full when
# the delay runs out, Run() cannot start the item, pushes "Tried to start delayed
# item ..., but failed" onto an in-memory error list, and polls again with a zero
# select() timeout, millions of times a second. Debian #1111554, fixed in apt
# 3.1.8; no trixie 3.0.y release carries the fix. shcp-installer#946 reproduced
# it on a 4096 MB swapless Debian 13 VM: 3.77 GB anon-rss and an OOM kill under a
# 70 s mirror stall, 69 MB with Acquire::Retries=0. Pipeline tuning
# (Retries::Delay=false, Max-Pipeline-Depth=1, Pipeline-Depth=0) still spun.
#
# On a customer box the OOM killer picks from the tenants' databases and PHP
# workers, and this engine runs apt nightly with the largest work sets the box
# ever sees. So every apt-get that downloads goes through apt_get_fetch, which
# turns apt's own retry off and re-runs the whole command instead. The re-run is
# safe: apt stops before dpkg when a fetch fails, and the archives it already has
# stay in the cache. Exempt, because none of them can fill a host pipeline:
# `apt-get -s` and `remove`/`check` download nothing, and `changelog` fetches one
# item. tests/test-updater-apt-no-delayed-retry.sh derives the call sites from
# this file and fails on a fetching apt-get without Acquire::Retries=0.
#
# deb only. dnf/librepo has no such loop: installer#946 ran the same stall
# against AlmaLinux 10.2 (dnf 4.20, librepo 1.19) and dnf stayed at 69 MB. The
# rpm arms keep dnf's own retries and gain no re-run.
# Remove this once every supported deb release ships apt >= 3.1.8.

# apt_fetch_failed — captured apt-get output (stdin) shows the command failed at
# the DOWNLOAD stage. A lock, a missing package, a bad signature or a dpkg error
# is not a download failure; running the same command again cannot fix it.
# Anchored at line start, so a progress line that merely quotes the words cannot
# match.
apt_fetch_failed() {
	grep -qE '^(E|W): Failed to fetch |^E: Unable to fetch some archives|^(E|W): Some index files failed to download'
}

# apt_classify_apply_failure <apt-stderr> -- when apt REFUSED a transaction for a
# reason the operator must act on, set APT_APPLY_DETAIL to a named, secret-free
# reason so the caller reports it instead of a bare exit code. Today it names the
# one refusal a security/update run must never paper over: the resolver selecting
# package DOWNGRADES, which apt aborts because osf_apply deliberately withholds
# --allow-downgrades (a security run never moves packages backward). The message is
# a FIXED string, never apt's raw stderr, so a source URL or credential in an E:/W:
# line can never reach a notification. deb only: apt is the only family that offers
# a downgrade under an upgrade verb -- dnf `update` is one-directional and simply
# ignores a lower-versioned candidate, so there is no rpm counterpart to classify.
apt_classify_apply_failure() {
	if grep -qiE 'were downgraded and .*--allow-downgrades' <<<"$1"; then
		APT_APPLY_DETAIL="the package resolver selected package DOWNGRADES and the engine refuses to apply them without --allow-downgrades (a security/update run never moves packages backward); nothing was applied -- see the run log for the packages and versions"
	fi
}

# apt_get_fetch <apt-get args>... — apt-get with its delayed retry off, re-run
# after a backoff while the only failure is a download, at most
# SHCP_APT_FETCH_ATTEMPTS times. Returns apt-get's own exit code, so a lock (100)
# or an OOM kill (137) reaches the caller exactly as before.
#
# The streams stay where a bare apt-get put them: stdout is the verbs' JSON
# channel, and apt writes its E:/W: lines to stderr. Only stderr is captured to
# classify (fd 3 carries stdout past the capture), and it is replayed to stderr
# as soon as apt exits.
#
# APT_FETCH_DOWNLOAD_FAILED=1 after a run whose last failure was a download, so
# osf_apt_transaction can run its own previewed re-run instead (#321).
APT_FETCH_DOWNLOAD_FAILED=0
apt_get_fetch() {
	local attempt=1 rc err
	APT_APPLY_DETAIL=""
	APT_FETCH_DOWNLOAD_FAILED=0
	while :; do
		rc=0
		err="$(DEBIAN_FRONTEND=noninteractive apt-get "${APT_LOCK_OPT[@]}" "${APT_PHASED_OPT[@]}" -o Acquire::Retries=0 "$@" 2>&1 1>&3 3>&-)" || rc=$?
		[[ -z "$err" ]] || printf '%s\n' "$err" >&2
		if (( rc == 0 || attempt >= SHCP_APT_FETCH_ATTEMPTS )) || ! apt_fetch_failed <<<"$err"; then
			(( rc == 0 )) || apt_classify_apply_failure "$err"
			(( rc == 0 )) || ! apt_fetch_failed <<<"$err" || APT_FETCH_DOWNLOAD_FAILED=1
			return "$rc"
		fi
		engine_log "apt: a package download failed (attempt ${attempt}/${SHCP_APT_FETCH_ATTEMPTS}); re-running in $(( SHCP_APT_FETCH_BACKOFF * attempt ))s"
		sleep "$(( SHCP_APT_FETCH_BACKOFF * attempt ))"
		attempt=$(( attempt + 1 ))
	done 3>&1
}

# --- package transactions never remove what the box runs on (shcp-updater#321) --
# A rollback's exact-version `apt-get --allow-downgrades install libcurl4t64=<old>`
# let apt satisfy the downgrade by REMOVING shcpd, shcp-updater, curl and apache2;
# the panel, the engine and the web front went with it and nothing alerted. apt
# and dnf both treat a removal as an acceptable way to resolve a transaction, so
# every engine call that changes the package set previews the transaction first
# and classifies each package it would remove:
#   refused -- a PROTECTED package: the product's essential set
#              (osf_essential_pkgs), SHCP's own packages (shcp*, shmp*), the
#              runtime packages the signed manifest declares the installer lays
#              down (osf_installer_pkgs) and the packages that ship the services
#              the panel runs (osf_service_pkgs);
#   allowed -- a package the same transaction replaces (a Debian Replaces +
#              Conflicts transition such as the t64 renames, osf_apt_replaced; a
#              dnf Obsoletes rename), an
#              automatically installed dependency, an installonly instance dnf
#              rotates out at installonly_limit (a kernel), and a package this run
#              installed (journaled from:null, SC-078);
#   refused -- anything else: a package the operator installed that nothing in
#              the transaction replaces.
# Essentials are refused even when this run installed them. Whether a package was
# operator-installed is NOT a protection on its own: debootstrap marks every base
# package manual and the installer's `dnf install` marks what it installs
# user-installed, so a rename or transition of such a package is routine. A
# classification input that cannot be read refuses. Undoing an install
# (osf_remove_one) narrows "allowed" to packages this run installed.
OSF_TRANSACTION_DETAIL=""
# OSF_REFUSAL_KIND is "removal" when the last preview refused a planned removal
# (osf_apply holds the offending packages back), empty for any other refusal.
OSF_REFUSAL_KIND=""

# osf_essential_pkgs — the packages the resolved product cannot run without, one
# per line: the engine's own package and the curl it fetches with, plus the
# panel's daemon and web server (the package carries the unit's name on both
# families) or a layered product's distinct package. An unresolved product gets
# the union, since refusing a removal is the safe side.
osf_essential_pkgs() {
	printf '%s\n' curl shcp-updater
	case "${PRODUCT:-}" in
		shcp)          printf '%s\n' shcpd "$(osf_web_server_unit)" ;;
		shcp-dns|shmp) printf '%s\n' "$(product_distinct_pkg "$PRODUCT")" ;;
		*)             printf '%s\n' shcpd "$(osf_web_server_unit)" shcp-dns shmp-base ;;
	esac
}

# osf_installer_pkgs — the runtime packages the installer lays down, as far as
# the engine can know them: the signed manifest's installer.<family>.
# runtime_packages that are also on the compiled-in allow-list
# (runtime_deps_declared / runtime_dep_allowed, SC-543). The engine has no other
# copy of the installer's package list; a run without a verified manifest
# (scope=packages, scope=security) protects the essentials and shcp* alone.
osf_installer_pkgs() {
	local pkg
	while IFS= read -r pkg; do
		[[ -n "$pkg" ]] && runtime_dep_allowed "$pkg" && printf '%s\n' "$pkg"
	done < <(runtime_deps_declared)
}

# osf_service_pkgs — the packages that ship the product's own services, one per
# line: the owner of each unit the engine manages (SHCP_MANAGED_SERVICES, globs
# included, so the PHP-FPM of any series counts) that is installed on this box --
# the MariaDB server, Postfix, Dovecot, PHP-FPM, PowerDNS, rspamd, valkey and the
# web server, under whatever package name this family and its overlay use. Read
# from the package database, not from a list, because the names differ by family
# and by repository (dovecot-core vs dovecot, php8.4-fpm vs php85-php-fpm). The
# DNS-only edition protects its DNS stack; shmp manages no OS service.
# OSF_PKG_UNIT_DIR is where an rpm-family box keeps package units.
OSF_PKG_UNIT_DIR="${SHCP_UPDATE_PKG_UNIT_DIR:-/usr/lib/systemd/system}"
osf_service_pkgs() {
	local unit f
	local -a units=()
	case "${PRODUCT:-}" in
		shcp-dns) units=(pdns pdns-recursor) ;;
		shmp)     units=() ;;
		*)        units=("${SHCP_MANAGED_SERVICES[@]}") ;;
	esac
	for unit in "${units[@]+"${units[@]}"}"; do
		case "$OS_FAMILY" in
			deb) dpkg-query -S "*/systemd/system/${unit}.service" 2>/dev/null \
				| awk -F': ' 'NF >= 2 && $1 !~ /^diversion / {n = split($1, p, ", "); for (i = 1; i <= n; i++) print p[i]}' ;;
			rpm)
				for f in "${OSF_PKG_UNIT_DIR}"/${unit}.service; do
					[[ -e "$f" ]] && rpm -qf --qf '%{NAME}\n' "$f" 2>/dev/null
				done ;;
		esac
	done | sed 's/:.*//' | awk 'NF' | sort -u
}

# osf_removal_refusals <pkg>... — one "pkg (reason)" line for every planned
# removal the policy above refuses; no output means every removal is allowed.
# OSF_REPLACED lists the packages the transaction replaces, one per line.
OSF_REPLACED=""
osf_removal_refusals() {
	local pkg why essential installer services run_installed="" installonly="" auto="" auto_ok=true
	(( $# > 0 )) || return 0
	essential="$(osf_essential_pkgs)"
	installer="$(osf_installer_pkgs)"
	services="$(osf_service_pkgs)"
	if [[ -n "${CURRENT_RUN_ID:-}" ]]; then
		run_installed="$(jq -r '(.packages.changed // [])[] | select(.from == null) | .pkg' \
			"$(journal_path "$CURRENT_RUN_ID")" 2>/dev/null)" || run_installed=""
	fi
	case "$OS_FAMILY" in
		deb) auto="$(apt-mark showauto 2>/dev/null)" || auto_ok=false ;;
		rpm)
			# dnf has no "automatic" query; automatic is "not user-installed".
			local installed user
			installonly="$(dnf -q repoquery --installed --installonly --qf '%{name}' 2>/dev/null)" || installonly=""
			if installed="$(rpm -qa --qf '%{NAME}\n' 2>/dev/null)" \
					&& user="$(dnf -q repoquery --userinstalled --qf '%{name}' 2>/dev/null)"; then
				auto="$(grep -vxF -f <(printf '%s\n' "$user") <<<"$installed")"
			else
				auto_ok=false
			fi ;;
		*) auto_ok=false ;;
	esac
	for pkg in "$@"; do
		if ! osf_valid_pkg "$pkg"; then
			pkg="(unparseable name)"; why="cannot be classified"
		elif grep -qxF -- "$pkg" <<<"$essential"; then
			why="essential to ${PRODUCT:-this box}"
		elif grep -qxF -- "$pkg" <<<"$run_installed"; then
			continue
		elif [[ "${OSF_REMOVAL_SCOPE:-}" == run ]]; then
			why="not installed by this run"
		elif [[ "$pkg" == shcp* || "$pkg" == shmp* ]]; then
			why="an SHCP package"
		elif grep -qxF -- "$pkg" <<<"$installer"; then
			why="required by the installer"
		elif grep -qxF -- "$pkg" <<<"$services"; then
			why="ships a service the panel runs"
		elif grep -qxF -- "$pkg" <<<"$OSF_REPLACED" || grep -qxF -- "$pkg" <<<"$installonly"; then
			continue
		elif [[ "$auto_ok" != true ]]; then
			why="its automatic/manual state could not be read"
		elif grep -qxF -- "$pkg" <<<"$auto"; then
			continue
		else
			why="installed by the operator and not replaced by this transaction"
		fi
		printf '%s (%s)\n' "$pkg" "$why"
	done
}

# osf_transaction_refuse <detail> [removal] — record why a package transaction was
# refused. The detail names packages and fixed reasons only, never package-manager
# output, so a source URL or credential cannot reach stage_error or a notification.
osf_transaction_refuse() {
	OSF_TRANSACTION_DETAIL="$1"
	OSF_REFUSAL_KIND="${2:-}"
	[[ "$OS_FAMILY" != deb ]] || APT_APPLY_DETAIL="$1"
	engine_log "WARNING: ${1}"
}

# osf_refused_list <refusals> — osf_removal_refusals' lines as one phrase.
osf_refused_list() {
	paste -sd';' <<<"$1" | sed 's/;/, /g'
}

# osf_apt_removals <apt-get -s output> — the packages it removes or purges, sorted.
osf_apt_removals() {
	awk '$1 == "Remv" || $1 == "Purg" {print $2}' <<<"$1" | sort -u
}

# osf_apt_replaced <apt-get -s output> — the removed packages this transaction
# replaces, in the Debian whole-package replacement idiom (libfoo1t64 Replaces +
# Conflicts libfoo1): a package the transaction NEWLY installs (an Inst line with
# no installed version) declares Replaces AND Conflicts or Breaks against the
# removed package, and the removed package's installed version (its Remv line)
# satisfies both constraints. Nothing else counts. An upgrade or downgrade of a
# package already installed is not a replacement: mariadb-server-core carries
# `Replaces: mariadb-server (<< its own version)` for a file move, and reading it
# as a replacement let a one-package downgrade remove the database server
# (#331 break pass). A Provides is never one: sudo-ldap Provides sudo.
osf_apt_replaced() {
	local name ver rel rname op rver removed rv
	local -a new=()
	mapfile -t new < <(awk '$1 == "Inst" && $3 ~ /^\(/ { v = $3; sub(/^\(/, "", v); print $2, v }' <<<"$1")
	(( ${#new[@]} > 0 )) || return 0
	# Remv <name> [<installed version>]: what the transaction removes, at what version.
	declare -A removed_ver=()
	while read -r removed rv; do
		removed_ver["$removed"]="$rv"
	done < <(awk '$1 == "Remv" || $1 == "Purg" { v = $3; gsub(/[][]/, "", v); print $2, v }' <<<"$1")
	for name in "${new[@]}"; do
		ver="${name#* }"; name="${name%% *}"
		# One "<field> <name> <op> <version>" line per relation entry of the first
		# record; alternatives and architecture qualifiers are split off.
		rel="$(apt-cache show "${name}=${ver}" 2>/dev/null | awk '
			/^$/ { exit }
			/^(Replaces|Conflicts|Breaks):/ {
				f = $1; sub(/:$/, "", f); s = $0; sub(/^[^:]*:/, "", s)
				n = split(s, e, /[,|]/)
				for (i = 1; i <= n; i++) {
					x = e[i]; op = ""; v = ""
					if (match(x, /\(.*\)/)) {
						c = substr(x, RSTART + 1, RLENGTH - 2); x = substr(x, 1, RSTART - 1)
						gsub(/^[ \t]+|[ \t]+$/, "", c); split(c, cv, /[ \t]+/); op = cv[1]; v = cv[2]
					}
					gsub(/[ \t]/, "", x); sub(/:.*/, "", x)
					if (x != "") print f, x, (op == "" ? "-" : op), (v == "" ? "-" : v)
				}
			}')"
		for removed in "${!removed_ver[@]}"; do
			osf_apt_relation_holds Replaces "$removed" "${removed_ver[$removed]}" "$rel" || continue
			osf_apt_relation_holds Conflicts "$removed" "${removed_ver[$removed]}" "$rel" \
				|| osf_apt_relation_holds Breaks "$removed" "${removed_ver[$removed]}" "$rel" || continue
			printf '%s\n' "$removed"
		done
	done | sort -u
}

# osf_apt_relation_holds <field> <pkg> <installed-version> <relations> — 0 when a
# <field> entry names <pkg> unversioned, or with a constraint <installed-version>
# satisfies (dpkg --compare-versions). An unknown installed version satisfies only
# an unversioned entry.
osf_apt_relation_holds() {
	local field="$1" pkg="$2" have="$3" f n op v
	while read -r f n op v; do
		[[ "$f" == "$field" && "$n" == "$pkg" ]] || continue
		[[ "$op" == - ]] && return 0
		[[ -n "$have" ]] && dpkg --compare-versions "$have" "$op" "$v" 2>/dev/null && return 0
	done <<<"$4"
	return 1
}

# osf_apt_preview <apt-get args>... — simulate the transaction (apt-get -s takes
# no lock and fetches nothing) and leave what it would remove in OSF_APT_REMOVALS,
# sorted. 0 only when the simulation reached its summary line, the line's removal
# count equals the Remv/Purg lines read, and every removal is allowed. stderr is
# kept: apt names a refused downgrade set there (#241).
OSF_APT_REMOVALS=""
osf_apt_preview() {
	local out rc=0 count refused
	local -a removals=()
	OSF_APT_REMOVALS=""
	OSF_REFUSAL_KIND=""
	out="$(DEBIAN_FRONTEND=noninteractive apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 "$@" 2>&1)" || rc=$?
	if (( rc != 0 )); then
		printf '%s\n' "$out" >&2
		APT_APPLY_DETAIL=""
		apt_classify_apply_failure "$out"
		osf_transaction_refuse "${APT_APPLY_DETAIL:-apt could not resolve the transaction (simulation exit ${rc}); nothing was applied -- see the run log}"
		return 1
	fi
	count="$(sed -nE 's/^[0-9]+ upgraded, [0-9]+ newly installed, ([0-9]+ downgraded, )?([0-9]+) to remove and [0-9]+ not upgraded\.$/\2/p' <<<"$out" | tail -n 1)"
	mapfile -t removals < <(osf_apt_removals "$out")
	if [[ -z "$count" ]] || (( count != ${#removals[@]} )); then
		printf '%s\n' "$out" >&2
		osf_transaction_refuse "the apt simulation did not account for the packages it would remove; nothing was applied -- see the run log"
		return 1
	fi
	OSF_REPLACED=""
	(( ${#removals[@]} == 0 )) || OSF_REPLACED="$(osf_apt_replaced "$out")"
	refused="$(osf_removal_refusals "${removals[@]+"${removals[@]}"}")"
	if [[ -n "$refused" ]]; then
		osf_transaction_refuse "apt would remove $(osf_refused_list "$refused"); nothing was applied" removal
		return 1
	fi
	OSF_APT_REMOVALS="$(printf '%s\n' "${removals[@]+"${removals[@]}"}")"
}

# osf_apt_install_names <apt-get args>... — the package names an `install`
# transaction names (pkg=version cut to pkg), one per line.
osf_apt_install_names() {
	local a verb="" skip=0
	for a in "$@"; do
		if (( skip )); then skip=0; continue; fi
		case "$a" in
			-o|-c) skip=1; continue ;;
			-*) continue ;;
		esac
		if [[ -z "$verb" ]]; then verb="$a"; continue; fi
		[[ "$verb" == install ]] && printf '%s\n' "${a%%=*}"
	done
}

# osf_apt_transaction <apt-get args>... — apt-get behind osf_apt_preview. A
# transaction that removes nothing runs with --no-remove, so apt aborts instead of
# removing anything the preview did not show. One that removes only allowed
# packages cannot carry --no-remove: it is simulated again immediately before it
# runs and refused if the removal set moved in between.
#
# The download re-run (shcp-updater#174) lives here, not in apt_get_fetch: every
# attempt is previewed again. The backoff is long enough for an index refresh
# (apt-daily, an operator's apt update, a mirror finishing its sync) to change what
# the same command resolves to, and an unpreviewed re-run is the incident again.
#
# `apt-get install <pkg>` marks an automatically installed package manual, which
# would make the next transition of it look operator-installed: each named
# package that was automatic before is marked automatic again afterwards.
osf_apt_transaction() {
	local attempt=1 rc planned
	local -a was_auto=()
	mapfile -t was_auto < <(grep -xF -f <(osf_apt_install_names "$@") <(apt-mark showauto 2>/dev/null) 2>/dev/null)
	while :; do
		OSF_TRANSACTION_DETAIL=""
		osf_apt_preview "$@" || return 1
		planned="$OSF_APT_REMOVALS"
		if [[ -n "$planned" ]]; then
			osf_apt_preview "$@" || return 1
			if [[ "$OSF_APT_REMOVALS" != "$planned" ]]; then
				osf_transaction_refuse "apt changed the packages it would remove between two simulations; nothing was applied"
				return 1
			fi
			engine_log "apt: removing $(paste -sd' ' <<<"$planned") (replaced, automatically installed, or installed by this run)"
		fi
		rc=0
		if [[ -z "$planned" ]]; then
			SHCP_APT_FETCH_ATTEMPTS=1 apt_get_fetch --no-remove "$@" || rc=$?
		else
			SHCP_APT_FETCH_ATTEMPTS=1 apt_get_fetch "$@" || rc=$?
		fi
		if (( rc == 0 )); then
			(( ${#was_auto[@]} == 0 )) || apt-mark auto "${was_auto[@]}" >/dev/null 2>&1 \
				|| engine_log "apt: could not mark ${was_auto[*]} automatic again"
			return 0
		fi
		if [[ "$APT_FETCH_DOWNLOAD_FAILED" != 1 ]] || (( attempt >= SHCP_APT_FETCH_ATTEMPTS )); then
			return "$rc"
		fi
		engine_log "apt: a package download failed (attempt ${attempt}/${SHCP_APT_FETCH_ATTEMPTS}); re-running in $(( SHCP_APT_FETCH_BACKOFF * attempt ))s"
		sleep "$(( SHCP_APT_FETCH_BACKOFF * attempt ))"
		attempt=$(( attempt + 1 ))
	done
}

# osf_dnf_removals <dnf output> — the names a dnf 4 transaction table removes:
# every row under a "Removing...:" heading (plain, dependent and unused-dependency
# removals, installonly rotation) and every "replacing name.arch" line an Obsoletes
# install or upgrade carries. A row whose name overflows its column wraps, so the
# name is the first field of any line indented by exactly one space. The engine
# runs LC_ALL=C, which keeps the headings untranslated.
osf_dnf_removals() {
	awk '
		/^[^ ]/                    { removing = ($0 ~ /^Removing.*:$/); next }
		/^ [^ ]/                   { if (removing) print $1; next }
		$1 == "replacing" && NF >= 2 { n = $2; sub(/\.[^.]*$/, "", n); print n }
	' <<<"$1" | sort -u
}

# osf_dnf_replaced <dnf output> — the packages an Obsoletes install or upgrade in
# the table replaces (its "replacing name.arch" lines).
osf_dnf_replaced() {
	awk '$1 == "replacing" && NF >= 2 { n = $2; sub(/\.[^.]*$/, "", n); print n }' <<<"$1" | sort -u
}

# osf_dnf_preview <dnf args>... — depsolve with --assumeno and classify every
# removal. --assumeno exits 1 for a clean aborted transaction and for a depsolve
# failure alike, so only the table's summary (or dnf's no-op line) proves the
# transaction resolved. The output stays in OSF_DNF_PREVIEW for callers with
# stricter rules of their own (osf_remove_one).
OSF_DNF_PREVIEW=""
osf_dnf_preview() {
	local refused
	local -a removals=()
	OSF_REFUSAL_KIND=""
	OSF_DNF_PREVIEW="$(dnf --assumeno "$@" </dev/null 2>&1)" || true
	if ! grep -qE '^(Transaction Summary|Nothing to do\.)' <<<"$OSF_DNF_PREVIEW"; then
		printf '%s\n' "$OSF_DNF_PREVIEW" >&2
		osf_transaction_refuse "dnf could not resolve the transaction; nothing was applied -- see the run log"
		return 1
	fi
	mapfile -t removals < <(osf_dnf_removals "$OSF_DNF_PREVIEW")
	OSF_REPLACED="$(osf_dnf_replaced "$OSF_DNF_PREVIEW")"
	refused="$(osf_removal_refusals "${removals[@]+"${removals[@]}"}")"
	if [[ -n "$refused" ]]; then
		osf_transaction_refuse "dnf would remove $(osf_refused_list "$refused"); nothing was applied" removal
		return 1
	fi
	(( ${#removals[@]} == 0 )) \
		|| engine_log "dnf: removing ${removals[*]} (replaced, installonly rotation, automatically installed, or installed by this run)"
}

# osf_dnf_transaction <dnf args>... — `dnf -y` behind osf_dnf_preview. dnf has no
# --no-remove, so the transaction is pinned to what was previewed instead: the
# packages are downloaded first (refreshing metadata if it expired), then the
# preview and the apply both run cache-only (-C) against that one metadata
# snapshot. No engine path passes --allowerasing.
osf_dnf_transaction() {
	OSF_TRANSACTION_DETAIL=""
	OSF_REFUSAL_KIND=""
	local out
	if ! out="$(dnf -y --downloadonly "$@" </dev/null 2>&1)"; then
		printf '%s\n' "$out" >&2
		# The verb's stderr is not the run log; without this the log said "see the
		# run log" and held nothing (shcp-master#3224: a bad NEVRA hid this way).
		engine_log "dnf $*: $(grep -E '^(Error|No package|Problem)' <<<"$out" | head -n 3 | tr '\n' ' ')"
		osf_transaction_refuse "dnf could not resolve or download the transaction; nothing was applied -- see the run log"
		return 1
	fi
	printf '%s\n' "$out" >&2
	osf_dnf_preview -C "$@" || return 1
	dnf -y -C "$@" </dev/null
}

# essential_installed — the essential packages installed right now, one per line.
essential_installed() {
	local pkg
	while IFS= read -r pkg; do
		[[ -n "$pkg" ]] && osf_pkg_installed "$pkg" && printf '%s\n' "$pkg"
	done < <(osf_essential_pkgs)
	return 0
}

INDEX_REFRESH_DETAIL=""
osf_refresh_index() {
	local out="" rc=0
	INDEX_REFRESH_DETAIL=""
	if [[ "$OS_FAMILY" == deb ]] && ! osf_rollback_source_ensure; then
		INDEX_REFRESH_DETAIL="signed rollback source or pin could not be installed"
		return 1
	fi
	case "$OS_FAMILY" in
		# Error-Mode=any makes a failed index download exit 100, so apt_get_fetch
		# re-runs it like any other download failure.
		deb) out="$(apt_get_fetch -o APT::Update::Error-Mode=any update -qq 2>&1)" || rc=$? ;;
		rpm) out="$(dnf -q makecache 2>&1)" || rc=$? ;;
	esac
	if (( rc != 0 )); then
		# The exit code is the decision; the detail is a capped, ASCII-clamped hint
		# naming which source failed.
		INDEX_REFRESH_DETAIL="exit ${rc}: $(printf '%s\n' "$out" | grep -E '^(E|W|Err|Error)' | head -n 3 | tr -cd '\11\12\40-\176' | tr '\n' ' ')"
		INDEX_REFRESH_DETAIL="${INDEX_REFRESH_DETAIL% }"
		engine_log "package index refresh failed (${INDEX_REFRESH_DETAIL})"
		return 1
	fi
	index_stamp_write
	return 0
}

# The engine's OWN record of when it last refreshed the package index
# successfully, on both families. Neither package manager leaves a reliable one:
# apt's periodic/update-success-stamp is written only by apt.systemd.daily (and
# on Debian only under unattended-upgrades' APT::Periodic config, which a 1.0
# box never carries — the installer masks that timer), and an `apt-get update`
# whose sources are unchanged rewrites nothing under lists/, so their mtimes
# freeze too. dnf has no stamp at all. Without this file index_age_seconds
# aged a perfectly refreshed box past SHCP_APT_INDEX_MAX_AGE in a week, after
# which `check` raised apt_index_stale permanently and preflight refused on any
# transient refresh failure (SC-319 in reverse: a guard against silent
# non-patching that itself stopped patching). Root 0600 like every other
# state-dir record; written durably because it is a one-way latch the next
# run's refusal logic reads.
INDEX_STAMP="${SHCP_UPDATE_STATE_DIR}/index-refreshed-at"
index_stamp_write() {
	[[ -d "${INDEX_STAMP%/*}" ]] || return 0   # no state dir (a bare probe): nothing to record
	printf '%s\n' "$(date +%s)" | atomic_write_durable "$INDEX_STAMP" 2>/dev/null \
		|| engine_log "package index: could not record the refresh time at ${INDEX_STAMP} (the age check falls back to the package manager's own signals)"
	return 0
}

# index_refresh_flag <where> — record on the run that its package index could not
# be fully refreshed. The convergence gate reads .index_refresh_failed so the run
# never stamps last_security_success, and `check` raises
# package_index_refresh_failing from .index_refresh_detail, so the operator hears of
# it the same day rather than at the 3-day staleness alarm.
index_refresh_flag() {
	journal_update "$CURRENT_RUN_ID" '.index_refresh_failed = true | .index_refresh_detail = $d' \
		--arg d "$1: ${INDEX_REFRESH_DETAIL:-refresh failed}"
}

# rpm work-set, apply and self-update dnf calls skip a repo that cannot be reached.
# makecache has already flagged the run (above); without this a dead repo whose
# metadata was never cached fails check-update outright, and the security run turns
# a pending update into a silent noop.
DNF_SKIP_DEAD_REPOS=(--setopt=skip_if_unavailable=True)

# --- the self-update probe (§4.2 stage 0, updater#35) --------------------------
# Stage 0 was the ONE place that called apt directly instead of dispatching on
# OS_FAMILY, so every EL apply died at `apt-get update` before preflight ever
# ran — and nothing fresh-install-shaped could see it, because only a real
# `apply` reaches stage 0. These verbs close that hole; the stage itself stays
# family-neutral.

# osf_selfupdate_installed — the installed engine version, empty when unknown.
# rpm -q prints its "not installed" complaint to STDOUT and exits 1, so the
# value is captured only on success.
osf_selfupdate_installed() {
	local v
	case "$OS_FAMILY" in
		deb) dpkg-query -W -f '${Version}' shcp-updater 2>/dev/null || true ;;
		rpm) v="$(rpm -q --qf '%{EVR}' shcp-updater 2>/dev/null)" && printf '%s' "$v" || true ;;
	esac
}

# osf_selfupdate_candidate — the version the repos would install, empty when
# none is known. deb may echo the INSTALLED version or "(none)" back (apt-cache
# has no "only upgrades" notion) — osf_selfupdate_is_newer sorts that out. rpm
# uses `dnf check-update <pkg>`, which by contract names only genuine upgrades
# (exit 100) — same parse as osf_upgradable, keeping the EVR column instead.
osf_selfupdate_candidate() {
	case "$OS_FAMILY" in
		deb) apt-cache policy shcp-updater 2>/dev/null \
			| sed -n 's/^ *Candidate: *//p' | head -1 || true ;;
		# Column-0 anchor, not just a $1 match: inside an "Obsoleting
		# Packages" section the INSTALLED package appears as an indented
		# continuation line, and awk strips leading whitespace before
		# splitting — an unanchored match would print the installed EVR as
		# "candidate" and hand the hop to the obsoleter. Verified against
		# real dnf 4.20 output on almalinux:10.
		rpm) dnf -q check-update shcp-updater "${DNF_SKIP_DEAD_REPOS[@]}" 2>/dev/null \
			| awk '$0 !~ /^[[:space:]]/ && $1 ~ /^shcp-updater\./ && NF>=3 {print $2; exit}' || true ;;
	esac
}

# osf_selfupdate_is_newer <installed> <candidate> — is the candidate a real
# upgrade? deb needs the full compare dance because the candidate line is
# unfiltered; on rpm a non-empty candidate is newer by check-update's contract,
# but the `!= installed` belt stays anyway — the parse above is the only other
# thing standing between a future dnf output shape and a phantom from==to hop.
osf_selfupdate_is_newer() {
	local installed="$1" candidate="$2"
	case "$OS_FAMILY" in
		deb)
			[[ -n "$candidate" && "$candidate" != "(none)" && "$candidate" != "$installed" ]] \
				&& dpkg --compare-versions "$candidate" gt "$installed" 2>/dev/null ;;
		rpm) [[ -n "$candidate" && "$candidate" != "$installed" ]] ;;
	esac
}

# osf_selfupdate_install — install the newer engine package, quietly; the
# caller logs and journals around it.
osf_selfupdate_install() {
	case "$OS_FAMILY" in
		deb) osf_apt_transaction install -y shcp-updater >/dev/null 2>&1 ;;
		rpm) osf_dnf_transaction upgrade shcp-updater "${DNF_SKIP_DEAD_REPOS[@]}" >/dev/null 2>&1 ;;
	esac
}

# dpkg_interrupted — a dpkg transaction was interrupted and the box needs
# `dpkg --configure -a` before apt will do ANYTHING. Worth its own probe because
# the failure is otherwise reported at the wrong place: apt-get -s upgrade takes
# no lock and happily computes a work set, so preflight passes and the run dies
# at stage_apt with "apt-get exited non-zero", which reads like a mirror problem.
# The box then fails that way every night, forever, with nothing naming the cause.
dpkg_interrupted() {
	[[ "$OS_FAMILY" == "deb" ]] || return 1
	command -v dpkg >/dev/null 2>&1 || return 1
	# dpkg --audit prints nothing on a healthy box.
	[[ -n "$(dpkg --audit 2>/dev/null)" ]]
}

# rpm_transaction_interrupted — the EL twin of dpkg_interrupted (shcp-updater#179).
# A dnf killed mid-transaction (unit timeout, OOM, power) leaves the rpmdb holding
# BOTH EVRs of the package it was replacing. Measured on EL10 (dnf 4.20): after
# that, `check-update` exits 0, `update` says "Nothing to do" and exits 0, so every
# later run reports the box current while the old EVR's files and scriptlets are
# half-replaced — the same silent, nightly failure dpkg_interrupted names on deb.
# `dnf check --duplicates` is dnf's own detector: it prints one line per
# duplicated package and nothing on a healthy box, and it excludes installonly
# packages (several kernels are not a duplicate). Local rpmdb only, no metadata,
# and it never waits on dnf's lock (exit_on_lock): a lock held by a running dnf is
# not evidence of an interrupted one.
rpm_transaction_interrupted() {
	[[ "$OS_FAMILY" == "rpm" ]] || return 1
	command -v dnf >/dev/null 2>&1 || return 1
	[[ -n "$(timeout 120 dnf -q -C --setopt=exit_on_lock=True check --duplicates </dev/null 2>/dev/null)" ]]
}

# series_pointer_series — the series the package manager's SHCP pointer names: deb,
# the <major.minor> suffix of shcp.list's single deb line suite; rpm, the dnf var
# shcpseries. Empty when it cannot be read or has no series (a pre-series binding).
series_pointer_series() {
	local root="${CONFIG_ROOT%/}" v=""
	case "$OS_FAMILY" in
		deb)
			local list="${root}/etc/apt/sources.list.d/shcp.list" line n=0 suite=""
			local deb_re='^[[:space:]]*deb([[:space:]]+\[[^]]*\])?[[:space:]]+[^[:space:]]+[[:space:]]+([^[:space:]]+)[[:space:]]'
			[[ -r "$list" ]] || return 0
			while IFS= read -r line || [[ -n "$line" ]]; do
				[[ "$line" =~ $deb_re ]] && { suite="${BASH_REMATCH[2]}"; n=$((n + 1)); }
			done < "$list"
			[[ "$n" -eq 1 && "$suite" == *-* ]] && v="${suite##*-}" ;;
		rpm) v="$(series_rpm_read_var "${root}/etc/dnf/vars/shcpseries")" ;;
	esac
	[[ "$v" =~ ^[0-9]+\.[0-9]+$ ]] && printf '%s' "$v"
	return 0
}

# series_pointer_mismatch — 0 when the package-manager pointer names a series that
# is not the running panel's (shcp-updater#179 break-it round 2). The state a
# pointer-kept series rollback leaves: the panel went back, the pointer could not.
# A routine run from there has no series convergence check and would pull SHCP
# packages from the NEW series under the OLD panel, ending healthy — a silent
# half-jump. Unknown on either side is not a mismatch.
SERIES_POINTER_DETAIL=""
series_pointer_mismatch() {
	SERIES_POINTER_DETAIL=""
	# The SHCP panel's series only: another product's version need not track the
	# SHCP repo suite (shcp-dns has no series binding yet).
	[[ -z "$PRODUCT" || "$PRODUCT" == "shcp" ]] || return 1
	local ptr cur pser
	ptr="$(series_pointer_series)"
	cur="$(panel_current_version)"
	[[ -n "$ptr" ]] && ver_valid "$cur" || return 1
	pser="$(ver_series "$cur")"
	[[ "$ptr" != "$pser" ]] || return 1
	SERIES_POINTER_DETAIL="the SHCP package-manager pointer names series ${ptr} but the panel runs ${cur} (series ${pser}): SHCP packages would be upgraded from the wrong series. A series rollback that could not revert the pointer leaves this — retry it once the ${pser} repo is reachable (shcp-update rollback <run>, with --force-superseded if a later security run superseded it). Security-scope runs continue."
	return 0
}

# rpm_transaction_interrupted_detail — the duplicated packages, for the blocker text.
rpm_transaction_interrupted_detail() {
	local dups
	dups="$(timeout 120 dnf -q -C --setopt=exit_on_lock=True check --duplicates </dev/null 2>/dev/null \
		| awk 'NF && NR <= 5' | tr -cd '\11\12\40-\176' | paste -sd';' - || true)"
	printf 'an rpm transaction was interrupted: the rpmdb holds two versions of a package (%s) — run `dnf remove --duplicates`, then `dnf reinstall` the named packages; dnf reports nothing to do until it is repaired' "${dups:-see dnf check --duplicates}"
}

# index_age_seconds — seconds since the package index was MOST RECENTLY
# refreshed, both families (empty/return 1 when unknown, which callers must
# treat as "do not block", never as "stale").
#
# This is the freshest refresh signal on the box — the MINIMUM age over the
# engine's own stamp (INDEX_STAMP, written after every successful
# osf_refresh_index), apt's periodic update-success-stamp when present, and the
# newest file under the package manager's index cache (/var/lib/apt/lists on
# deb, the dnf metadata cache on rpm) — NOT a per-source age. A box where ANY
# source refreshed reads fresh even while another
# source has been dead for weeks, because one working source resets the signal.
# That limitation is deliberate (SC-319): a single persistently failing source is
# already caught the SAME DAY by the package_index_refresh_failing advisory —
# osf_refresh_index runs apt with APT::Update::Error-Mode=any, so any failing
# source makes the refresh return non-zero, which flags the run (index_refresh_flag)
# and `check` surfaces naming the source. Feeding a per-source (oldest) age into
# this function's callers instead — the apt_index_stale blocker and preflight's
# stale-cache refusal — would let a dead third-party source HARD-BLOCK a
# panel-independent security run, which is exactly what SC-319/AD-6 forbid. So the
# per-source age is deliberately NOT computed here; the advisory is its substitute.
# The paths are env-overridable for hermetic tests; the defaults are production.
index_age_seconds() {
	local -a cands=("$INDEX_STAMP")
	case "$OS_FAMILY" in
		deb) cands+=("${SHCP_APT_SUCCESS_STAMP:-/var/lib/apt/periodic/update-success-stamp}" \
			"${SHCP_APT_LISTS_DIR:-/var/lib/apt/lists}") ;;
		# dnf 4.x (RHEL 10): /var/cache/dnf/<repo>-<hash>/repodata/repomd.xml is
		# rewritten by every makecache that fetched, so the newest file under it
		# is the last real refresh.
		rpm) cands+=("${SHCP_DNF_CACHE_DIR:-/var/cache/dnf}") ;;
		*) return 1 ;;
	esac
	local cand t newest="" now
	for cand in "${cands[@]}"; do
		[[ -e "$cand" ]] || continue
		if [[ -d "$cand" ]]; then
			# The newest FILE, not the directory's own mtime: apt/dnf rewrite index
			# files in place, which does not touch the directory entry.
			t="$(find "$cand" -maxdepth 3 -type f -printf '%T@\n' 2>/dev/null | sort -n | tail -1 | cut -d. -f1)"
			[[ -n "$t" ]] || t="$(stat -c %Y "$cand" 2>/dev/null || true)"
		else
			t="$(stat -c %Y "$cand" 2>/dev/null || true)"
		fi
		[[ "$t" =~ ^[0-9]+$ ]] || continue
		[[ -z "$newest" || "$t" -gt "$newest" ]] && newest="$t"
	done
	[[ -n "$newest" ]] || return 1
	now="$(date +%s)"
	echo $(( now - newest ))
}

# osf_listing_failed — the package manager could not produce an upgrade listing.
# Both listers below run in pipelines, and callers read their stdout, where "the
# manager failed" and "nothing is pending" are the same empty string. A caller
# that must tell them apart (check --pending) sets OSF_LISTING_FAIL_MARK to a path
# that does not exist yet; a failed listing creates it. A file rather than a
# variable because every lister runs in a subshell. Exit codes are unchanged.
OSF_LISTING_FAIL_MARK=""
osf_listing_failed() {
	[[ -n "$OSF_LISTING_FAIL_MARK" ]] && : > "$OSF_LISTING_FAIL_MARK"
	return 0
}

# osf_upgradable — names of all upgradable installed packages, one per line.
osf_upgradable() {
	case "$OS_FAMILY" in
		deb) ( rc=0; apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 --with-new-pkgs upgrade 2>/dev/null || rc=$?
			(( rc == 0 )) || osf_listing_failed; exit "$rc" ) | awk '$1 == "Inst" && $3 ~ /^\[/ {print $2}' ;;
		# EL: `name.arch  evr  repo` — strip the trailing .arch to the bare name.
		# check-update exits 100 when updates exist, 0 when none, 1 on error.
		rpm) ( rc=0; dnf -q check-update "${DNF_SKIP_DEAD_REPOS[@]}" 2>/dev/null || rc=$?
			(( rc == 0 || rc == 100 )) || osf_listing_failed; exit "$rc" ) | awk 'NF>=3 && $1 ~ /\./ {sub(/\.[^.]+$/,"",$1); print $1}' || true ;;
	esac
}

# osf_upgradable_full — (deb) installed packages a dist-upgrade would upgrade. Only
# `Inst <pkg> [<installed>]` lines: a package the upgrade would newly install has
# no bracket and is never named (osf_apply is --only-upgrade; apt pulls it in).
osf_upgradable_full() {
	( rc=0; apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 dist-upgrade 2>/dev/null || rc=$?
		(( rc == 0 )) || osf_listing_failed; exit "$rc" ) | awk '/^Inst [^ ]+ \[/{print $2}'
}

# apt 3 may print "localhost" for local archives in Inst. Match the exact
# version's security suite in policy; apt 2 may list multiple URIs under one
# version, while apt 3 may repeat the version once per archive.
osf_deb_policy_security_version() {
	local policy
	policy="$(LC_ALL=C apt-cache policy "$1" 2>/dev/null)" || return 1
	awk -v wanted="$2" '
		$1 == "***" { ver = $2; next }
		$2 ~ /^-?[0-9]+$/ && (NF == 2 || $3 ~ /^\(phased/) { ver = $1; next }
		ver == wanted && $1 ~ /^-?[0-9]+$/ && $3 ~ /-security\// { found = 1 }
		END { exit !found }
	' <<<"$policy"
}

# osf_security_pkgs — exact targets for upgradable packages whose candidate comes from a
# SECURITY origin (AD-6: the set unattended-upgrades would take). deb parses the
# `apt-get -s --with-new-pkgs upgrade` Inst-line origin, matching EITHER the archive LABEL
# `Debian-Security`/`Ubuntu-*-security` (which is authoritative regardless of the
# suite name — `:` follows it in real output) OR a suite ending in `-security`.
# A third-party repo merely called "securitycorp" matches neither (both are
# anchored on a hyphen before "security").
osf_security_pkgs() {
	case "$OS_FAMILY" in
		deb)
			# Force security archives above ordinary archives only for discovery.
			# Otherwise an already-published ordinary v3 hides marked security v2
			# from apt's single global candidate. The emitted pkg=version remains
			# exact for the later transaction.
			# Staged in the RUN's own directory when there is one: a signal between
			# mktemp and the rm below (the unit's TimeoutStartSec SIGKILL, an OOM
			# kill) used to leak a root-owned file in TMPDIR on every such run,
			# forever. Under the run dir it is swept by run retention. A bare probe
			# (`check --pending`, a sourced test) has no run dir and keeps mktemp.
			#
			# Priority 999, NOT 1001: apt treats >1000 as "install even if it is a
			# downgrade", so wherever the ordinary suite sits ahead of the security
			# suite the old 1001 pin selected the OLDER security build (apache2 main
			# 2.4.68 vs trixie-security 2.4.67, and a cross-origin pdns downgrade),
			# which the real transaction refuses -- blocking the whole security lane
			# (SC-319). 999 stays in apt's 990-1000 band: security still outranks the
			# ordinary and target-release archives, but a build older than installed
			# is never selected. The strictly-newer filter below is the belt to this
			# suspenders; NEVER add --allow-downgrades.
			#
			# The SHCP-Security stanza is SPECIFIC-form (`Package: /./`), the two
			# distro stanzas stay general-form (`Package: *`), and the difference is
			# the point. This file replaces only /etc/apt/preferences; preferences.d
			# stays in force, and there the installer pins our own daemons by name
			# (`Package: shcpd shcp-deps` / `Pin: origin repo.shcp.dev` / 900). apt
			# lets a specific-form record beat every general-form one, and that pin
			# matches the marked build and the ordinary suite's newer unmarked build
			# alike (same host), so as a general-form record this stanza lost: both
			# sat at 900, the unmarked build was the candidate, and a box one build
			# behind never received the marked one (SC-725). In specific form it
			# outranks that 900 for exactly the versions the SHCP-Security archive
			# carries (apt 2.8 and 3.0, real archives; an operator's by-name 1001
			# hold still wins). The distro stanzas must NOT follow it: they would
			# then override a box's own by-name pins over distro packages.
			local security_preferences
			if [[ -n "${CURRENT_RUN_ID:-}" && -d "${RUNS_DIR}/${CURRENT_RUN_ID}" ]]; then
				security_preferences="${RUNS_DIR}/${CURRENT_RUN_ID}/security-preferences"
			else
				security_preferences="$(mktemp)" || return 1
			fi
			cat > "$security_preferences" <<'EOF'
Package: *
Pin: release a=*-security
Pin-Priority: 999

Package: *
Pin: release l=Debian-Security
Pin-Priority: 999

Package: /./
Pin: release l=SHCP-Security
Pin-Priority: 999
EOF
			( rc=0; apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 \
				-o "Dir::Etc::preferences=${security_preferences}" --with-new-pkgs upgrade 2>/dev/null || rc=$?
				(( rc == 0 )) || osf_listing_failed; exit "$rc" ) | while IFS= read -r line; do
				[[ "$line" == Inst\ * ]] || continue
				local origin="${line#*\(}"; origin="${origin%\)*}"
				# apt 3 can call a local archive "localhost" in Inst, so check the
				# exact version's policy suite when the Inst origin is inconclusive.
				local pkg version
				pkg="$(awk '{print $2}' <<<"$line")"
				version="${line#*\(}"; version="${version%% *}"
				if [[ "$origin" =~ (^|[[:space:],])[A-Za-z0-9._-]*-[Ss]ecurity([[:space:],/:]|$) \
					|| "$origin" =~ [A-Za-z0-9._]-security([[:space:],/]|$) ]] \
					|| osf_deb_policy_security_version "$pkg" "$version"; then
					# Keep the selected security version in the transaction argument. Passing
					# only the name lets apt re-solve after discovery and take a newer,
					# unmarked build from the ordinary SHCP suite
					# (SC-725).
					# Only a version STRICTLY NEWER than installed is a real security
					# UPGRADE. Under the discovery pin a package whose ordinary suite
					# already sits ahead of its security suite would otherwise be emitted
					# as a DOWNGRADE (apache2 main > trixie-security), which the real
					# transaction refuses, blocking the whole security lane (SC-319). The
					# installed version is the [..] field of the Inst line; compare with
					# dpkg and drop anything not newer (fail closed: an unparseable pair
					# is not emitted). SC-725 re-solve still applies to what survives.
					# Only a [..] BEFORE the "(" is the installed version. A package that
					# is new (`Inst linux-image-6.12.113+deb13-amd64 (6.12.113-1 ...
					# [amd64])`, a new-ABI security kernel) has none, and the first
					# bracket used to be its arch: every check then printed dpkg's
					# "version 'amd64' has bad syntax" (SenternalTech/shcp-master#3337).
					# Not emitted, as before: its metapackage's upgrade pulls it in.
					local installed=""
					[[ "$line" =~ ^Inst\ [^\ ]+\ \[([^]]+)\]\ \( ]] && installed="${BASH_REMATCH[1]}"
					if [[ -n "$pkg" && -n "$version" && -n "$installed" ]] \
						&& dpkg --compare-versions "$installed" lt "$version"; then
						printf '%s\t%s=%s\n' "$pkg" "$pkg" "$version"
					fi
				fi
			done
			local pipeline_rc=${PIPESTATUS[0]}
			rm -f "$security_preferences"
			return "$pipeline_rc" ;;
		rpm)
			# ONE read of the rpmdb, hoisted out of the per-target loop: rpm -qa is
			# a full database walk (~1 s on an EL box) and used to run once per
			# advisory target, so a large security window cost minutes here.
			local installed_names
			installed_names="$(rpm -qa --qf '%{NAME}\t%{EVR}\n' 2>/dev/null || true)"
			( rc=0; dnf -q updateinfo list --security updates "${DNF_SKIP_DEAD_REPOS[@]}" 2>/dev/null || rc=$?
			(( rc == 0 || rc == 100 )) || osf_listing_failed; exit "$rc" ) \
			| awk 'NF>=3 && $NF ~ /\.[^.]+$/ {print $NF}' \
			| while IFS= read -r target; do
				local name best=""
				while IFS=$'\t' read -r name _; do
					[[ "$target" == "$name-"* && ${#name} -gt ${#best} ]] && best="$name"
				done <<<"$installed_names"
				[[ -n "$best" ]] && printf '%s\t%s\n' "$best" "$target"
			done || true ;;
	esac
}

# --- Security scope and the repo.shcp.dev mirror (shcp-master#2761, SC-319) -----
# A package whose INSTALLED build came from the mirror stays on mirror builds in a
# security run, even when the distro's security pocket carries a newer build; that
# is warned and journaled once per run instead (owner decision A). Ownership comes
# from where the package manager itself says the installed build lives: the archive
# URIs apt lists for that exact version, the repo dnf recorded the install from.
# Never from an archive Label or Origin, which any repository sets for itself.
# The check reads the RESOLVED transaction, so a dependency the manager would pull
# across is caught as well as a named target. A package whose origin cannot be
# established is held, and only the targets that would move it: the rest of the
# security batch still applies.
OSF_MIRROR_URI_PREFIXES="https://repo.shcp.dev/ http://repo.shcp.dev/"
OSF_MIRROR_PIN_DIR="/etc/apt/preferences.d"
OSF_MIRROR_PIN_ORIGIN="repo.shcp.dev"
OSF_RPM_MIRROR_REPO="shcp"
OSF_PENDING_HOLDS_FILE=""

# osf_mirror_pinned <pkg> — (deb) the installer pins <pkg> to the mirror
# (`Pin: origin repo.shcp.dev`). That marks it as a package the mirror carries
# even when the mirror's index no longer lists it.
osf_mirror_pinned() {
	[[ "$OS_FAMILY" == deb ]] || return 1
	local pattern
	while IFS= read -r pattern; do
		# shellcheck disable=SC2053 # the pin's Package field is a glob
		[[ "$1" == $pattern ]] && return 0
	done < <(awk -v origin="$OSF_MIRROR_PIN_ORIGIN" '
		BEGIN { RS = ""; FS = "\n" }
		{
			names = ""; pinned = 0
			for (i = 1; i <= NF; i++) {
				if ($i ~ /^Package:/) names = substr($i, 9)
				if ($i ~ /^Pin:[[:space:]]*origin[[:space:]]/) {
					o = $i; sub(/^Pin:[[:space:]]*origin[[:space:]]+/, "", o); gsub(/[" \t]/, "", o)
					if (o == origin) pinned = 1
				}
			}
			if (pinned) { n = split(names, w, /[[:space:]]+/); for (j = 1; j <= n; j++) if (w[j] != "") print w[j] }
		}' "$OSF_MIRROR_PIN_DIR"/* 2>/dev/null)
	return 1
}

# osf_deb_security_moves <target>... — one row per INSTALLED package the exact
# security transaction (the one osf_apply runs) would change:
#   pkg  installed  evidence  mirror-carries-pkg  candidate  candidate-on-mirror
# evidence: mirror | distro (archives apt lists for the installed version) | none
# (no archive lists it any more) | missing (apt printed no version table for it,
# which dpkg's own status entry guarantees for any installed version).
# candidate is "-" for a package the transaction removes. A candidate version
# counts as the mirror's only when no other archive lists the same version: apt
# merges same-version entries and may fetch either.
osf_deb_security_moves() {
	local simulation policy
	local -a pkgs=()
	simulation="$(apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 --only-upgrade install "$@" 2>/dev/null)" || return 1
	mapfile -t pkgs < <(awk '$1 == "Inst" || $1 == "Remv" || $1 == "Purg" {print $2}' <<<"$simulation")
	(( ${#pkgs[@]} )) || return 0
	policy="$(LC_ALL=C apt-cache policy "${pkgs[@]}" 2>/dev/null)" || return 1
	# Both outputs name a package the same way (apt's FullName: an arch suffix
	# only for a foreign architecture).
	awk -v mirrors="$OSF_MIRROR_URI_PREFIXES" '
		function on_mirror(uri,   i, n, m) {
			n = split(mirrors, m, " ")
			for (i = 1; i <= n; i++) if (index(uri, m[i]) == 1) return 1
			return 0
		}
		function emit(p, inst, cand,   ev) {
			if (!((p, inst) in listed)) ev = "missing"
			else if ((p, inst) in mirror) ev = "mirror"
			else if ((p, inst) in other) ev = "distro"
			else ev = "none"
			print p "\t" inst "\t" ev "\t" ((p in carries) ? 1 : 0) "\t" cand "\t" ((inst == "-" ? ((p, cand) in ordinary) : ((p, cand) in mirror && !((p, cand) in other))) ? 1 : 0)
		}
		FILENAME == ARGV[1] {
			if ($0 ~ /^[^ ]/) { pkg = $0; sub(/:$/, "", pkg); ver = ""; next }
			if ($1 ~ /:$/) next
			if ($1 == "***") { ver = $2; listed[pkg, ver] = 1; next }
			if ($2 ~ /^-?[0-9]+$/ && (NF == 2 || $3 ~ /^\(phased/)) { ver = $1; listed[pkg, ver] = 1; next }
			if (ver == "" || $1 !~ /^-?[0-9]+$/ || (NF == 2 && $2 ~ /^\//)) next
			if (on_mirror($2)) { mirror[pkg, ver] = 1; carries[pkg] = 1; if ($3 !~ /-security\//) ordinary[pkg, ver] = 1 } else other[pkg, ver] = 1
			next
		}
		$1 == "Inst" && $3 ~ /^\[/ { inst = $3; gsub(/[][]/, "", inst); cand = $4; sub(/^\(/, "", cand); emit($2, inst, cand) }
		$1 == "Inst" && $3 ~ /^\(/ { cand = $3; sub(/^\(/, "", cand); emit($2, "-", cand) }
		$1 == "Remv" || $1 == "Purg" { inst = $3; gsub(/[][]/, "", inst); emit($2, inst, "-") }
	' <(printf '%s\n' "$policy") <(printf '%s\n' "$simulation")
}

# osf_rpm_transaction_rows <target>... — what `dnf update <target>...` resolves
# to, one row per installed package it replaces or removes:
#   name  installed-evr  from_repo  mirror-carries-name  new-evr|-  new-repo|-
osf_rpm_transaction_rows() {
	command -v python3 >/dev/null 2>&1 || return 1
	timeout 600 python3 -I - "$OSF_RPM_MIRROR_REPO" "$@" 2>/dev/null <<'PY'
import sys
import dnf
import dnf.exceptions
import dnf.subject
import dnf.transaction
import hawkey

mirror, specs = sys.argv[1], sys.argv[2:]
b = dnf.Base()
b.conf.read()
b.conf.substitutions.update_from_etc(b.conf.installroot)
b.conf.skip_if_unavailable = True
b.read_all_repos()
b.fill_sack(load_system_repo=True, load_available_repos=True)
carried = {p.name for p in b.sack.query().available().filter(reponame=mirror)}

def row(old, evr, repo):
    print("\t".join([old.name, str(old.evr), old.from_repo or "",
                     "1" if old.name in carried else "0", evr, repo]))

for spec in specs:
    try:
        b.upgrade(spec)
    except dnf.exceptions.Error:
        # `dnf update` skips a spec it cannot take, e.g. a distro build the
        # mirror repo's priority masks. Report the installed package anyway, so
        # a mirror build that the distro has overtaken is still warned about.
        for nevra in dnf.subject.Subject(spec).get_nevra_possibilities(forms=[hawkey.FORM_NEVRA])[:1]:
            evr = "%s-%s" % (nevra.version, nevra.release)
            if nevra.epoch:
                evr = "%d:%s" % (nevra.epoch, evr)
            for old in b.sack.query().installed().filter(name=nevra.name):
                row(old, evr, "-")
b.resolve()
items = list(b.transaction or [])
incoming = {}
for tsi in items:
    if tsi.action in dnf.transaction.FORWARD_ACTIONS:
        incoming.setdefault((tsi.pkg.name, tsi.pkg.arch), tsi.pkg)
        incoming.setdefault((tsi.pkg.name, None), tsi.pkg)
for tsi in items:
    if tsi.action in dnf.transaction.BACKWARD_ACTIONS:
        new = incoming.get((tsi.pkg.name, tsi.pkg.arch)) or incoming.get((tsi.pkg.name, None))
        row(tsi.pkg, str(new.evr) if new else "-", new.reponame if new else "-")
for tsi in items:
    if tsi.action in dnf.transaction.FORWARD_ACTIONS:
        old = b.sack.query().installed().filter(name=tsi.pkg.name, arch=tsi.pkg.arch)
        if not list(old):
            print("\t".join([tsi.pkg.name, "-", "-", "1" if tsi.pkg.name in carried else "0",
                             str(tsi.pkg.evr), tsi.pkg.reponame]))
PY
}

# osf_rpm_security_moves <target>... — osf_deb_security_moves' rows for dnf.
# from_repo is the repo the install was recorded from; empty or @-prefixed
# (@System, @commandline) proves nothing.
osf_rpm_security_moves() {
	local rows
	rows="$(osf_rpm_transaction_rows "$@")" || return 1
	[[ -n "$rows" ]] || return 0
	# awk, not `read`: tab is IFS whitespace, so read would collapse an empty
	# from_repo and shift the next column into its place.
	awk -F'\t' -v m="$OSF_RPM_MIRROR_REPO" '
		NF != 6 { bad = 1; exit }
		{
			ev = ($3 == m) ? "mirror" : ($3 == "" || $3 ~ /^@/) ? "none" : "distro"
			print $1 "\t" $2 "\t" ev "\t" $4 "\t" $5 "\t" (($6 == m) ? 1 : 0)
		}
		END { exit bad }' <<<"$rows"
}

# A security transaction may pull in a package absent from the installed set.
# New SHCP packages from the ordinary suite need an admin-approved update (SC-725).
osf_security_new_shcp_deps() {
	local moves
	case "$OS_FAMILY" in
		deb) moves="$(osf_deb_security_moves "$@")" || return 1 ;;
		rpm) moves="$(osf_rpm_security_moves "$@")" || return 1 ;;
		*) return 1 ;;
	esac
	awk -F'\t' '$2 == "-" && $6 == 1 {print $1}' <<<"$moves" | sort -u
}

# Resolve the unblacklisted security set before the usual per-target mirror
# filtering, which may otherwise hide one target and let the rest apply.
security_new_shcp_deps_pending() {
	local pairs name target p
	local -a targets=()
	local -A deny=()
	while IFS= read -r p; do [[ -n "$p" ]] && deny["$p"]=1; done < <(blacklist_pkgs)
	pairs="$(osf_security_pkgs)" || return 1
	while IFS=$'\t' read -r name target; do
		[[ -n "$target" ]] && osf_valid_pkg "$name" && [[ -z "${deny[$name]:-}" ]] && targets+=("$target")
	done <<<"$pairs"
	(( ${#targets[@]} )) || return 0
	osf_security_new_shcp_deps "${targets[@]}"
}

# osf_security_violations <target>... — packages the exact security transaction
# would take off the mirror, one row each:  pkg  installed  candidate  kind
# kind = mirror (installed from the mirror) | unknown (origin unprovable, and the
# mirror carries the package, so it may have been) | removal (a security run
# never removes a package). Fails when the transaction cannot be resolved or
# read: callers refuse rather than guess.
osf_security_violations() {
	local moves pkg installed evidence carries candidate on_mirror
	case "$OS_FAMILY" in
		deb) moves="$(osf_deb_security_moves "$@")" || return 1 ;;
		rpm) moves="$(osf_rpm_security_moves "$@")" || return 1 ;;
		*) return 1 ;;
	esac
	while IFS=$'\t' read -r pkg installed evidence carries candidate on_mirror; do
		[[ "$installed" == - ]] && continue
		[[ -n "$pkg" && "$on_mirror" != 1 ]] || continue
		if [[ "$candidate" == - ]]; then
			printf '%s\t%s\t-\tremoval\n' "$pkg" "$installed"
			continue
		fi
		case "$evidence" in
			distro) continue ;;
			mirror) printf '%s\t%s\t%s\tmirror\n' "$pkg" "$installed" "$candidate" ;;
			none) [[ "$carries" == 1 ]] || osf_mirror_pinned "$pkg" && printf '%s\t%s\t%s\tunknown\n' "$pkg" "$installed" "$candidate" ;;
			*) printf '%s\t%s\t%s\tunknown\n' "$pkg" "$installed" "$candidate" ;;
		esac
	done <<<"$moves"
	return 0
}

osf_security_reason() {
	local pkg installed candidate kind
	IFS=$'\t' read -r pkg installed candidate kind <<<"$1"
	if [[ "$kind" == mirror ]]; then
		printf '%s %s is the repo.shcp.dev mirror build and stays on it; the distro security build %s waits until SHCP re-mirrors' \
			"$pkg" "$installed" "$candidate"
	elif [[ "$kind" == removal ]]; then
		printf '%s %s would be removed' "$pkg" "$installed"
	else
		printf '%s %s has no provable origin (no repository lists the installed build) and the mirror carries %s' \
			"$pkg" "$installed" "$pkg"
	fi
}

# osf_security_hold <package> <target> <reason> — warn once per run, journal it.
osf_security_hold() {
	local journal=""
	[[ -n "$OSF_PENDING_HOLDS_FILE" ]] && printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$OSF_PENDING_HOLDS_FILE"
	[[ -n "${CURRENT_RUN_ID:-}" ]] && journal="$(journal_path "$CURRENT_RUN_ID")"
	if [[ -n "$journal" && -f "$journal" ]]; then
		jq -e --arg p "$1" --arg t "$2" --arg r "$3" \
			'any(.security_held[]?; .package == $p and .target == $t and .reason == $r)' \
			"$journal" >/dev/null 2>&1 && return 0
		journal_update "$CURRENT_RUN_ID" \
			'.security_held = ((.security_held // []) + [{package: $p, target: $t, reason: $r}])' \
			--arg p "$1" --arg t "$2" --arg r "$3"
	fi
	engine_log "WARNING: security update ${2} held: ${3}"
}

# Record one notice when a later run can take a formerly held package from the
# mirror itself. A clean transaction alone does not prove the mirror published it.
osf_security_hold_cleared() {
	local pkg="$1" target="$2" previous="" d journal moves
	[[ -n "${CURRENT_RUN_ID:-}" && -f "$(journal_path "$CURRENT_RUN_ID")" ]] || return 0
	jq -e --arg p "$pkg" 'any(.security_hold_cleared[]?; .package == $p)' \
		"$(journal_path "$CURRENT_RUN_ID")" >/dev/null 2>&1 && return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		[[ "$d" != "$CURRENT_RUN_ID" ]] && run_id_valid "$d" || continue
		journal="$(journal_path "$d")"
		[[ -f "$journal" ]] || continue
		previous="$(jq -r --arg p "$pkg" '
			if any(.security_hold_cleared[]?; .package == $p) then "cleared"
			elif any(.security_held[]?; .package == $p) then "held" else empty end' "$journal" 2>/dev/null)"
		[[ -n "$previous" ]] && break
	done
	[[ "$previous" == held ]] || return 0
	case "$OS_FAMILY" in
		deb) moves="$(osf_deb_security_moves "$target")" || return 0 ;;
		rpm) moves="$(osf_rpm_security_moves "$target")" || return 0 ;;
		*) return 0 ;;
	esac
	awk -F'\t' -v p="$pkg" '$1 == p && $6 == 1 {found=1} END {exit !found}' <<<"$moves" || return 0
	journal_update "$CURRENT_RUN_ID" \
		'.security_hold_cleared = ((.security_hold_cleared // []) + [{package: $p, target: $t}])' \
		--arg p "$pkg" --arg t "$target"
	engine_log "NOTICE: security hold cleared: ${pkg} now resolves to mirror build ${target}"
}

# osf_security_safe_targets <name<TAB>target>... — the targets whose resolved
# transaction moves nothing off the mirror and removes nothing, one per line.
# A target whose own package is held is dropped by name. Otherwise (a pulled-in
# dependency is held, or the set cannot be resolved) the culprit is the target
# without which the rest is clean; failing that, each target is resolved alone,
# and failing that, a clean set is grown one target at a time.
osf_security_safe_targets() {
	local -a keep=("$@") rest others
	local held row pair other why
	while (( ${#keep[@]} )); do
		rest=()
		if held="$(osf_security_violations "${keep[@]#*$'\t'}")"; then
			if [[ -z "$held" ]]; then
				printf '%s\n' "${keep[@]#*$'\t'}"
				return 0
			fi
			why="pulls in: $(osf_security_reason "${held%%$'\n'*}")"
			for pair in "${keep[@]}"; do
				row="$(awk -F'\t' -v p="${pair%%$'\t'*}" '$1 == p {print; exit}' <<<"$held")"
				if [[ -n "$row" ]]; then
					osf_security_hold "${pair%%$'\t'*}" "${pair#*$'\t'}" "$(osf_security_reason "$row")"
				else
					rest+=("$pair")
				fi
			done
		else
			why="cannot be resolved"
			rest=("${keep[@]}")
		fi
		if (( ${#rest[@]} == ${#keep[@]} )); then
			rest=()
			for pair in "${keep[@]}"; do
				others=()
				for other in "${keep[@]}"; do [[ "$other" == "$pair" ]] || others+=("$other"); done
				if row="$(osf_security_violations "${others[@]#*$'\t'}")" && [[ -z "$row" ]]; then
					osf_security_hold "${pair%%$'\t'*}" "${pair#*$'\t'}" "the batch is clean without it; with it the batch ${why}"
				else
					rest+=("$pair")
				fi
			done
		fi
		if (( ${#rest[@]} == ${#keep[@]} )); then
			rest=()
			for pair in "${keep[@]}"; do
				if ! row="$(osf_security_violations "${pair#*$'\t'}")"; then
					osf_security_hold "${pair%%$'\t'*}" "${pair#*$'\t'}" "its transaction cannot be resolved"
				elif [[ -n "$row" ]]; then
					osf_security_hold "${pair%%$'\t'*}" "${pair#*$'\t'}" \
						"it pulls in: $(osf_security_reason "${row%%$'\n'*}")"
				else
					rest+=("$pair")
				fi
			done
		fi
		if (( ${#rest[@]} == ${#keep[@]} )); then
			# Each target is clean alone; only the set is not. Grow a clean set one
			# target at a time and hold the ones that spoil it.
			rest=()
			for pair in "${keep[@]}"; do
				if row="$(osf_security_violations "${rest[@]#*$'\t'}" "${pair#*$'\t'}")" && [[ -z "$row" ]]; then
					rest+=("$pair")
				else
					osf_security_hold "${pair%%$'\t'*}" "${pair#*$'\t'}" "together with the rest of the batch it ${why}"
				fi
			done
			(( ${#rest[@]} == 0 )) || printf '%s\n' "${rest[@]#*$'\t'}"
			return 0
		fi
		keep=("${rest[@]}")
	done
}

# osf_apply <pkg>... — upgrade exactly the named (already-installed) packages.
# Both scopes funnel here as an explicit set, so blacklist exclusion is plain
# set subtraction and a security run touches only security packages. confdef/
# confold keeps a maintainer-config prompt from ever hanging a timer run. A
# non-installed name can never be pulled in (`--only-upgrade`).
# A security set is resolved again here, immediately before it runs: discovery
# already dropped anything that would leave the mirror, so a hit now means the
# indexes moved underneath the run, and the whole apply is refused.
osf_apply() {
	OSF_TRANSACTION_DETAIL=""
	OSF_HELD_BACK=()
	[[ $# -gt 0 ]] || return 0
	if [[ "${REQ_SCOPE:-}" == security ]]; then
		local held new_shcp_dep
		new_shcp_dep="$(osf_security_new_shcp_deps "$@")" || return 1
		if [[ -n "$new_shcp_dep" ]]; then
			engine_log "WARNING: refusing the security apply: security_new_shcp_dependency: ${new_shcp_dep%%$'\n'*} requires an admin-approved update"
			return 1
		fi
		if ! held="$(osf_security_violations "$@")"; then
			engine_log "WARNING: the security transaction could not be resolved before apply; refusing it"
			return 1
		fi
		if [[ -n "$held" ]]; then
			engine_log "WARNING: refusing the security apply: the transaction changed since discovery and $(osf_security_reason "${held%%$'\n'*}")"
			return 1
		fi
	fi
	local rc=0
	osf_apply_set "$@" || rc=$?
	(( rc == 0 )) && return 0
	[[ "$OSF_REFUSAL_KIND" == removal ]] || return "$rc"
	# #321: one package whose upgrade needs a refused removal must not stop the
	# rest. Each target is previewed alone; the ones that still plan a refused
	# removal (an upgrade that needs a held one fails its own preview the same way)
	# are held back, named in OSF_HELD_BACK, and the rest applied. Holding back an
	# essential or SHCP package fails the apply: the release cannot go ahead
	# without it.
	local t name
	local -a keep=()
	for t in "$@"; do
		if osf_apply_preview "$t"; then
			keep+=("$t")
			continue
		fi
		OSF_HELD_BACK+=("${t}"$'\t'"${OSF_TRANSACTION_DETAIL}")
		name="$(osf_target_name "$t")"
		if [[ "$name" == shcp* || "$name" == shmp* ]] || grep -qxF -- "$name" <<<"$(osf_essential_pkgs)"; then
			osf_transaction_refuse "${name} cannot be upgraded: ${OSF_TRANSACTION_DETAIL}" removal
			return 1
		fi
	done
	engine_log "WARNING: holding back $(printf '%s\n' "${OSF_HELD_BACK[@]}" | cut -f1 | paste -sd' '): their upgrade would remove a protected package"
	OSF_TRANSACTION_DETAIL=""
	(( ${#keep[@]} > 0 )) || return 0
	osf_apply_set "${keep[@]}"
}
OSF_HELD_BACK=()

# osf_apply_set <pkg>... — the upgrade transaction itself. Every scope goes
# through the removal policy (#321): a series apply lists from dist-upgrade and a
# routine one lets the resolver choose, so either can plan a removal.
osf_apply_set() {
	case "$OS_FAMILY" in
		deb)
			osf_apt_transaction -y \
				-o Dpkg::Options::=--force-confdef \
				-o Dpkg::Options::=--force-confold \
				--only-upgrade install "$@" ;;
		rpm) osf_dnf_transaction update "$@" "${DNF_SKIP_DEAD_REPOS[@]}" ;;
	esac
}

# osf_apply_preview <pkg> — osf_apply_set's preview for one target, nothing applied.
osf_apply_preview() {
	case "$OS_FAMILY" in
		deb)
			osf_apt_preview -y \
				-o Dpkg::Options::=--force-confdef \
				-o Dpkg::Options::=--force-confold \
				--only-upgrade install "$1" ;;
		rpm) osf_dnf_preview update "$1" "${DNF_SKIP_DEAD_REPOS[@]}" ;;
	esac
}

# osf_target_name <target> — the package name of a work-set entry: deb pkg=version
# and an rpm exact NEVRA (the security scope's spellings) cut to the name.
osf_target_name() {
	case "$OS_FAMILY" in
		rpm) sed -E 's/-([0-9]+:)?[0-9][^-]*-[^-]+$//; s/\.(x86_64|aarch64|noarch|i686)$//' <<<"$1" ;;
		*)   printf '%s\n' "${1%%=*}" ;;
	esac
}

# --- additive package delivery (shcp-build#183, SC-543)
# osf_apply is --only-upgrade and can NEVER pull in a not-installed package; that
# one-directionality is load-bearing everywhere else. These two verbs are the
# additive counterpart, used ONLY by runtime_deps_ensure to install a panel runtime
# dep the signed manifest declares and this box is missing.

# osf_pkg_installed <pkg> — 0 iff the package is installed on this box.
osf_pkg_installed() {
	local pkg="$1"
	case "$OS_FAMILY" in
		deb) [[ "$(dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null)" == *"install ok installed"* ]] ;;
		rpm) rpm -q "$pkg" >/dev/null 2>&1 ;;
	esac
}

# osf_pkg_install <pkg> — install a not-yet-installed package additively.
# Deliberately install-only: it never upgrades, downgrades, removes or purges, and
# it touches no already-installed package (the caller has already confirmed absence).
# The name is validated by the caller (osf_valid_pkg, SC-002) and passed as a single
# argument — never word-split, never eval'd. deb goes through the no-autostart
# wrapper (a daemon dep such as unbound must not be started at delivery time, #226);
# rpm's %systemd_post applies the EL `disable` preset, so dnf neither starts nor
# enables the daemon and needs no wrapper.
osf_pkg_install() {
	local pkg="$1"
	case "$OS_FAMILY" in
		deb) osf_deb_install_no_autostart "$pkg" ;;
		rpm) osf_dnf_transaction install "$pkg" ;;
	esac
}

# --- transient policy-rc.d lifecycle (shcp-updater#256) -------------------------
# osf_deb_install_no_autostart drops a transient policy-rc.d so a delivered daemon
# package is not started by its own maintainer script (#226/SC-691). It MUST be taken
# back down on EVERY exit: a SIGTERM (the apply unit stopped, TimeoutStopSec, an
# operator ^C) or a set -e abort mid-apt otherwise leaves the exit-101 policy in
# place, and every LATER apt run on the box then silently never starts an installed
# daemon. The restore used to be inline, so an external kill skipped it (#256).
# SIGKILL cannot be trapped, so a stranded one is also swept at engine start
# (osf_policy_rc_d_self_heal). State lives in two globals so the signal trap, the
# normal end-of-function path and on_engine_exit all reach ONE idempotent restore.
# The transient file carries a marker line and its owner pid so a stale one can be
# told apart from an operator's own policy-rc.d and attributed to a dead run.
POLICY_RC_D_MARKER="shcp-update transient policy-rc.d (do-not-start, #226)"
POLICY_RC_D_ACTIVE=""   # the policy path we currently own, "" when none
POLICY_RC_D_SAVED=""    # the operator file we moved aside, "" when there was none

# osf_policy_rc_d_restore -- idempotent. Restore the operator's saved policy-rc.d if
# we moved one aside; otherwise remove OUR transient file, and only if it is still
# ours (an operator who dropped a fresh policy-rc.d mid-run is never clobbered).
osf_policy_rc_d_restore() {
	local policy="$POLICY_RC_D_ACTIVE" saved="$POLICY_RC_D_SAVED"
	# Clear the globals FIRST: a second entry (a signal during restore, then the
	# EXIT trap) must be a no-op, never a double mv/rm.
	POLICY_RC_D_ACTIVE=""
	POLICY_RC_D_SAVED=""
	[[ -n "$policy" ]] || return 0
	if [[ -n "$saved" && -e "$saved" ]]; then
		mv -f -- "$saved" "$policy" 2>/dev/null || true
		return 0
	fi
	# No saved file: remove ours, but only if the on-disk file is still the transient
	# one we wrote (marker present). Never remove a plain file an operator created in
	# the window.
	if [[ -f "$policy" && ! -L "$policy" ]] && grep -q -- "$POLICY_RC_D_MARKER" "$policy" 2>/dev/null; then
		rm -f -- "$policy" 2>/dev/null || true
	fi
}

# osf_policy_rc_d_signal <signum> -- trap body for TERM/INT/HUP. Restore, then exit
# with the conventional 128+signal status so the EXIT trap (on_engine_exit) still
# runs and marks the run failed. A plain re-raise (kill -SIG $$) would die by signal
# and SKIP the EXIT trap, leaving the journal `running`.
osf_policy_rc_d_signal() {
	osf_policy_rc_d_restore
	exit $((128 + $1))
}

# osf_pid_is_live_engine <pid> -- 0 iff pid names a LIVE shcp-update engine process.
# Guards the self-heal against removing a transient policy-rc.d a concurrent run
# legitimately owns, and against PID reuse (a dead engine's pid handed to an
# unrelated process). The cmdline match is overridable for the test harness.
osf_pid_is_live_engine() {
	local pid="$1"
	[[ "$pid" =~ ^[0-9]+$ ]] || return 1
	kill -0 "$pid" 2>/dev/null || return 1
	local want="${SHCP_UPDATE_ENGINE_CMDLINE_MATCH:-shcp-update}"
	local cl
	cl="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" || return 1
	[[ "$cl" == *"$want"* ]]
}

# osf_policy_rc_d_self_heal -- SIGKILL cannot be trapped, so a run OOM-killed mid-apt
# leaves its transient policy-rc.d (and, if the box had one, the operator file moved
# aside to <policy>.shcp-update-saved.<pid>) behind. Run at engine start: restore any
# operator file a DEAD run stranded, then remove any OUR-marked transient a dead run
# left, keying "dead" on the owner pid the file records. A file owned by a LIVE engine
# (a concurrent run mid-apt) is left strictly alone, and an operator's own (unmarked)
# policy-rc.d is never touched. deb-shaped but harmless elsewhere: it only ever acts
# on a file carrying our marker or our saved-suffix.
osf_policy_rc_d_self_heal() {
	local policy="${SHCP_UPDATE_POLICY_RC_D:-/usr/sbin/policy-rc.d}"
	local dir base f pid
	dir="$(dirname -- "$policy")"
	base="$(basename -- "$policy")"
	# 1) operator files stranded by a dead run -- restore (this may overwrite the
	#    dead run's transient with the operator's real file, which is the goal).
	for f in "$dir/$base".shcp-update-saved.*; do
		[[ -e "$f" && ! -L "$f" ]] || continue
		pid="${f##*.}"
		[[ "$pid" =~ ^[0-9]+$ ]] || continue
		osf_pid_is_live_engine "$pid" && continue
		mv -f -- "$f" "$policy" 2>/dev/null \
			&& engine_log "self-heal: restored operator policy-rc.d stranded by dead run (pid ${pid})"
	done
	# 2) our transient left by a dead run and NOT already replaced by (1).
	[[ -f "$policy" && ! -L "$policy" ]] || return 0
	grep -q -- "$POLICY_RC_D_MARKER" "$policy" 2>/dev/null || return 0
	pid="$(sed -n 's/^# owner-pid: \([0-9][0-9]*\)$/\1/p' "$policy" | head -n1)"
	if [[ -z "$pid" ]] || ! osf_pid_is_live_engine "$pid"; then
		rm -f -- "$policy" 2>/dev/null \
			&& engine_log "self-heal: removed stale shcp-update policy-rc.d (owner pid ${pid:-unknown} is not a live engine run)"
	fi
}

# osf_deb_install_no_autostart <pkg> — the deb arm of osf_pkg_install. A daemon
# package delivered here (unbound, SC-691/#226) must NOT be started by its own
# maintainer script: this path owns package PRESENCE only, while the converge action
# (installer#1051) owns the recursor's enable/start once its config is in place —
# until then a default-config daemon has no business running, and on a box still
# pointing resolv.conf elsewhere it would bind :53 for nothing. A transient
# policy-rc.d returning 101 makes the postinst (invoke-rc.d / deb-systemd-invoke both
# honour it) skip the start; a daemonless dep such as bind9-dnsutils is unaffected.
# Boot-time enable is the converge action's to settle — it runs on a frequent timer,
# so the final unit state is set long before any reboot. An operator/build policy-rc.d
# already on the box is preserved and restored. Restored on EVERY exit path so a
# failed apt run never leaves service starts blocked system-wide. confdef/confold and
# --no-install-recommends carry over from the original arm (a maintainer prompt must
# never hang a timer run; an additive dep must not drag in a wider set).
osf_deb_install_no_autostart() {
	local pkg="$1"
	local policy="${SHCP_UPDATE_POLICY_RC_D:-/usr/sbin/policy-rc.d}"
	local saved=""
	if [[ -e "$policy" ]]; then
		saved="${policy}.shcp-update-saved.$$"
		mv -f -- "$policy" "$saved"
	fi
	# Publish the restore state BEFORE the file exists on disk, so a signal caught
	# mid-write still restores through the globals (#256).
	POLICY_RC_D_ACTIVE="$policy"
	POLICY_RC_D_SAVED="$saved"
	{
		printf '#!/bin/sh\n'
		printf '# %s\n' "$POLICY_RC_D_MARKER"
		printf '# owner-pid: %s\n' "$$"
		printf '# Transient: written by shcp-update additive package delivery (#226) so a\n'
		printf '# daemon package is not started by its own maintainer script. Removed on exit\n'
		printf '# (trap), or swept at the next engine start if a SIGKILL stranded it (#256).\n'
		printf 'exit 101\n'
	} > "$policy"
	chmod 0755 "$policy"
	# Restore on a signal that would otherwise skip the inline restore below: a
	# systemctl stop / TimeoutStopSec sends SIGTERM, ^C SIGINT, a dropped session
	# SIGHUP. See osf_policy_rc_d_signal for why it exits rather than re-raising.
	trap 'osf_policy_rc_d_signal 15' TERM
	trap 'osf_policy_rc_d_signal 2'  INT
	trap 'osf_policy_rc_d_signal 1'  HUP
	local rc=0
	osf_apt_transaction -y \
		-o Dpkg::Options::=--force-confdef \
		-o Dpkg::Options::=--force-confold \
		--no-install-recommends install "$pkg" || rc=$?
	trap - TERM INT HUP
	osf_policy_rc_d_restore
	# policy-rc.d blocks only the START; dpkg's postinst still ENABLES the unit. Leave
	# any unit THIS package just installed DISABLED, so the box ends installed /
	# not-enabled / not-running — the same state EL's systemd preset yields, and the
	# state the converge action expects (installer#1489's start_unbound runs
	# `manage_service enable` + restart, so it never assumes the unit is already
	# enabled). Without this a reboot before the converge action runs would autostart a
	# default-config unbound (127.0.0.1:53 / [::1]:53), racing PowerDNS for :53 —
	# whichever loses breaks DNS (SC-691). Units come from `dpkg -L`, so only this
	# package's own units are touched (a daemonless dep such as bind9-dnsutils yields
	# none); no --now, since the start was suppressed and the unit is not running.
	# runtime_deps_ensure installs only absent packages, so an operator's pre-existing
	# unbound never reaches here.
	if (( rc == 0 )); then
		local unit
		while IFS= read -r unit; do
			[[ -n "$unit" ]] || continue
			systemctl disable "$unit" >/dev/null 2>&1 || true
		done < <(osf_deb_pkg_units "$pkg")
	fi
	return "$rc"
}

# osf_deb_pkg_units <pkg> — the systemd .service units a deb package ships, one per
# line (bare unit name), read from the package's own installed-file list so nothing is
# hardcoded per package. Empty for a package that ships no unit.
osf_deb_pkg_units() {
	dpkg -L "$1" 2>/dev/null \
		| sed -n 's#^.*/systemd/system/\([^/]*\.service\)$#\1#p' | sort -u
}

# --- the rollback direction (UPD-5, §4.5 step 2) -------------------------------
# The ordinary SHCP source supplies the archive URL, suite and signed-by key.
# The rollback suite is deliberately below every installed/current candidate.
osf_rollback_source_ensure() {
	[[ "$OS_FAMILY" == deb ]] || return 0
	local root="${CONFIG_ROOT%/}" list line="" opts="" url="" suite="" comps="" count=0
	list="${root}/etc/apt/sources.list.d/shcp.list"
	[[ -r "$list" ]] || return 0
	local re='^deb[[:space:]]+(\[signed-by=/[^][:space:]]+\])[[:space:]]+(https://[^[:space:]]+)[[:space:]]+([a-z][a-z0-9-]*-[0-9]+\.[0-9]+)[[:space:]]+(main)$'
	while IFS= read -r line || [[ -n "$line" ]]; do
		[[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue
		[[ "$line" =~ $re ]] || return 1
		count=$((count + 1))
		opts="${BASH_REMATCH[1]}" url="${BASH_REMATCH[2]}" suite="${BASH_REMATCH[3]}" comps="${BASH_REMATCH[4]}"
	done < "$list"
	[[ "$count" == 1 ]] || return 1
	local sources="${root}/etc/apt/sources.list.d" pins="${root}/etc/apt/preferences.d"
	local source="${sources}/shcp-rollback.list" pinfile="${pins}/00shcp-rollback.pref"
	local want="deb ${opts} ${url} ${suite}-rollback ${comps}"
	local pin="Package: shcpd shcp-valkey shcp-dovecot shcp-restic
Pin: release n=${suite}-rollback
Pin-Priority: 1"
	[[ ! -L "$source" && ! -L "$pinfile" && -d "$pins" ]] || return 1
	# A series may be published before its rollback suite. Keep apt usable until it is.
	local head_status=""
	head_status="$(curl -fsSI --max-time 10 -o /dev/null -w '%{http_code}' "${url}/dists/${suite}-rollback/InRelease" 2>/dev/null)" || head_status=""
	if [[ "$head_status" != 200 ]]; then
		rm -f "$source" "$pinfile" || return 1
		engine_log "rollback suite ${suite}-rollback not published; source not installed"
		return 0
	fi
	if [[ -r "$source" && -r "$pinfile" && "$(<"$source")" == "$want" && "$(<"$pinfile")" == "$pin" ]]; then
		return 0
	fi
	local source_tmp pin_tmp
	source_tmp="$(mktemp "${sources}/.shcp-rollback.XXXXXX")" || return 1
	pin_tmp="$(mktemp "${pins}/.shcp-rollback.XXXXXX")" || return 1
	printf '%s\n' "$pin" > "$pin_tmp" || return 1
	printf '%s\n' "$want" > "$source_tmp" || return 1
	chmod 0644 "$pin_tmp" "$source_tmp" || return 1
	mv -f "$pin_tmp" "$pinfile" && mv -f "$source_tmp" "$source"
}

# SC-991: a missing, malformed, or unverifiable pair never means an empty list.
OSF_WITHDRAWAL_READY=false
OSF_WITHDRAWAL_ROWS=""
osf_withdrawal_rows_valid() {
	local rc=0
	# A comment line is '#' plus any text: the published list opens with three of
	# them, and an anchored bare '#' rejected every real list (shcp-master#3336).
	grep -qvE '^(#.*|$|((deb\|(shcpd|shcp-valkey|shcp-dovecot|shcp-restic)\|(amd64|arm64)|rpm\|(shcpd|shcp-valkey|shcp-dovecot|shcp-restic)\|(x86_64|aarch64))\|[A-Za-z0-9.+_~:-]+))$' "$1" || rc=$?
	[[ "$rc" == 1 ]]
}
osf_withdrawal_load() {
	OSF_WITHDRAWAL_READY=false OSF_WITHDRAWAL_ROWS=""
	local dir list sig status fpr key url
	url="${SHCP_UPDATE_WITHDRAWAL_URL:-https://repo.shcp.dev/withdrawn-daemon-versions.tsv}"
	fpr="${SHCP_UPDATE_REPO_GPG_FPR:-B9B7366DB831B2765ABB03D8BFAF79919CF7776E}"
	case "$OS_FAMILY" in
		deb) key="${SHCP_UPDATE_WITHDRAWAL_KEYRING:-/usr/share/keyrings/shcp-archive-keyring.gpg}" ;;
		rpm) key="${SHCP_UPDATE_WITHDRAWAL_KEYRING:-/etc/pki/rpm-gpg/RPM-GPG-KEY-shcp}" ;;
		*) return 1 ;;
	esac
	[[ "$url" =~ ^https://[^[:space:]]+/withdrawn-daemon-versions\.tsv$ && "$fpr" =~ ^[A-Fa-f0-9]{40}$ && -f "$key" ]] || return 1
	mkdir -p "$SHCP_UPDATE_STATE_DIR" || return 1
	dir="$(mktemp -d "${SHCP_UPDATE_STATE_DIR}/.withdrawals.XXXXXX")" || return 1
	list="${dir}/list" sig="${dir}/list.sig"
	# gpgv reads only a binary keyring. EL ships the repo key ASCII-armored
	# (/etc/pki/rpm-gpg/RPM-GPG-KEY-shcp, what dnf's gpgkey= points at), and gpgv
	# fails on it with "invalid packet (ctb=2d)", so dearmor it into this
	# throwaway dir first. The trust decision stays the pinned-primary VALIDSIG.
	local keyring="$key"
	if grep -aqm1 -e '^-----BEGIN PGP PUBLIC KEY BLOCK-----' -- "$key" 2>/dev/null; then
		keyring="${dir}/keyring.gpg"
		GNUPGHOME="$dir" gpg --batch --quiet --yes --dearmor -o "$keyring" "$key" 2>/dev/null || keyring=""
	fi
	# The PINNED PRIMARY is VALIDSIG's last field: the first is the key that
	# made the signature, which for the repo key is its signing subkey. Same
	# match as verify_manifest's.
	if [[ -n "$keyring" ]] \
		&& curl -fsS --max-time 15 --max-filesize 65536 -o "$list" "$url" \
		&& curl -fsS --max-time 15 --max-filesize 65536 -o "$sig" "${url}.sig" \
		&& [[ -f "$list" && -s "$sig" ]] \
		&& osf_withdrawal_rows_valid "$list" \
		&& status="$(gpgv --keyring "$keyring" --status-fd 1 "$sig" "$list" 2>/dev/null)" \
		&& grep -Eq "^\\[GNUPG:\] VALIDSIG .*[[:space:]]${fpr}[[:space:]]*$" <<< "$status"; then
		OSF_WITHDRAWAL_ROWS="$(cat "$list")"
		OSF_WITHDRAWAL_READY=true
	fi
	rm -rf "$dir"
	[[ "$OSF_WITHDRAWAL_READY" == true ]]
}

osf_version_permitted() {
	local pkg="$1" ver="$2" arch="${3:-}"
	case "$pkg" in shcpd|shcp-valkey|shcp-dovecot|shcp-restic) ;; *) return 0 ;; esac
	[[ "$OSF_WITHDRAWAL_READY" == true ]] || return 1
	[[ "$OS_FAMILY" != rpm ]] || ver="$(osf_rpm_evr_norm "$ver")"
	if [[ -z "$arch" ]]; then
		case "$OS_FAMILY" in
			deb) arch="$(dpkg --print-architecture)" || return 1 ;;
			rpm) arch="$(rpm -E '%{_arch}')" || return 1 ;;
		esac
	fi
	case "$OS_FAMILY:$arch" in
		deb:amd64|deb:arm64|rpm:x86_64|rpm:aarch64) ;;
		*) return 1 ;;
	esac
	local rc=0
	grep -Fqx -- "${OS_FAMILY}|${pkg}|${arch}|${ver}" <<< "$OSF_WITHDRAWAL_ROWS" || rc=$?
	[[ "$rc" == 1 ]]
}

# osf_apply is deliberately one-directional (`--only-upgrade`), so the rollback
# needs its own seam. ONE PACKAGE PER CALL, on purpose: §4.5 requires per-package
# error isolation ("hold errors, continue, report"), which a variadic transaction
# cannot give — one prior version missing from the repo would abort the whole apt
# run and lose every other restore with it.

# A package VERSION, validated per family. Both patterns are anchored and contain
# no shell metacharacter, so a version can never inject argv (SC-002, the same
# discipline as osf_valid_pkg). This matters more than it looks: the version comes
# from .packages.changed[].from, which is a FILE ON DISK re-read minutes or days
# after it was written. By then it is journal data, not package-manager output,
# and no unquoted journal value may reach a command line.
osf_valid_pkg_version() {
	case "$OS_FAMILY" in
		rpm) [[ "$1" =~ ^[0-9][A-Za-z0-9._+~:-]*$ ]] ;;
		*)   [[ "$1" =~ ^[0-9][A-Za-z0-9.+~:-]*$ ]] ;;
	esac
}

# osf_version_available <pkg> <version> — THE SC-078 DETECTOR. 0 = the enabled
# repos carry exactly that version, 1 = they do not, 2 = the name or version did
# not validate.
#
# Exit 2 is separate on purpose: the caller must be able to tell "a malformed
# journal" from "the version aged out of the repo", or a corrupt record gets
# reported to the operator as an EOL'd package.
#
# The engine ASKS FIRST rather than attempting the install and interpreting an
# apt error message, so the reason it records is the true one and not a guess at
# what apt meant. SC-078's two-suite window is supposed to keep priors
# installable; when it did not, the operator must be told which package and why.
osf_version_available() {
	local pkg="$1" ver="$2"
	osf_valid_pkg "$pkg" && osf_valid_pkg_version "$ver" || return 2
	osf_version_permitted "$pkg" "$ver" "${3:-}" || return 1
	case "$OS_FAMILY" in
		# `apt-cache madison` lists every version the ENABLED indexes carry —
		# repo state only, never dpkg's installed status — one line per source;
		# the version is the second `|`-delimited field. An exact field match,
		# never a substring. NOT `apt-cache show pkg=ver`: on trixie (and noble)
		# that exits 0 whenever the package exists at ANY version, so an aged-out
		# or plainly nonexistent version read as "available" and the rollback
		# then tried an install it could never fetch (updater#172, SC-476).
		deb) apt-cache madison "$pkg" 2>/dev/null \
			| awk -F'|' -v v="$ver" 'NF>=2 {gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); if ($2==v) found=1} END {exit !found}' ;;
		# --showduplicates lists every EVR the enabled repos carry; an exact field
		# match, never a substring (2.0 must not satisfy a want of 2.0.1). The want
		# comes from `rpm %{EVR}`, which spells an explicit Epoch: 0 as "0:1.0-1"
		# while dnf lists "1.0-1" (measured, updater#159) — compare epoch-0-less.
		# --available: without it the listing also carries an "Installed Packages"
		# section, so a prior EVR merely still in the rpmdb (an installonly kernel,
		# a package whose old build the repo has since dropped) passed this SC-078
		# check and the rollback then tried to install a build no repo carries.
		rpm) dnf -q list --available --showduplicates "$pkg" 2>/dev/null \
			| awk -v v="$(osf_rpm_evr_norm "$ver")" 'NF>=2 && $2 == v {found=1} END {exit !found}' ;;
	esac
}

# osf_downgrade_one <pkg> <version> — restore one package to a prior version.
osf_downgrade_one() {
	local pkg="$1" ver="$2" arch="${3:-}"
	osf_valid_pkg "$pkg" && osf_valid_pkg_version "$ver" || return 2
	osf_version_permitted "$pkg" "$ver" "$arch" || return 1
	case "$OS_FAMILY" in
		deb)
			# NOT --only-upgrade: that flag is exactly what makes osf_apply
			# one-directional. --allow-downgrades is required because apt refuses a
			# lower version without it. confdef/confold for osf_apply's reason — a
			# maintainer prompt must never hang a rollback.
			osf_apt_transaction -y \
				-o Dpkg::Options::=--force-confdef \
				-o Dpkg::Options::=--force-confold \
				--allow-downgrades install "${pkg}=${ver}" ;;
		rpm)
			# `dnf install` refuses a lower EVR outright and `dnf downgrade` refuses
			# a package that is not installed — and the run being rolled back may
			# have INSTALLED this package, in which case the restore is a plain
			# install of the prior EVR. Branch on what is actually on the box rather
			# than on what the journal implies.
			local evr spec; evr="$(osf_rpm_evr_norm "$ver")"
			# dnf reads name-[epoch:]version-release.arch; name.arch-evr is no NEVRA
			# and resolves to "No package ... available" (shcp-master#3224, alma10).
			spec="${pkg}-${evr}${arch:+.${arch}}"
			if rpm -q "${pkg}${arch:+.${arch}}" >/dev/null 2>&1; then
				osf_dnf_transaction downgrade "$spec"
			else
				osf_dnf_transaction install "$spec"
			fi ;;
	esac
}

# osf_deb_downgrade_group <pkg=version>... — restore several Debian packages in
# ONE apt transaction (#321). Lockstep pairs (curl/libcurl4t64, apache2/
# apache2-bin, openssl/libssl3t64, libc6/libc-bin) depend on each other's exact
# version, so neither half moves alone: the tool alone is unresolvable and the
# library alone resolves only by removing its dependents. Each member is re-read
# from the package database afterwards; OSF_DEB_GROUP_OK[pkg]=true marks one that
# reached its version.
declare -A OSF_DEB_GROUP_OK=()
osf_deb_downgrade_group() {
	local spec after
	OSF_DEB_GROUP_OK=()
	[[ "$OS_FAMILY" == deb && $# -gt 0 ]] || return 2
	for spec in "$@"; do
		osf_version_permitted "${spec%%=*}" "${spec#*=}" || return 1
	done
	osf_apt_transaction -y \
		-o Dpkg::Options::=--force-confdef \
		-o Dpkg::Options::=--force-confold \
		--allow-downgrades install "$@" || return 1
	after="$(baseline_json 2>/dev/null)" || return 1
	for spec in "$@"; do
		if [[ "$(jq -r --arg p "${spec%%=*}" '.[$p] // ""' <<<"$after")" == "${spec#*=}" ]]; then
			OSF_DEB_GROUP_OK["${spec%%=*}"]=true
		fi
	done
	return 0
}

# osf_rpm_evr_norm <evr> — an rpm EVR with a zero epoch dropped. `rpm --qf %{EVR}`
# prints "0:" whenever the header carries an Epoch tag, even Epoch: 0; libsolv and
# the dnf CLI/API drop a zero epoch. A non-zero epoch is kept: it is significant.
osf_rpm_evr_norm() { printf '%s' "${1#0:}"; }

# osf_repo_current_version <pkg> — the HIGHEST version the CURRENTLY-enabled repos
# carry for <pkg>, echoed on stdout; empty output + nonzero when they carry none.
# REPO STATE ONLY: it reads the apt index (apt-cache madison), never dpkg's
# installed status, so a version installed from a now-disabled suite — the abandoned
# new-series daemon a failed series upgrade left behind — is NOT a candidate.
#
# This is why osf_settle_one cannot use `apt-cache policy`'s Candidate: the daemon
# Priority-1001 pin (SC-078) matches BOTH the stranded new-series version AND the
# reverted series' current one, so apt's own candidate selection tie-breaks to the
# HIGHER installed version. The settle target has to be read from the repo instead
# (updater#172, SC-476).
osf_repo_current_version() {
	local pkg="$1" arch="${2:-}"
	osf_valid_pkg "$pkg" || return 2
	[[ -z "$arch" || "$arch" =~ ^[A-Za-z0-9_]+$ ]] || return 2
	case "$OS_FAMILY" in
		deb)
			local best="" v
			while IFS= read -r v; do
				[[ -n "$v" ]] || continue
				if [[ -z "$best" ]] || dpkg --compare-versions "$v" gt "$best" 2>/dev/null; then
					best="$v"
				fi
			done < <(apt-cache madison "$pkg" 2>/dev/null \
				| awk -F'|' 'NF>=2 {gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); if ($2!="") print $2}' \
				| sort -u)
			[[ -n "$best" ]] || return 1
			printf '%s\n' "$best" ;;
		# rpm: the same answer has to honour repo `priority=` — shcp.repo's
		# priority=10 is the EL twin of the deb pin, and a lower-priority repo
		# (EPEL, Remi, AppStream) carrying a HIGHER same-named EVR must not be the
		# settle target. `dnf repoquery --latest-limit` is priority-blind (measured:
		# it returned a priority-99 repo's 9.9-1 over shcp's 1.0-2, updater#159), so
		# ask dnf's own solver query: available packages only (the installed rpmdb
		# is never loaded), latest per arch BY PRIORITY. Exactly one EVR across
		# arches or nothing — a split answer is not a settle target. The shcp repo
		# must load (a dead shcp with the rest skipped would unmask EPEL/Remi). The name was
		# validated above and reaches python as argv, never as code. `import dnf` is
		# always present where the dnf CLI is: EL10's /usr/bin/dnf is owned by
		# python3-dnf. -I: isolated mode, so no PYTHON* environment or user site
		# directory can inject modules into a root process.
		rpm)
			command -v python3 >/dev/null 2>&1 || return 1
			local evr
			local -a query_args=("$pkg")
			[[ -z "$arch" ]] || query_args+=("$arch")
			evr="$(python3 -I - "${query_args[@]}" 2>/dev/null <<'PY'
import sys
import dnf
name = sys.argv[1]
arch = sys.argv[2] if len(sys.argv) > 2 else ""
with dnf.Base() as base:
    base.conf.read()
    base.conf.substitutions.update_from_etc(base.conf.installroot)
    base.read_all_repos()
    shcp = base.repos.get("shcp")
    if shcp is None or not shcp.enabled:
        sys.exit(1)
    for repo in base.repos.iter_enabled():
        # every other repo may be down; the shcp repo may not, or priority=10
        # masks nothing and a lower-priority same-named build becomes the target
        repo.skip_if_unavailable = repo.id != "shcp"
    base.fill_sack(load_system_repo=False, load_available_repos=True)
    q = base.sack.query().available().filter(name=name)
    if arch:
        q = q.filter(arch=arch)
    evrs = sorted({p.evr for p in q.filter(latest_per_arch_by_priority=True)})
    if len(evrs) != 1:
        sys.exit(1)
    print(evrs[0])
PY
)" || return 1
			[[ -n "$evr" && "$evr" != *$'\n'* ]] && osf_valid_pkg_version "$evr" || return 1
			printf '%s\n' "$evr" ;;
	esac
}

# osf_settle_one <pkg> — bring <pkg> to the version the CURRENTLY-enabled repos
# offer for it, downgrading from a higher installed version if need be. On success
# the version it settled on is exported in OSF_SETTLED_VERSION so the caller can
# journal it. The cross-series rollback uses this AFTER the suite/pin revert, when
# the byte-exact pre-upgrade version has aged out of the reverted suite (reprepro
# serves only a suite's CURRENT versions): the daemon Priority-1001 pins now point
# back at the reverted series, so the repo's current version IS that series'
# current, security-maintained one. This is the SC-476 "settle to current" leg —
# an inability to reach the *exact* prior version is a warning, not a wedge.
#
# A bare `apt-get install <pkg>` was WRONG here: under the 1001 pin apt keeps the
# HIGHER installed (new-series) version and downgrades nothing. The reverted
# series' current version is resolved explicitly and installed by exact version so
# --allow-downgrades actually moves the daemon DOWN onto the reverted series
# (updater#172, SC-476).
#
# rpm (updater#159): the shcp.repo series var was just reverted, so the target is
# the reverted leaf's priority-aware current EVR. `dnf distro-sync <pkg>` is the
# verb that moves a package to what the enabled repos offer in EITHER direction
# (`dnf install` refuses a lower EVR, `dnf downgrade` refuses an equal one) and it
# honours priority. It is never trusted on its own: a versionlock turns it into a
# no-op or an error, and a dep conflict can land elsewhere, so the installed EVR is
# read back and must be exactly the resolved target — one line, which also refuses
# an rpmdb holding two EVRs of the package (an interrupted transaction).
osf_settle_one() {
	local pkg="$1"
	OSF_SETTLED_VERSION=""
	osf_valid_pkg "$pkg" || return 2
	case "$OS_FAMILY" in
		deb)
			local target
			target="$(osf_repo_current_version "$pkg")" || return 1
			[[ -n "$target" ]] && osf_valid_pkg_version "$target" || return 1
			if osf_apt_transaction -y \
				-o Dpkg::Options::=--force-confdef \
				-o Dpkg::Options::=--force-confold \
				--allow-downgrades install "${pkg}=${target}"; then
				OSF_SETTLED_VERSION="$target"
				return 0
			fi
			return 1 ;;
		rpm)
			local target have
			target="$(osf_repo_current_version "$pkg")" || return 1
			[[ -n "$target" ]] && osf_valid_pkg_version "$target" || return 1
			# The shcp repo is NOT skippable here even though other dead repos are:
			# with shcp skipped, priority=10 masks nothing and distro-sync would
			# happily install a lower-priority repo's same-named build.
			osf_dnf_transaction distro-sync "$pkg" "${DNF_SKIP_DEAD_REPOS[@]}" --setopt=shcp.skip_if_unavailable=False || return 1
			have="$(rpm -q --qf '%{EVR}\n' "$pkg" 2>/dev/null)" || return 1
			[[ "$have" != *$'\n'* && "$(osf_rpm_evr_norm "$have")" == "$(osf_rpm_evr_norm "$target")" ]] || return 1
			OSF_SETTLED_VERSION="$target"
			return 0 ;;
	esac
}

# osf_settle_group <pkg>... -- RPM-only grouped counterpart to osf_settle_one.
# Every member target is resolved independently before the transaction, using the
# same priority-aware, fail-closed SHCP repository query.  One distro-sync keeps
# subpackages built from the same source RPM coherent. Results are keyed by
# validated `name|arch` identities and re-read per member: dnf success is not
# proof that every requested instance moved (versionlock and installonly policy
# can produce partial results).
declare -A OSF_SETTLE_GROUP_OK=() OSF_SETTLE_GROUP_VERSION=()
rollback_settle_group_key() {
	local entry="$1" pkg source_rpm installonly identity
	pkg="$(jq -r '.pkg // empty' <<<"$entry")"
	source_rpm="$(jq -r '.from_instance.source_rpm // .to_instance.source_rpm // empty' <<<"$entry")"
	installonly="$(jq -r '.installonly // false' <<<"$entry")"
	identity="$(jq -r '.from_instance.nevra // .to_instance.nevra // empty' <<<"$entry")"
	if [[ -n "$source_rpm" && "$installonly" != true ]]; then
		printf 'source:%s\n' "$source_rpm"
	else
		printf 'instance:%s\n' "${identity:-$pkg}"
	fi
}
osf_settle_group() {
	OSF_SETTLE_GROUP_OK=() OSF_SETTLE_GROUP_VERSION=()
	[[ "$OS_FAMILY" == "rpm" && $# -gt 0 ]] || return 2
	local identity pkg arch spec target have transaction_ok=true
	local -a members=() specs=()
	for identity in "$@"; do
		pkg="${identity%%|*}"; arch="${identity#*|}"
		[[ "$identity" == *'|'* && -n "$arch" && "$arch" =~ ^[A-Za-z0-9_]+$ ]] || return 2
		osf_valid_pkg "$pkg" || return 2
		[[ -z "${OSF_SETTLE_GROUP_VERSION[$identity]+x}" ]] || continue
		target="$(osf_repo_current_version "$pkg" "$arch")" || return 1
		[[ -n "$target" ]] && osf_valid_pkg_version "$target" || return 1
		spec="${pkg}.${arch}"
		members+=("$identity"); specs+=("$spec")
		OSF_SETTLE_GROUP_VERSION["$identity"]="$target"
	done
	((${#members[@]} > 0)) || return 1
	osf_dnf_transaction distro-sync "${specs[@]}" "${DNF_SKIP_DEAD_REPOS[@]}" \
		--setopt=shcp.skip_if_unavailable=False || transaction_ok=false
	for identity in "${members[@]}"; do
		pkg="${identity%%|*}"; arch="${identity#*|}"; spec="${pkg}.${arch}"
		have="$(rpm -q --qf '%{EVR}\n' "$spec" 2>/dev/null)" || have=""
		if [[ "$transaction_ok" == true && -n "$have" && "$have" != *$'\n'* \
				&& "$(osf_rpm_evr_norm "$have")" == "$(osf_rpm_evr_norm "${OSF_SETTLE_GROUP_VERSION[$identity]}")" ]]; then
			OSF_SETTLE_GROUP_OK["$identity"]=true
		else
			OSF_SETTLE_GROUP_OK["$identity"]=false
		fi
	done
	# A non-zero transaction makes every member fail even if its partial work happened
	# to put some on target: retrying is safe and provides an auditable clean result
	# rather than blessing an interrupted solver run.
	[[ "$transaction_ok" == true ]] || return 1
	for identity in "${members[@]}"; do
		[[ "${OSF_SETTLE_GROUP_OK[$identity]}" == true ]] || return 1
	done
	return 0
}

# osf_remove_one <pkg> — the mirror case. journal_changed_json emits
# {"pkg":…, "from": null, "to": "…"} for a package the run INSTALLED; rolling
# that back means removing it.
#
# NOT --purge, deliberately: a rollback restores the previous state, and purging
# configuration the operator may have edited is a wider blast radius than the
# update it is undoing ever had.
#
# Both families preview the removal and refuse it unless every package it takes
# is one this run installed (OSF_REMOVAL_SCOPE, read by osf_removal_refusals):
# `apt-get remove` cascades to every reverse dependency, so a pre-run package
# that depends on the run's new library would otherwise go with it (#321).
#
# Returns 3 (kept, not a failure) for a deb package that holds the RUNNING
# kernel's image or modules: the box rebooted into the kernel the run installed.
# Removing it leaves the running kernel without its modules (nftables, a new
# filesystem, a NIC driver all fail to load until the next reboot). Keeping it
# is harmless once the metapackage is restored; the operator removes it after
# booting the previous kernel. rpm needs no guard: dnf's protect_running_kernel
# refuses that removal and the preview fails closed.
osf_remove_one() {
	local pkg="$1" instance="${2:-}"
	local OSF_REMOVAL_SCOPE=run
	OSF_TRANSACTION_DETAIL=""
	osf_valid_pkg "$pkg" || return 2
	case "$OS_FAMILY" in
		deb)
			if osf_deb_holds_running_kernel "$pkg"; then
				OSF_TRANSACTION_DETAIL="it holds the running kernel ($(osf_running_kernel)); boot the previous kernel, then remove it"
				engine_log "rollback: not removing ${pkg} — ${OSF_TRANSACTION_DETAIL}"
				return 3
			fi
			osf_apt_transaction -y remove "$pkg" ;;
		# rpm: a package the series run INSTALLED is usually a new dependency of
		# something it upgraded, and `dnf remove` takes every dependent with it —
		# erasing a daemon and saving its edited %config(noreplace) as .rpmsave
		# (measured, updater#159). Preview first; remove only when the transaction
		# removes exactly this package, never its dependents or "unused" deps.
		# NOT -q on the preview: quiet mode suppresses the transaction table and
		# prints only "Operation aborted." (measured). The engine runs LC_ALL=C, so
		# the section headers are the untranslated dnf 4 strings. A preview that
		# does not show the package being removed at all is not trusted either.
		rpm)
			# The journal names the exact instance. A name-only record cannot prove
			# which parallel/multilib RPM the run added, so it is never allowed to
			# erase anything.
			[[ -n "$instance" ]] || { engine_log "rollback: not removing ${pkg} — the journal names no RPM instance identity"; return 1; }
			[[ "$instance" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*-[0-9]+:[A-Za-z0-9._+~:-]+\.[A-Za-z0-9_]+$ ]] || return 2
			osf_dnf_preview --noautoremove remove "$instance" || return 1
			local preview="$OSF_DNF_PREVIEW"
			if [[ "$preview" != *$'\nRemoving:'* && "$preview" != "Removing:"* ]]; then
				engine_log "rollback: not removing ${pkg} — dnf's removal preview did not show a plain removal"
				return 1
			fi
			if [[ "$preview" == *"Removing dependent packages"* || "$preview" == *"Removing unused dependencies"* ]]; then
				engine_log "rollback: not removing ${pkg} — dnf would also remove packages that depend on it"
				return 1
			fi
			dnf -y --noautoremove remove "$instance" </dev/null ;;
	esac
}

# osf_running_kernel — `uname -r`; SHCP_UPDATE_RUNNING_KERNEL is the test seam.
osf_running_kernel() {
	printf '%s' "${SHCP_UPDATE_RUNNING_KERNEL:-$(uname -r)}"
}

# osf_deb_holds_running_kernel <pkg> — 0 when the installed package ships the
# running kernel's image or its module tree (linux-image-*, Ubuntu's
# linux-modules-*). An unreadable file list is not proof of anything: 1.
osf_deb_holds_running_kernel() {
	local rel
	rel="$(osf_running_kernel)"
	[[ -n "$rel" ]] || return 1
	dpkg-query -L "$1" 2>/dev/null | grep -qxF \
		-e "/boot/vmlinuz-${rel}" -e "/lib/modules/${rel}" -e "/usr/lib/modules/${rel}"
}

# osf_changelog <pkg> — best-effort changelog text; never fatal (needs the
# network and is display-only, §4.2-3).
osf_changelog() {
	case "$OS_FAMILY" in
		deb) apt-get changelog "$1" 2>/dev/null || true ;;
		rpm) dnf -q changelog "$1" 2>/dev/null || true ;;
	esac
}

# osf_needrestart_ksta — needrestart's kernel verdict on deb, the NEEDRESTART-KSTA
# value of `needrestart -b`: 1 = the running kernel is current, 2 = an
# ABI-compatible upgrade is pending, 3 = a version upgrade is pending, 0 (or no
# line, or no needrestart) = unknown. SHCP_UPDATE_NEEDRESTART_KSTA is the test
# seam, the same shape as SHCP_UPDATE_REBOOT_FILE.
osf_needrestart_ksta() {
	if [[ -n "${SHCP_UPDATE_NEEDRESTART_KSTA:-}" ]]; then
		printf '%s' "$SHCP_UPDATE_NEEDRESTART_KSTA"
		return 0
	fi
	command -v needrestart >/dev/null 2>&1 || { printf '0'; return 0; }
	local ksta
	ksta="$(needrestart -b 2>/dev/null | awk -F': ' '/^NEEDRESTART-KSTA:/{print $2; exit}' || true)"
	printf '%s' "${ksta:-0}"
}

# osf_reboot_detection_available — 0 when a real kernel/libc verdict is
# possible. deb: needrestart (the marker file alone is written only by
# unattended-upgrades' kernel hook or Ubuntu's update-notifier-common, neither of
# which a 1.0 Debian 13/14 box carries); rpm: needs-restarting (dnf-utils).
osf_reboot_detection_available() {
	osf_restart_detection_available
}

# osf_reboot_required — "true"/"false" (§4.4).
#   deb: the /var/run/reboot-required marker OR needrestart's kernel verdict
#        (KSTA 2/3). The marker alone never fires on a 1.0 Debian box (see
#        osf_reboot_detection_available), so a kernel/libc upgrade there used to
#        journal reboot_required=false forever.
#   rpm: `needs-restarting -r`, exit 1 ⇒ reboot needed — and ONLY exit 1. It
#        used to read every non-zero as "reboot required", so a RHEL 10 box
#        without dnf-utils (exit 127: no such binary) journaled
#        reboot_required=true on every run and fired the HIGH notification
#        nightly for a reboot nothing needed. Absent ⇒ "false" plus a log line;
#        `check` carries the same fact as reboot_detection_unavailable.
osf_reboot_required() {
	case "$OS_FAMILY" in
		deb)
			if [[ -f "${SHCP_UPDATE_REBOOT_FILE:-/var/run/reboot-required}" ]]; then
				echo true
				return 0
			fi
			case "$(osf_needrestart_ksta)" in 2|3) echo true ;; *) echo false ;; esac ;;
		rpm)
			if ! command -v needs-restarting >/dev/null 2>&1; then
				engine_log "reboot detection: needs-restarting (dnf-utils) is not installed — whether this host needs a reboot cannot be determined; reboot_required is recorded false"
				echo false
				return 0
			fi
			local rc=0
			needs-restarting -r >/dev/null 2>&1 || rc=$?
			case "$rc" in
				0) echo false ;;
				1) echo true ;;
				*) engine_log "reboot detection: needs-restarting -r exited ${rc} (neither 0 nor 1) — reboot_required is recorded false"; echo false ;;
			esac ;;
	esac
}

# --- boot images: every installed kernel's initramfs carries its modules ------
# SC-783 (shcp-updater#271). A package transaction that runs
# the initramfs generator where the module tree is hidden -- every engine unit set
# ProtectKernelModules=yes until #271 -- does not fail. mkinitramfs prints
# "W: missing /lib/modules/<v>" and writes an initrd with no drivers, depmod errors,
# apt exits 0, and the box runs on until its next boot cannot mount root. It
# bricked the dev VM and left vp102 one reboot from the same.
#
# The units no longer hide the tree, but a clean package-manager exit still proves
# nothing about the image, and boxes updated before the fix can already carry a
# broken one. So every package-capable verb ends with initramfs_gate, which audits
# EVERY installed kernel, not only the ones this run touched: that makes the first
# run of a fixed engine the fleet repair.
#
# The test is driver-agnostic: the image must carry at least one module file under
# lib/modules/<v>/. NOT modules.dep -- a blind mkinitramfs still runs depmod over
# its empty staging tree and packs an empty modules.dep (measured on trixie: 11.8
# MB, modules.dep present, 0 modules). A kernel counts as
# installed when /boot/vmlinuz-<v> AND <modules>/<v>/modules.dep both exist; that
# skips the EL rescue image (it has no module tree) and module dirs a removed
# kernel left behind.
SHCP_UPDATE_BOOT_DIR="${SHCP_UPDATE_BOOT_DIR:-/boot}"
SHCP_UPDATE_MODULES_DIR="${SHCP_UPDATE_MODULES_DIR:-/usr/lib/modules}"
SHCP_UPDATE_MOUNTINFO="${SHCP_UPDATE_MOUNTINFO:-/proc/self/mountinfo}"
SHCP_UPDATE_SELF="${SHCP_UPDATE_SELF:-/usr/sbin/shcp-update}"
INITRAMFS_ERROR=""
INITRAMFS_RESULT='null'

initramfs_image_path() {
	case "$OS_FAMILY" in
		rpm) printf '%s/initramfs-%s.img' "$SHCP_UPDATE_BOOT_DIR" "$1" ;;
		*)   printf '%s/initrd.img-%s' "$SHCP_UPDATE_BOOT_DIR" "$1" ;;
	esac
}

# initramfs_image_ok <version> <image> -- 0 the image carries a module (*.ko,
# *.ko.<compression>) under lib/modules/<version>/; 1 it does not (absent,
# unreadable, or built blind); 2 no lister is installed, so it cannot be judged.
# lsinitramfs prints bare paths, lsinitrd an ls -l listing, so the path is the
# last field of either. index(), not a regex: versions carry '+'. Another kernel's
# modules do not count: an image carrying the wrong tree boots no better.
initramfs_image_ok() {
	local v="$1" image="$2" lister
	[[ -f "$image" && -s "$image" ]] || return 1
	if command -v lsinitramfs >/dev/null 2>&1; then
		lister=lsinitramfs
	elif command -v lsinitrd >/dev/null 2>&1; then
		lister=lsinitrd
	else
		return 2
	fi
	timeout 300 "$lister" "$image" 2>/dev/null | awk -v dir="lib/modules/${v}/" '
		{ p = $NF; i = index(p, dir) }
		i > 0 && (i == 1 || substr(p, i - 1, 1) == "/") && p ~ /\.ko(\.[a-z0-9]+)?$/ { found = 1 }
		END { exit !found }'
}

# initramfs_regenerate <version> <image> -- rebuild one image with whichever
# generator the box uses. Output goes to stderr (the unit journal); stdout is the
# verbs' JSON channel.
initramfs_regenerate() {
	local v="$1" image="$2"
	if command -v update-initramfs >/dev/null 2>&1; then
		if [[ -e "$image" ]]; then
			update-initramfs -u -k "$v" -b "$SHCP_UPDATE_BOOT_DIR" >&2
		else
			update-initramfs -c -k "$v" -b "$SHCP_UPDATE_BOOT_DIR" >&2
		fi
	elif command -v dracut >/dev/null 2>&1; then
		dracut -f "$image" "$v" >&2
	else
		engine_log "initramfs: neither update-initramfs nor dracut is installed -- cannot regenerate ${image}"
		return 1
	fi
}

# initramfs_modules_hidden -- 0 when this process sits in a mount namespace with
# the module tree mounted over, i.e. a unit that still carries ProtectKernelModules.
# That is the one run a fixed engine can still start in: the self-update hop
# re-execs the new engine inside the OLD unit's sandbox. Nothing verified or
# regenerated from in here can be trusted, so initramfs_gate goes outside.
initramfs_modules_hidden() {
	[[ -r "$SHCP_UPDATE_MOUNTINFO" ]] || return 1
	awk -v a="$SHCP_UPDATE_MODULES_DIR" '$5 == a || $5 == "/lib/modules" { hit = 1 } END { exit !hit }' \
		"$SHCP_UPDATE_MOUNTINFO"
}

# The verdict for each image build is cached, one line per /boot/vmlinuz-<v>:
# version, image key, ok|broken|skip. Two readers need it (shcp-updater#273):
#   - `verify-initramfs`, on the monitoring collector's 5-minute timer. lsinitrd
#     takes ~5 s per image on EL10, so an unchanged image is not listed again.
#   - `check` from the panel's units, which still set ProtectKernelModules. With
#     the module tree hidden nothing can be audited, so the verdict of the last
#     audit is all there is, and only for an image that has not changed since.
# verify-initramfs and the post-transaction gate write it. `check` only reads it,
# because check mutates nothing. The gate never reads it: a repair judges the
# image it is looking at.
SHCP_UPDATE_INITRAMFS_CACHE="${SHCP_UPDATE_INITRAMFS_CACHE:-${SHCP_UPDATE_STATE_DIR}/initramfs-audit}"

# initramfs_image_key <image> -- one build of an image: device, inode, size and
# mtime to the nanosecond. Generators write a new file and rename it over the old
# one, so a rebuild changes the key. "absent" when there is no image.
initramfs_image_key() {
	stat -c '%d:%i:%s:%y' -- "$1" 2>/dev/null || printf 'absent'
}

# initramfs_cached_verdict <version> <key> -- the verdict recorded for exactly
# this build of the version's image, or nothing.
initramfs_cached_verdict() {
	[[ -r "$SHCP_UPDATE_INITRAMFS_CACHE" ]] || return 0
	awk -F '\t' -v v="$1" -v k="$2" '$1 == v && $2 == k { print $3; exit }' \
		"$SHCP_UPDATE_INITRAMFS_CACHE" 2>/dev/null || true
}

# initramfs_ensure <check|repair|cached> -- audit every installed kernel's image;
# under repair, regenerate a broken one and re-verify it. Sets INITRAMFS_RESULT
# (journal JSON) and, when an image is still broken, INITRAMFS_ERROR; returns 1
# then. An image that cannot be judged (no lister) is recorded, logged and not
# failed: there is then no generator this engine knows how to drive either.
# cached lists nothing and reads only the cache, for a process that cannot see
# the module tree; a kernel whose image changed since its last audit is
# unverified.
initramfs_ensure() {
	local mode="$1" vmlinuz v image key rc verdict cache=""
	local -a ok=() repaired=() broken=() unverified=()
	INITRAMFS_ERROR=""
	osf_detect_family
	for vmlinuz in "$SHCP_UPDATE_BOOT_DIR"/vmlinuz-*; do
		[[ -f "$vmlinuz" && ! -L "$vmlinuz" ]] || continue
		v="${vmlinuz##*/vmlinuz-}"
		image="$(initramfs_image_path "$v")"
		key="$(initramfs_image_key "$image")"
		if [[ "$mode" == cached ]]; then
			case "$(initramfs_cached_verdict "$v" "$key")" in
				ok)     ok+=("$v") ;;
				broken) broken+=("$v") ;;
				skip)   ;;
				*)      unverified+=("$v") ;;
			esac
			continue
		fi
		if [[ ! -f "${SHCP_UPDATE_MODULES_DIR}/${v}/modules.dep" ]]; then
			cache+="${v}"$'\t'"${key}"$'\tskip\n'
			continue
		fi
		rc=0 verdict=""
		if [[ "$mode" == check ]]; then
			verdict="$(initramfs_cached_verdict "$v" "$key")"
		fi
		case "$verdict" in
			ok)     ;;
			broken) rc=1 ;;
			*)      initramfs_image_ok "$v" "$image" || rc=$? ;;
		esac
		if (( rc == 0 )); then
			ok+=("$v")
			cache+="${v}"$'\t'"${key}"$'\tok\n'
			continue
		elif (( rc == 2 )); then
			engine_log "initramfs: neither lsinitramfs nor lsinitrd is installed -- ${image} not verified"
			unverified+=("$v")
			continue
		fi
		engine_log "initramfs: ${image} carries none of kernel ${v}'s modules -- this box would not boot kernel ${v}"
		if [[ "$mode" == repair ]] && initramfs_regenerate "$v" "$image" \
				&& initramfs_image_ok "$v" "$image"; then
			engine_log "initramfs: regenerated ${image}; it carries kernel ${v}'s modules again"
			repaired+=("$v")
			cache+="${v}"$'\t'"$(initramfs_image_key "$image")"$'\tok\n'
		else
			broken+=("$v")
			cache+="${v}"$'\t'"$(initramfs_image_key "$image")"$'\tbroken\n'
		fi
	done
	if [[ "$mode" != cached && -z "${INITRAMFS_CACHE_READONLY:-}" && -d "${SHCP_UPDATE_INITRAMFS_CACHE%/*}" ]]; then
		printf '%s' "$cache" | atomic_write "$SHCP_UPDATE_INITRAMFS_CACHE" 0600 2>/dev/null || true
	fi
	INITRAMFS_RESULT="$(jq -nc \
		--argjson ok "$(jq -nc '$ARGS.positional' --args ${ok[@]+"${ok[@]}"})" \
		--argjson repaired "$(jq -nc '$ARGS.positional' --args ${repaired[@]+"${repaired[@]}"})" \
		--argjson broken "$(jq -nc '$ARGS.positional' --args ${broken[@]+"${broken[@]}"})" \
		--argjson unverified "$(jq -nc '$ARGS.positional' --args ${unverified[@]+"${unverified[@]}"})" \
		'{ok: $ok, repaired: $repaired, broken: $broken, unverified: $unverified}')"
	if (( ${#broken[@]} > 0 )); then
		local tried=""
		[[ "$mode" == repair ]] && tried=" and regenerating it failed"
		INITRAMFS_ERROR="the initramfs of kernel ${broken[*]} carries no kernel modules${tried} -- the box will not boot that kernel. Regenerate it (update-initramfs -u -k <version>; dracut -f <image> <version> on EL) and verify it before any reboot (shcp-updater#271)"
		engine_log "initramfs: CRITICAL: ${INITRAMFS_ERROR}"
		return 1
	fi
	return 0
}

# initramfs_gate [run-id] -- repair-mode audit, run where the module tree is
# visible, journaled under .initramfs when a run id is given. 0 = every installed
# kernel can boot; 1 = one cannot, with INITRAMFS_ERROR set. From inside a
# sandbox that hides the tree the audit runs in a transient unit instead, which
# systemd starts in the host's mount namespace.
initramfs_gate() {
	local run_id="${1:-}"
	INITRAMFS_ERROR=""
	INITRAMFS_RESULT='null'
	if compgen -G "${SHCP_UPDATE_BOOT_DIR}/vmlinuz-*" >/dev/null && initramfs_modules_hidden; then
		engine_log "initramfs: the module tree is hidden from this unit (a sandbox from before shcp-updater#271) -- auditing from a transient unit outside it"
		if systemd-run --wait --collect --quiet --service-type=oneshot -p TimeoutStartSec=1800 \
				--setenv=SHCP_UPDATE_BOOT_DIR="$SHCP_UPDATE_BOOT_DIR" \
				--setenv=SHCP_UPDATE_MODULES_DIR="$SHCP_UPDATE_MODULES_DIR" \
				"$SHCP_UPDATE_SELF" verify-initramfs --repair --outside >&2; then
			INITRAMFS_RESULT='{"outside": true, "broken": []}'
		else
			INITRAMFS_RESULT='{"outside": true, "broken": null}'
			INITRAMFS_ERROR="the initramfs audit outside the unit sandbox failed or found an image it could not repair -- see journalctl for the transient shcp-update verify-initramfs unit; do not reboot until every /boot image is verified (shcp-updater#271)"
			engine_log "initramfs: CRITICAL: ${INITRAMFS_ERROR}"
		fi
	else
		initramfs_ensure repair || true
	fi
	if [[ -n "$run_id" ]]; then
		journal_update "$run_id" '.initramfs = $r' --argjson r "$INITRAMFS_RESULT" || true
	fi
	[[ -z "$INITRAMFS_ERROR" ]]
}

# run_initramfs_verdict <run-id> -- after run_stages, whatever it ended in. A run
# that leaves an unbootable kernel behind is FAILED, however healthy the panel is:
# the next reboot takes the box down. Parked runs were gated by park_for_reboot.
run_initramfs_verdict() {
	local run_id="$1" jf status prior
	jf="$(journal_path "$run_id")"
	status="$(jq -r '.status // empty' "$jf" 2>/dev/null || true)"
	[[ "$status" == awaiting_reboot ]] && return 0
	initramfs_gate "$run_id" && return 0
	prior="$(jq -r '.error // empty' "$jf" 2>/dev/null || true)"
	[[ "$prior" == *"$INITRAMFS_ERROR"* ]] && return 1   # park_for_reboot already said so
	journal_finish "$run_id" failed "${INITRAMFS_ERROR} (the run itself ended ${status:-unknown}${prior:+: ${prior}})"
	panel_import_run "$run_id"
	return 1
}

cmd_verify_initramfs() {
	local mode=check outside=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--repair)  mode=repair; shift ;;
			--outside) outside=1; shift ;;
			--json)    JSON_OUTPUT=1; shift ;;
			*) die "verify-initramfs: unknown argument: $1" ;;
		esac
	done
	json_claim_stdout
	local rc=0
	if (( outside )); then
		# Started by initramfs_gate from a unit holding the run lock.
		initramfs_modules_hidden && die "verify-initramfs --outside: the module tree is still hidden"
		initramfs_ensure "$mode" || rc=1
	elif [[ "$mode" == repair ]]; then
		acquire_lock || die "another shcp-update run holds ${SHCP_UPDATE_LOCK}"
		initramfs_gate || rc=1
	else
		initramfs_modules_hidden && die "verify-initramfs: the module tree is hidden from this process; run it outside the unit sandbox"
		initramfs_ensure check || rc=1
	fi
	if [[ $JSON_OUTPUT -eq 1 ]]; then
		jq -nc --argjson r "$INITRAMFS_RESULT" --arg e "$INITRAMFS_ERROR" \
			'$r + {error: (if $e == "" then null else $e end)}' >&"$JSON_FD"
	else
		log "initramfs: $(jq -c . <<<"$INITRAMFS_RESULT")"
	fi
	return "$rc"
}

# osf_restart_detector — the external binary osf_restart_services depends on.
# Named separately because its ABSENCE is indistinguishable from "nothing needs
# restarting" once the pipeline below has run: the not-found diagnostic goes to
# the already-redirected fd 2, the subshell exits 127, awk reads EOF and exits
# 0, so the pipeline's status is awk's and `|| true` never even fires. Callers
# MUST check this before believing an empty result.
osf_restart_detector() {
	case "$OS_FAMILY" in
		deb) printf 'needrestart' ;;
		rpm) printf 'needs-restarting' ;;
	esac
}

# osf_restart_detection_available — 0 when the detector is present.
osf_restart_detection_available() {
	local d; d="$(osf_restart_detector)"
	[[ -n "$d" ]] && command -v "$d" >/dev/null 2>&1
}

# osf_web_server_unit — the systemd unit name of the web server, resolved by
# OS family exactly like osf_restart_detector: apache2 on deb, httpd on rpm.
# Never hardcode the name at a call site — stage_restart uses this to know which
# restart to withhold when the run's own vhost republish journaled a broken
# config (SC-573).
osf_web_server_unit() {
	case "$OS_FAMILY" in
		deb) printf 'apache2' ;;
		rpm) printf 'httpd' ;;
	esac
}

# osf_restart_services — service unit names whose running processes still use
# deleted/old libraries (§4.4). deb: `needrestart -b` NEEDRESTART-SVC lines;
# EL: `needs-restarting -s`. Emitted unfiltered — the caller intersects with the
# shcp-managed allow-list or the validated Messenger worker inventory.
osf_restart_services() {
	case "$OS_FAMILY" in
		deb) needrestart -b 2>/dev/null | awk -F': ' '/^NEEDRESTART-SVC:/{print $2}' || true ;;
		rpm) needs-restarting -s 2>/dev/null | awk 'NF{print $1}' || true ;;
	esac
}

# Services the engine may restart inside the window (§4.4 allow-list). The
# validated Messenger worker inventory is the other permitted set. Anything
# outside both is the operator's own and is only reported, never touched.
#
# An entry may be a GLOB. PHP-FPM's unit carries the PHP version in its name —
# php8.5-fpm on deb, php85-php-fpm (Remi) on rpm — and the digits move with
# every PHP series, so a literal list went stale the moment PHP bumped and a
# libssl/libxml security update that flagged the FPM pool landed in
# needs_manual only, leaving the patched library unloaded in every tenant's
# PHP worker. Pattern entries are matched by osf_service_is_managed only;
# walkers that need CONCRETE unit names (restore_baseline_managed_daemons,
# health_unit_list) use managed_services_concrete and skip them.
#
# PostgreSQL is not in the list. A box may run an operator's own cluster that
# SHCP never set up, so the one PostgreSQL unit the engine manages is the one the
# installer's facts file names, and only while the feature is enabled
# (pgsql_managed_unit); none without that file.
SHCP_MANAGED_SERVICES=(apache2 httpd dovecot postfix pdns pdns-recursor rspamd valkey valkey-server mariadb shcpd shcp-worker 'php*-fpm' 'php*-php-fpm')

# osf_service_is_managed <unit> [<pg_unit>] — 0 when <unit> matches the allow-list
# or is <pg_unit>, the facts file's PostgreSQL unit (empty: none).
osf_service_is_managed() {
	local want="$1" pg="${2:-}" s
	for s in "${SHCP_MANAGED_SERVICES[@]}"; do
		# Unquoted on purpose: the allow-list entry is a pattern, the candidate
		# (quoted) is the literal unit name from needrestart. The entries are
		# engine constants, never input, so the glob cannot be widened from outside.
		# shellcheck disable=SC2053
		[[ "$want" == $s ]] && return 0
	done
	[[ -n "$pg" && "$want" == "$pg" ]]
}

# is_tenant_app_unit <unit> — 0 when <unit> is exactly a managed tenant
# application, shcp-app@<16 chars of [a-z2-7]>.service, or the same name with
# no suffix (systemd resolves a bare name to its .service, and the rpm detector's
# output is matched suffix-free elsewhere in this stage). The key is the panel's
# CSPRNG output and never anything else, so the grammar is closed: a near miss
# (another length, another alphabet, .socket, the bare template) is NOT one and
# takes the caller's existing path. Fails closed by construction.
is_tenant_app_unit() {
	[[ "$1" =~ ^shcp-app@[a-z2-7]{16}(\.service)?$ ]]
}

# service_is_database <unit> [<pg_unit>] — 0 for a database server, whose restart
# drops every client connection, so update.restart.allow_db can withhold it.
service_is_database() {
	[[ "$1" == mariadb ]] || [[ -n "${2:-}" && "$1" == "$2" ]]
}

# managed_services_concrete — the allow-list minus its glob entries, plus the
# managed PostgreSQL unit (pgsql_managed_unit), one per line, for callers that
# name units rather than match them.
managed_services_concrete() {
	local s
	for s in "${SHCP_MANAGED_SERVICES[@]}"; do
		case "$s" in *[*?[]*) continue ;; esac
		printf '%s\n' "$s"
	done
	pgsql_managed_unit
}

# pgsql_facts_unit — the server unit named by the PostgreSQL facts file, without
# .service; nothing when the file is absent (the feature is not installed). The
# installer writes it root:root 0644 as the last act of a verified setup. The unit
# it names is one the engine restarts and diffs for health, so a file the engine
# cannot trust contributes nothing and is logged: not a regular root-owned file
# in a root-owned directory, group/other-writable, unparseable, a schema other
# than 1, or a unit not of the family's server shape.
PG_FACTS_FILE="${SHCP_UPDATE_PG_FACTS:-/etc/shcp/postgresql.json}"
pgsql_facts_unit() {
	local f="$PG_FACTS_FILE" unit
	[[ -e "$f" || -L "$f" ]] || return 0
	# stat does not follow links and a symlink reports 0777, so the mode test
	# refuses one; -f keeps jq from blocking on a FIFO.
	if [[ ! -f "$f" ]] || ! path_uid0_not_writable "$f" || ! path_uid0_not_writable "$(dirname -- "$f")"; then
		engine_log "postgresql: IGNORING ${f}: not a regular file owned by uid ${SHCP_UPDATE_EXPECT_UID} in a directory it owns, or group/other-writable; no PostgreSQL unit is managed"
		return 0
	fi
	unit="$(jq -r 'if .schema_version == 1 then .unit else empty end' "$f" 2>/dev/null)" || unit=""
	case "$OS_FAMILY" in
		deb) [[ "$unit" =~ ^postgresql@[0-9]+-main\.service$ ]] || unit="" ;;
		rpm) [[ "$unit" == postgresql.service ]] || unit="" ;;
		*) unit="" ;;
	esac
	if [[ -z "$unit" ]]; then
		engine_log "postgresql: IGNORING ${f}: unparseable, not schema_version 1, or its unit is not the ${OS_FAMILY:-unknown} family's PostgreSQL server; no PostgreSQL unit is managed"
		return 0
	fi
	printf '%s\n' "${unit%.service}"
}

# The PostgreSQL feature's two root-written records besides the facts file
# (PG_FACTS_FILE): the marker is the admin's request, the cluster record says
# the installer has created a cluster on this box.
PG_MARKER_FILE="${SHCP_UPDATE_PG_MARKER:-/etc/shcp/features/postgresql}"
PG_CLUSTER_FILE="${SHCP_UPDATE_PG_CLUSTER:-/etc/shcp/postgresql/cluster}"

# pgsql_record_state <path> — absent, trusted (a regular file owned by uid 0 in a
# directory uid 0 owns, neither group/other-writable) or untrusted (anything
# else, a symlink included: stat does not follow it and reports 0777).
pgsql_record_state() {
	if [[ ! -e "$1" && ! -L "$1" ]]; then
		printf 'absent\n'
	elif [[ -f "$1" && ! -L "$1" ]] && path_uid0_not_writable "$1" \
		&& path_uid0_not_writable "$(dirname -- "$1")"; then
		printf 'trusted\n'
	else
		printf 'untrusted\n'
	fi
}

# pgsql_managed_unit — the facts file's unit while the admin wants PostgreSQL
# (a trusted marker), else nothing. A disabled cluster keeps its facts file, and
# a stopped server it names must not be restarted, health-diffed or brought
# back by the baseline restore.
pgsql_managed_unit() {
	local unit
	unit="$(pgsql_facts_unit)"
	[[ -n "$unit" ]] || return 0
	case "$(pgsql_record_state "$PG_MARKER_FILE")" in
		trusted) printf '%s\n' "$unit" ;;
		untrusted) engine_log "postgresql: IGNORING ${PG_MARKER_FILE}: not a regular file owned by uid ${SHCP_UPDATE_EXPECT_UID} in a directory it owns, or group/other-writable; ${unit} is not managed" ;;
	esac
}

# blacklist_read — the raw update.blacklist JSON on stdout. 0 when read (empty
# when unset), 1 when the panel DB exists but the read FAILED: a busy database
# (SQLITE_BUSY past sqlite3's default 0 ms wait), an unwritable -shm sidecar
# (WAL mode needs it even for a -readonly open), a corrupt file, a malformed
# value. A DB with no system_settings table yet is "unset", not a failure: the
# panel has never written a setting there, so there is no blacklist to lose.
#
# Split out from blacklist_pkgs so a caller that runs in THIS shell (not the
# process substitution apt_target_pkgs reads the list through) can learn that
# the read failed. The failure is deliberately NOT a refusal (AD-6: a broken DB
# must never stop security patching), but it is no longer silent either: every
# operator-excluded package is upgraded on this run, and the operator hears so
# from the run log, the stage result (blacklist_unreadable) and `check`.
BLACKLIST_READ_DETAIL=""
blacklist_read() {
	BLACKLIST_READ_DETAIL=""
	[[ -f "$SHCP_UPDATE_DB" ]] || return 0
	local raw rc=0
	raw="$(sqlite3 -readonly "$SHCP_UPDATE_DB" \
		"SELECT value FROM system_settings WHERE key = 'update.blacklist';" 2>&1)" || rc=$?
	if (( rc != 0 )); then
		if [[ "$raw" == *"no such table: system_settings"* ]]; then
			return 0
		fi
		BLACKLIST_READ_DETAIL="sqlite exit ${rc}: $(printf '%s' "$raw" | tr -cd '\40-\176' | cut -c1-200)"
		engine_log "update.blacklist could not be read from ${SHCP_UPDATE_DB} (${BLACKLIST_READ_DETAIL}) — NO blacklist is applied to this run's work set; every operator-excluded package is eligible for upgrade"
		return 1
	fi
	printf '%s' "$raw"
}

# blacklist_pkgs — validated package names the operator has excluded from every
# scope (update.blacklist). A malformed entry is dropped, not fatal; an
# unreadable list yields NO names (fail toward patching), logged by blacklist_read.
blacklist_pkgs() {
	local raw
	raw="$(blacklist_read)" || return 0
	[[ -n "$raw" ]] || return 0
	jq -r 'if type == "array" then .[] else empty end' <<<"$raw" 2>/dev/null | while IFS= read -r p; do
		osf_valid_pkg "$p" && printf '%s\n' "$p"
	done
}

# baseline_json — installed packages as a {name: version} JSON object (deb) or
# an instance array (rpm). Non-zero (pipefail) when osf_baseline could not read
# the package database; the output is then the EMPTY shape, which a caller must
# never journal as a baseline (see stage_preflight / baseline_json_empty).
baseline_json() {
	if [[ "$OS_FAMILY" == "rpm" ]]; then
		osf_baseline | jq -R -s '
			split("\n") | map(select(length > 0) | split("\t"))
			| map(select(length == 8) | {epoch:(.[0]|tonumber), name:.[1], version:.[2], release:.[3], arch:.[4], nevra:.[5], installonly:(.[6]=="true"), source_rpm:.[7]})
			| unique_by([.nevra,.arch]) | sort_by(.name,.arch,.epoch,.version,.release)'
	else
		osf_baseline | jq -R -s '
			split("\n") | map(select(length > 0) | split("\t"))
			| map(select(length == 2)) | map({(.[0]): .[1]}) | add // {}'
	fi
}

# baseline_json_empty <json> — 0 when the baseline names no package at all
# ({} or []), the shape an unreadable package database produces.
baseline_json_empty() {
	jq -e 'length == 0' <<<"$1" >/dev/null 2>&1
}

# apt_target_pkgs <scope> — the package NAMES to upgrade, minus the blacklist.
# `security` → security-origin set; `packages`/`all` → all upgradable; `panel`
# → nothing (OS packages are not panel work). Validated names only (SC-002),
# so a garbage name from the package manager can never reach osf_apply's argv.
apt_target_pkgs() {
	local scope="$1"
	[[ "$scope" == "panel" ]] && return 0
	local -A deny=()
	local p
	while IFS= read -r p; do [[ -n "$p" ]] && deny["$p"]=1; done < <(blacklist_pkgs)
	local src=osf_upgradable
	[[ "$scope" == "security" ]] && src=osf_security_pkgs
	# A series jump uses the full upgrade work set. The apply still previews each
	# target under the #321 removal policy; --only-upgrade install pulls new
	# dependencies.
	[[ "$scope" == "series" && "$OS_FAMILY" == "deb" ]] && src=osf_upgradable_full
	local pairs
	pairs="$("$src" | while IFS= read -r p; do
		[[ -n "$p" ]] || continue
		local name="$p" target="$p"
		if [[ "$scope" == "security" ]]; then
			name="${p%%$'\t'*}"; target="${p#*$'\t'}"
			[[ "$name" != "$target" ]] || { engine_log "ignoring security candidate without an exact target: ${p}"; continue; }
		fi
		if ! osf_valid_pkg "$name"; then
			engine_log "ignoring malformed package name from the package manager: ${name}"
			continue
		fi
		[[ -n "${deny[$name]:-}" ]] && continue
		printf '%s\t%s\n' "$name" "$target"
	done)"
	[[ -n "$pairs" ]] || return 0
	if [[ "$scope" != "security" ]]; then
		awk -F'\t' '{print $2}' <<<"$pairs"
		return 0
	fi
	local -a candidates
	mapfile -t candidates <<<"$pairs"
	local safe target pair
	safe="$(osf_security_safe_targets "${candidates[@]}")"
	while IFS= read -r target; do
		[[ -n "$target" ]] || continue
		for pair in "${candidates[@]}"; do
			[[ "${pair#*$'\t'}" == "$target" ]] || continue
			osf_security_hold_cleared "${pair%%$'\t'*}" "$target"
			break
		done
		printf '%s\n' "$target"
	done <<<"$safe"
}

# security_apply_downgrades — (deb) the packages a security apply would DOWNGRADE,
# one per line. The security target set (apt_target_pkgs security) is already
# filtered to versions STRICTLY NEWER than installed (SC-319/#220), so none of the
# NAMED targets is ever a downgrade. But pinning those exact versions can still
# force apt to move a DEPENDENCY backward, and osf_apply (-y, deliberately no
# --allow-downgrades — a security run never moves a package backward) then refuses
# the whole batch. Left unnamed that stalls shcp-update-security.timer every night
# with a generic "apt apply failed" (shcp-updater#241). Simulate the exact apply
# osf_apply would run (`apt-get -s --only-upgrade install <targets>`, offline — -s
# fetches nothing) and print the packages apt lists under DOWNGRADED, so cmd_check
# can raise a NAMED blocker BEFORE the timer run fails. Empty target set → nothing
# (a box already ahead of its security suite has no security work — the common
# transient state where the ordinary suite leads a point release).
security_apply_downgrades() {
	[[ "$OS_FAMILY" == "deb" ]] || return 0
	local -a targets=()
	local t
	while IFS= read -r t; do [[ -n "$t" ]] && targets+=("$t"); done < <(apt_target_pkgs security)
	[[ ${#targets[@]} -gt 0 ]] || return 0
	# Only the DOWNGRADED block's package names: apt lists them indented under the
	# header until the numeric summary line. A dependency-forced downgrade is what
	# the real transaction refuses, so this is exactly the set to name.
	apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 --only-upgrade install "${targets[@]}" 2>/dev/null \
		| awk '
			/^The following packages will be DOWNGRADED:/ { grab = 1; next }
			grab && /^[[:space:]]/ { for (i = 1; i <= NF; i++) print $i; next }
			grab { grab = 0 }
		' || true
}

# journal_changed_json <run_id> — the before/after package diff for §5.4
# packages.changed: a package whose version moved, appeared (to non-null), or
# was REMOVED (to: null) — the removed set matters so a rollback can restore it.
journal_changed_json() {
	if [[ "$OS_FAMILY" == "rpm" ]]; then
		jq -c '
			.packages.before as $b | .packages.after as $a
			| if (($b|type)=="array" and ($a|type)=="array") then
				def key: [.name,.arch];
				def evr: ((if .epoch == 0 then "" else (.epoch|tostring)+":" end)+.version+"-"+.release);
				[ (($b+$a)|map(select(.installonly|not)|key)|unique[]) as $k
				  | (($b|map(select((key)==$k and (.installonly|not)))|first)//null) as $old
				  | (($a|map(select((key)==$k and (.installonly|not)))|first)//null) as $new
				  | select(($old.nevra//null)!=($new.nevra//null))
				  | {pkg:(($old//$new).name),arch:(($old//$new).arch),from:(if $old then ($old|evr) else null end),to:(if $new then ($new|evr) else null end),from_instance:$old,to_instance:$new,installonly:false} ]
				+ [ $b[]|select(.installonly) as $old|select(any($a[];.nevra==$old.nevra and .arch==$old.arch)|not)
				    | {pkg:$old.name,arch:$old.arch,from:($old|evr),to:null,from_instance:$old,to_instance:null,installonly:true} ]
				+ [ $a[]|select(.installonly) as $new|select(any($b[];.nevra==$new.nevra and .arch==$new.arch)|not)
				    | {pkg:$new.name,arch:$new.arch,from:null,to:($new|evr),from_instance:null,to_instance:$new,installonly:true} ]
			else [] end' "$(journal_path "$1")"
		return
	fi
	jq -c '
		.packages.before as $b | .packages.after as $a
		| [ ($a | to_entries[] | select(($b[.key] // null) != .value)
			  | {pkg: .key, from: ($b[.key] // null), to: .value}),
			($b | to_entries[] | select(($a[.key] // null) == null)
			  | {pkg: .key, from: .value, to: null}) ]
	' "$(journal_path "$1")"
}

# unattended_upgrades_armed — is a SECOND security-update mechanism live on this
# host? (AD-6: exactly one.) ONE probe, because the two callers used to disagree
# and both were wrong in ways measured on a real box:
#
#   - `systemctl is-active unattended-upgrades.service` is not a liveness test.
#     On Debian that unit is `unattended-upgrade-shutdown --wait-for-signal`,
#     Description "Unattended Upgrades Shutdown" — a shutdown blocker that is
#     permanently ACTIVE wherever the package is installed. It says nothing
#     about whether an upgrade is running, or even armed.
#
#   - `systemctl is-active apt-daily-upgrade.timer` is worse: that timer ships
#     with the APT package, NOT with unattended-upgrades, and is enabled on
#     every stock Debian/Ubuntu host. Testing it latched this blocker forever —
#     on a migrated host, and on a fresh install that never had u-u at all.
#
# The honest question is whether the unattended-upgrades PACKAGE is installed
# and has not been switched off, so that is what this asks. It goes false the
# moment the package is purged, which is what makes the blocker clearable.
unattended_upgrades_armed() {
	[[ "$OS_FAMILY" == "deb" ]] || return 1
	command -v dpkg-query >/dev/null 2>&1 || return 1

	local status
	status="$(dpkg-query -W -f '${Status}' unattended-upgrades 2>/dev/null || true)"
	[[ "$status" == *"install ok installed"* ]] || return 1

	# APT::Periodic::Unattended-Upgrade "0" disarms it without removing it, which
	# is a legitimate end state: one mechanism, ours. Treat it as not-armed.
	if command -v apt-config >/dev/null 2>&1; then
		local periodic
		periodic="$(apt-config shell periodic APT::Periodic::Unattended-Upgrade 2>/dev/null || true)"
		[[ "$periodic" == *"='0'"* ]] && return 1
	fi

	# A MASKED apt-daily-upgrade.timer disarms it too, and that is the state every
	# fresh install is born in (SC-319, shcp-installer#742). The installer masks the
	# timer and never installs u-u itself, but it does not purge one the image
	# brought along: Debian cloud images ship u-u installed with Unattended-Upgrade
	# "1". Asking only about the package therefore latched this blocker on every
	# such box, and nothing would ever clear it short of an operator purging u-u.
	#
	# Masked is the one state of that timer that answers the question. On a systemd
	# host (the only kind SHCP runs on) its service, `apt.systemd.daily install`, is
	# the only path that runs an unattended INSTALL: apt-daily.service's `update`
	# mode runs u-u --download-only, and /etc/cron.daily/apt-compat exits when
	# systemd is running. Enabled or active proves nothing, because the timer ships
	# with APT (see above), so the timer alone never arms this; the package test
	# still decides. The one remaining install path is
	# Unattended-Upgrade::InstallOnShutdown, which unattended-upgrades.service
	# honours without any timer, so with that set the package is still armed.
	if command -v systemctl >/dev/null 2>&1 \
			&& [[ "$(systemctl is-enabled apt-daily-upgrade.timer 2>/dev/null || true)" == masked* ]]; then
		local on_shutdown=""
		if command -v apt-config >/dev/null 2>&1; then
			on_shutdown="$(apt-config shell on_shutdown Unattended-Upgrade::InstallOnShutdown 2>/dev/null || true)"
		fi
		on_shutdown="${on_shutdown,,}"
		[[ "$on_shutdown" =~ =\'(true|yes|on|1)\' ]] || return 1
	fi
	return 0
}

# systemd_unit_armed <unit> — enabled (persists across boot) or currently active,
# and NOT masked. is-enabled decides first: enabled/enabled-runtime is armed,
# masked is never armed (even if somehow active). Anything else (disabled, static,
# indirect, generated, absent) is armed only if it is running right now.
systemd_unit_armed() {
	local unit="$1" enabled
	enabled="$(systemctl is-enabled "$unit" 2>/dev/null || true)"
	case "$enabled" in
		enabled|enabled-runtime) return 0 ;;
		masked|masked-runtime) return 1 ;;
	esac
	[[ "$(systemctl is-active "$unit" 2>/dev/null || true)" == active ]]
}

# dnf_automatic_applies — does dnf-automatic's config arm an APPLY? The file has
# exactly one apply_updates key (its [commands] section); a commented line never
# matches the anchored leading-whitespace pattern. Unreadable/absent reads as
# no-apply: the install timer is the unconditional apply path, and a legacy timer
# with no readable apply directive is not PROVEN to apply.
DNF_AUTOMATIC_CONF="${SHCP_DNF_AUTOMATIC_CONF:-/etc/dnf/automatic.conf}"
dnf_automatic_applies() {
	local val
	[[ -r "$DNF_AUTOMATIC_CONF" ]] || return 1
	val="$(sed -n 's/^[[:space:]]*apply_updates[[:space:]]*=[[:space:]]*//p' "$DNF_AUTOMATIC_CONF" 2>/dev/null \
		| tail -n 1 | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
	[[ "$val" == yes || "$val" == true || "$val" == 1 ]]
}

# dnf_automatic_armed — is dnf-automatic armed to APPLY updates on this EL host?
# (AD-6: exactly one applier.) The rpm-family counterpart of
# unattended_upgrades_armed. Two timers apply; the other two never do:
#   - dnf-automatic-install.timer ALWAYS applies (its service forces an install),
#     so an enabled/active install timer is armed regardless of config.
#   - dnf-automatic.timer (the legacy single timer) applies only when
#     apply_updates=yes; with it no/unset that timer merely downloads or notifies,
#     which is not a second APPLIER.
#   - dnf-automatic-download.timer and dnf-automatic-notifyonly.timer never apply
#     and are deliberately NOT probed — mirroring the deb side leaving
#     apt-daily.timer (index refresh only) alone.
# A MASKED apply timer reads as not-armed, which is the state
# enable_security_updates_rpm leaves a fresh EL box in (it masks the apply timers),
# so this clears once the host is back to one applier.
dnf_automatic_armed() {
	[[ "$OS_FAMILY" == "rpm" ]] || return 1
	command -v systemctl >/dev/null 2>&1 || return 1

	systemd_unit_armed dnf-automatic-install.timer && return 0
	systemd_unit_armed dnf-automatic.timer && dnf_automatic_applies && return 0
	return 1
}

# second_applier_armed — is a DISTRO auto-updater armed to APPLY updates beside
# the engine on THIS host? (AD-6: exactly one applier.) Family-dispatched so the
# two callers — stage_preflight and cmd_check — ask the one honest question and
# cannot drift apart (the disagreement the shared unattended_upgrades_armed
# predicate was introduced to kill). deb asks about unattended-upgrades, rpm about
# dnf-automatic.
second_applier_armed() {
	case "$OS_FAMILY" in
		deb) unattended_upgrades_armed ;;
		rpm) dnf_automatic_armed ;;
		*)   return 1 ;;
	esac
}

# second_applier_id — the check/blocker id for the family's distro updater. The
# ids are DISTINCT because the remedy is: unattended_upgrades_active is cleared
# by masking apt-daily-upgrade.timer and purging the deb-only unattended-upgrades
# package; dnf_automatic_active is cleared by masking dnf-automatic's apply
# timers, with no package to purge.
second_applier_id() {
	case "$OS_FAMILY" in
		rpm) printf 'dnf_automatic_active' ;;
		*)   printf 'unattended_upgrades_active' ;;
	esac
}

# second_applier_detail — operator-facing guidance, family-specific. Shared by the
# check blocker and the preflight warning so the two never phrase it differently.
second_applier_detail() {
	case "$OS_FAMILY" in
		rpm) printf 'dnf-automatic is armed to apply updates (dnf-automatic-install.timer, or dnf-automatic.timer with apply_updates=yes) — a second security-update mechanism beside the engine. Mask its apply timers (systemctl disable --now dnf-automatic-install.timer dnf-automatic.timer, or set apply_updates=no in %s) so the engine is the only applier (AD-6)' "$DNF_AUTOMATIC_CONF" ;;
		*)   printf 'unattended-upgrades is installed and armed — a second security-update mechanism beside the engine. The SHCP installer never installs it, so it was added by hand or by the image: mask its apply timer (systemctl mask --now apt-daily-upgrade.timer) and remove the package (apt purge unattended-upgrades) so the engine is the only applier (AD-6)' ;;
	esac
}

# --- panel release layout (UPD-3; plan §4.12 is the cross-repo contract) -------
#   /opt/shcp                 symlink into the active release, root-owned
#   /opt/shcp-releases/<ver>/ release trees
#   /etc/shcp/panel.env       canonical env; <release>/.env.local symlinks to it
#   /var/lib/shcp/panel-state/ shared mutable state, survives every flip
# Every 1.0 box is BORN in this layout: the installer creates it and the engine
# only ever flips the symlink between release trees inside it. There is no older
# layout to convert from (no alpha->1.0 upgrade path), so a tree that does not
# match is foreign or tampered and the stages refuse rather than repair it.
PANEL_LINK="${SHCP_UPDATE_PANEL_LINK:-/opt/shcp}"
RELEASES_DIR="${SHCP_UPDATE_RELEASES_DIR:-/opt/shcp-releases}"
PANEL_VAR_DIR="${SHCP_UPDATE_PANEL_VAR:-/var/lib/shcp/panel-state}"
# shcp both families (installer SYS_GROUP_SHCP). Overridable for the bash suite.
SHCP_SYS_GROUP="${SHCP_UPDATE_SYS_GROUP:-shcp}"
PANEL_KV_DIR="${SHCP_UPDATE_KV_DIR:-/var/lib/shcp/kv}"
# Where the installer vendored the AutoSSL recovery tools' dependency closure
# (SC-415/SC-507). One path for both families — our own directory, not a
# distro-owned name, so it takes no os/ overlay. stage_finalize re-vendors this
# closure so it tracks the panel a successful update installed; the .vendored
# stamp here is the contract shcp-installer's shcp_vendor_drift_reason reads.
SHCP_LIBEXEC_DIR="${SHCP_UPDATE_LIBEXEC_DIR:-/usr/libexec/shcp}"
# Where the installer drops the console recovery-tool ENTRYPOINTS
# (/usr/sbin/shcp-{hostname,cert}, recovery_tools_setup). One path both families.
# It, together with SHCP_LIBEXEC_DIR, is the closed allow-list of roots the UPD-14
# payload-backfill applier may write a declared installer file under (SC-064/207
# gives the CONTENT trust; this bounds the DESTINATION — the roots are engine-owned
# so a panel-writable declaration cannot name any other location).
SHCP_SBIN_DIR="${SHCP_UPDATE_SBIN_DIR:-/usr/sbin}"
PANEL_ENV="${SHCP_UPDATE_PANEL_ENV:-/etc/shcp/panel.env}"
# Mirrors what the installer sets on a fresh box: /etc/shcp root:root 0755,
# panel.env root:shcp 0640 (functions/shcp.sh configure_shcp_application). The
# group must be able to read it — shcpd, the workers and every timer oneshot
# run as shcp — and nothing outside that group ever may.
PANEL_ENV_OWNER="${SHCP_UPDATE_PANEL_ENV_OWNER:-root:shcp}"
# Shared panel.env write rendezvous (SC-480 residual #21 /
# SC-538). This reconcile appender is not the
# only writer of panel.env: the installer's hostname rename rewrites it via
# temp+rename and the panel's restore worker merges into it, neither under our
# UPDATE run flock. A rename/merge that clobbered our append would silently drop
# a freshly minted secret. All writers now serialise on ONE advisory lock file,
# flock(2)'d identically from bash here and from the panel's Symfony Lock. It is
# a NAMED lock, not the env file, so it is one stable path whichever route a
# writer takes to the env (panel.env, or a release's .env.local symlink to it).
# Installer-created root:root
# 0640 in the root-owned /etc/shcp; we ensure-and-refuse a symlinked/non-root
# lock rather than flock through it.
PANEL_ENV_LOCK="${SHCP_UPDATE_PANEL_ENV_LOCK:-/etc/shcp/panel.env.lock}"
PANEL_ENV_LOCK_WAIT="${SHCP_UPDATE_PANEL_ENV_LOCK_WAIT:-30}"
PANEL_ENV_LOCK_FD=""
# kv is shared too, but its installer-owned canonical path is /var/lib/shcp/kv rather than panel-state.
PANEL_VAR_SHARED=(sessions log lock data node-mtls kv)

MANIFEST_URL="${SHCP_UPDATE_MANIFEST_URL:-https://repo.shcp.dev/releases/shcp-base-versions.json}"
RELEASE_KEYRING="${SHCP_UPDATE_KEYRING:-/usr/share/keyrings/shcp-release-keyring.gpg}"
RELEASE_FPR="${SHCP_UPDATE_RELEASE_FPR:-53D8134D99A248002B320B24B0A2E16C763A1BFF}"

# --- product seam (EP-4, SC-614) --------------------------------------------
# The engine teaches ONE binary more than one product: the panel `shcp`, the
# DNS-only edition `shcp-dns`, and the self-hosted billing app `shmp`. Which
# product THIS box is must be resolved from authoritative ON-BOX state and NEVER
# from a manifest, a request field, or any network-supplied value. A
# network-steerable product is a confused-deputy path to installing another
# product's artifact as root, or (an app-scoped product coerced full-box) to a
# second apt owner racing the box's package manager against SC-319. SC-614.
#
# Resolution is FAIL CLOSED, the OPPOSITE failure direction from
# osf_detect_family (which defaults to deb): an absent, ambiguous, or
# inconsistent product REFUSES with no apply — it never picks one, and there is
# deliberately NO `${SHCP_UPDATE_PRODUCT:-shcp}` default. The product value comes
# only from the marker below cross-checked against the package database.
#
# The marker is a root-owned /etc/shcp-product written ONCE by the installer and
# touched by no update path, holding the product id on a single line. Its PATH is
# a test seam (point it at a fixture); its VALUE is never taken from the
# environment.
PRODUCT_MARKER_FILE="${SHCP_UPDATE_PRODUCT_MARKER:-/etc/shcp-product}"
# The resolved product. Empty until resolve_product succeeds -- never defaulted.
PRODUCT=""
PRODUCT_RESOLVE_ERROR=""
# Every product the engine knows.
PRODUCT_IDS=(shcp shcp-dns shmp)

# product_footprint <product> -- full-box | app-scoped (AD-E7). A full-box
# product owns the box package manager, the security pocket, unattended-upgrades,
# daemon pins and reboot detection; an app-scoped product owns none of those
# (SC-319: exactly one apt owner per box) and touches only its own app tarball,
# DB and runtime.
product_footprint() {
	case "$1" in
		shcp|shcp-dns) printf 'full-box' ;;
		shmp)          printf 'app-scoped' ;;
	esac
}

# product_distinct_pkg <product> -- the DEB/RPM package that uniquely marks the
# product on the box, used to cross-check the marker. The panel (`shcp`) ships as
# a tarball to /opt/shcp and has NO distinct package of its own: its identity is
# the marker PLUS the absence of every layered product's package. Only the two
# layered products carry a distinct package name.
product_distinct_pkg() {
	case "$1" in
		shcp-dns) printf 'shcp-dns' ;;
		shmp)     printf 'shmp-base' ;;
		*)        printf '' ;;
	esac
}

# The product-specific inputs SC-614 requires be DERIVED from the resolved
# product. The `shcp` values are byte-identical to the historical panel defaults
# (Q2: the panel key and URL are unchanged, so an existing panel box is
# untouched). Each product has its OWN offline release primary (SC-612, SC-246);
# the pins must equal shcp-build's lib/release-constants.sh, and
# tests/test-updater-product-keys.sh proves each is the primary of its public key
# in tests/fixtures/release-keys/. A pin must NEVER be the empty string -- an
# empty ${RELEASE_FPR} would false-match the VALIDSIG regex (verify_manifest:
# `VALIDSIG .*[[:space:]]${RELEASE_FPR}$`) and accept ANY key in the keyring.
product_manifest_url() {
	case "$1" in
		shcp)     printf 'https://repo.shcp.dev/releases/shcp-base-versions.json' ;;
		shcp-dns) printf 'https://repo.shcp.dev/releases/shcp-dns-versions.json' ;;
		shmp)     printf 'https://repo.shcp.dev/releases/shmp-base-versions.json' ;;
	esac
}
product_keyring() {
	case "$1" in
		shcp)     printf '/usr/share/keyrings/shcp-release-keyring.gpg' ;;
		shcp-dns) printf '/usr/share/keyrings/shcp-dns-release-keyring.gpg' ;;
		shmp)     printf '/usr/share/keyrings/shmp-release-keyring.gpg' ;;
	esac
}
product_release_fpr() {
	case "$1" in
		shcp)     printf '53D8134D99A248002B320B24B0A2E16C763A1BFF' ;;
		shcp-dns) printf 'A383D13340B121FFCDDBE20393E4EDD275C4D597' ;;
		shmp)     printf '44015C8D3C3F3FA18B0512BF9C12A78367EF0065' ;;
	esac
}
product_panel_link() {
	case "$1" in
		shcp)     printf '/opt/shcp' ;;
		shcp-dns) printf '/opt/shcp-dns' ;;
		shmp)     printf '/opt/shmp' ;;
	esac
}

# ledger_os_token -- the <os> part of the update-ledger stream (SC-1041).
# One token per os the product BUILDS for, never the raw os-release ID: on EL the
# ID is rhel|almalinux|rocky|centos and the ledger has one el<major> stream for
# all four, while Debian and Ubuntu keep their ID + VERSION_ID (Ubuntu's carries
# the dot, point releases do not change it). Fails closed on anything else --
# Debian testing has no VERSION_ID and must not guess a stream and fetch a
# document meant for a release. osf_detect_family already ran: OS_FAMILY is set.
ledger_os_token() {
	local osr="${SHCP_UPDATE_OS_RELEASE:-/etc/os-release}" id="" ver=""
	[[ -r "$osr" ]] || return 1
	id="$(awk -F= '$1=="ID"{gsub(/"/,"",$2); print tolower($2)}' "$osr" 2>/dev/null || true)"
	ver="$(awk -F= '$1=="VERSION_ID"{gsub(/"/,"",$2); print $2}' "$osr" 2>/dev/null || true)"
	case "$OS_FAMILY" in
		rpm)
			case "$id" in rhel|almalinux|rocky|centos) ;; *) return 1 ;; esac
			[[ "$ver" =~ ^([0-9]+)(\.[0-9]+)?$ ]] || return 1
			printf 'el%s' "${BASH_REMATCH[1]}" ;;
		deb)
			case "$id" in
				debian) [[ "$ver" =~ ^[0-9]+$ ]] || return 1; printf 'debian%s' "$ver" ;;
				ubuntu) [[ "$ver" =~ ^[0-9]+\.[0-9]+$ ]] || return 1; printf 'ubuntu%s' "$ver" ;;
				*) return 1 ;;
			esac ;;
		*) return 1 ;;
	esac
}

# ledger_stream -- <product>-<series>-<os>, the one document this box fetches.
# The series is the package-manager pointer's (series_pointer_series): the
# engine's own rollback keeps the pointer, so a rolled-back box stays on the
# stream its packages are bound to, and a pointer/panel mismatch follows the
# pointer (harmless for php-ini; a v2 panel-console entry waits on the panel).
# A product with no pointer yet (shcp-dns) falls back to the installed panel's
# series and says so in LEDGER_STREAM_DETAIL. Answers through globals like
# series_pointer_mismatch does (a $(...) caller would lose the detail): 0 with
# LEDGER_STREAM set, else 1 with the reason, and the caller defers rather than
# guesses (SC-1041 rule 1: stream match).
LEDGER_STREAM=""
LEDGER_STREAM_DETAIL=""
ledger_stream() {
	LEDGER_STREAM="" LEDGER_STREAM_DETAIL=""
	local os series cur
	[[ -n "$PRODUCT" ]] || { LEDGER_STREAM_DETAIL="product unresolved"; return 1; }
	os="$(ledger_os_token)" \
		|| { LEDGER_STREAM_DETAIL="no ledger stream for this os-release (${SHCP_UPDATE_OS_RELEASE:-/etc/os-release})"; return 1; }
	series="$(series_pointer_series)"
	if [[ -z "$series" ]]; then
		cur="$(panel_current_version)"
		ver_valid "$cur" || { LEDGER_STREAM_DETAIL="no series pointer and no installed panel version"; return 1; }
		series="$(ver_series "$cur")"
		LEDGER_STREAM_DETAIL="series_from_panel"
	fi
	LEDGER_STREAM="${PRODUCT}-${series}-${os}"
	return 0
}

# product_stage_applies <product> <stage> -- the TWO-axis gate (AD-E7, §4.2):
# footprint class AND stage-applicability (does the product even have the
# subsystem). Returns 0 iff the stage runs for the product.
#   apt              -- full-box only. The box-wide OS upgrade, u-u replacement,
#                       security-pocket ownership, daemon_pins and reboot
#                       detection all live inside stage_apt; an app-scoped product
#                       skips the WHOLE stage, which is exactly how it avoids
#                       becoming a second apt owner. (self-update still runs for
#                       all -- it touches apt narrowly for the engine's OWN
#                       package, which is not box ownership.)
#   db               -- products WITH a console/DB seam (shcp, shmp: SQLite).
#                       shcp-dns has no Symfony console, so its DB stage is
#                       SKIPPED, not errored.
#   republish-vhosts -- Apache tenant vhosts; a stage-APPLICABILITY question, not
#                       a footprint one. Only the panel manages them: shcp-dns
#                       (full-box) and shmp (FrankenPHP, no Apache) both skip it.
#   republish-pools  -- tenant PHP-FPM pools (SC-710 gate); panel-only, same reason.
#   everything else  -- ALL products. The self-update / preflight / snapshot /
#                       panel-flip / restart / health / finalize / rollback
#                       machinery is worth keeping for ANY unattended root
#                       self-update.
product_stage_applies() {
	local product="$1" stage="$2"
	case "$stage" in
		apt)              [[ "$(product_footprint "$product")" == "full-box" ]] ;;
		db)               [[ "$product" == "shcp" || "$product" == "shmp" ]] ;;
		republish-vhosts) [[ "$product" == "shcp" ]] ;;
		republish-pools)  [[ "$product" == "shcp" ]] ;;
		*)                return 0 ;;
	esac
}

# product_db_op_applies <product> <op> -- EP-4b DB-operation seam. The engine's
# DB work is FOUR named operations, each either IMPLEMENTED for a product or
# declared N/A (skipped, never errored -- a box that legitimately lacks an
# operation must still update):
#   snapshot          -- a restorable copy of the product DB before a migration
#   restore           -- put that copy back on rollback
#   integrity-check   -- the DB's own verdict that it is not corrupt
#   queue-drain-check -- quiescing the product's migration-sensitive message
#                        queues before the schema moves under them
# snapshot/restore/integrity-check are SQLite `.backup` + PRAGMA and product-
# AGNOSTIC: shcp and shmp are both SQLite (Q6 resolved), so they SHARE one
# implementation unchanged, and shcp-dns (no DB stage at all) declares them N/A.
# queue-drain-check is the only op with per-product BEHAVIOUR -- it counts
# messenger_messages rows in the panel's `verify`/`upload_cleanup` queues, names
# with no meaning off the panel, so only shcp implements it; shmp and shcp-dns
# declare it N/A. Returns 0 iff the product IMPLEMENTS the op, 1 (N/A) otherwise.
# An unknown op is N/A: the seam never silently runs an operation it does not
# recognise. The product is the SC-614-resolved $PRODUCT, never a network field.
# SC-662 (extends SC-614).
product_db_op_applies() {
	local product="$1" op="$2"
	case "$op" in
		snapshot|restore|integrity-check)
			[[ "$product" == "shcp" || "$product" == "shmp" ]] ;;
		queue-drain-check)
			[[ "$product" == "shcp" ]] ;;
		*) return 1 ;;
	esac
}

# resolve_product -- set PRODUCT from the on-box marker cross-checked against the
# package database, or FAIL CLOSED (return 1, PRODUCT left empty, a specific
# reason in PRODUCT_RESOLVE_ERROR). Requires OS_FAMILY (osf_detect_family) so the
# cross-check picks dpkg vs rpm. On success it also derives every product-scoped
# input, still honoring explicit env overrides (test seams / drills).
resolve_product() {
	PRODUCT=""
	PRODUCT_RESOLVE_ERROR=""
	local marker="$PRODUCT_MARKER_FILE"
	if [[ ! -r "$marker" ]]; then
		PRODUCT_RESOLVE_ERROR="no product marker at ${marker} (fail closed -- the installer writes it once; refusing to guess the product)"
		return 1
	fi
	# Distinct known-product tokens named in the marker. Reading the WHOLE file,
	# not just line 1, is deliberate: a marker naming two different products is
	# exactly the ambiguous state the rule forbids resolving.
	local -a claimed=()
	local line tok c seen
	while IFS= read -r line || [[ -n "$line" ]]; do
		tok="$(printf '%s' "$line" | tr -d '[:space:]')"
		[[ -z "$tok" || "$tok" == \#* ]] && continue
		seen=0
		if [[ ${#claimed[@]} -gt 0 ]]; then
			for c in "${claimed[@]}"; do [[ "$c" == "$tok" ]] && seen=1; done
		fi
		[[ $seen -eq 0 ]] && claimed+=("$tok")
	done < "$marker"
	if [[ ${#claimed[@]} -eq 0 ]]; then
		PRODUCT_RESOLVE_ERROR="empty product marker at ${marker} (fail closed)"
		return 1
	fi
	if [[ ${#claimed[@]} -gt 1 ]]; then
		PRODUCT_RESOLVE_ERROR="marker ${marker} names ${#claimed[@]} products (${claimed[*]}) -- refusing to pick one (fail closed)"
		return 1
	fi
	local want="${claimed[0]}" known=0 p
	for p in "${PRODUCT_IDS[@]}"; do [[ "$p" == "$want" ]] && known=1; done
	if [[ $known -eq 0 ]]; then
		PRODUCT_RESOLVE_ERROR="marker ${marker} names unknown product '${want}' (fail closed)"
		return 1
	fi
	# Cross-check against the package database. Collect which LAYERED products'
	# distinct package is installed (shcp has none: it is proven by the absence of
	# every layered package).
	local -a installed=() pkg
	for p in "${PRODUCT_IDS[@]}"; do
		pkg="$(product_distinct_pkg "$p")"
		[[ -z "$pkg" ]] && continue
		if osf_pkg_installed "$pkg"; then installed+=("$p"); fi
	done
	# Two distinct products installed at once is genuinely ambiguous -- refuse,
	# whatever the marker says.
	if [[ ${#installed[@]} -gt 1 ]]; then
		PRODUCT_RESOLVE_ERROR="two products' packages are installed (${installed[*]}) -- refusing to pick one (fail closed)"
		return 1
	fi
	# The marker must agree with the package database.
	local want_pkg
	want_pkg="$(product_distinct_pkg "$want")"
	if [[ -n "$want_pkg" ]]; then
		if ! osf_pkg_installed "$want_pkg"; then
			PRODUCT_RESOLVE_ERROR="marker says '${want}' but its package '${want_pkg}' is not installed -- inconsistent (fail closed)"
			return 1
		fi
	else
		if [[ ${#installed[@]} -ne 0 ]]; then
			PRODUCT_RESOLVE_ERROR="marker says 'shcp' but a layered product package is installed (${installed[*]}) -- inconsistent (fail closed)"
			return 1
		fi
	fi
	# Consistent and unambiguous. Derive every product-specific input from the ONE
	# resolved product (SC-614), honoring explicit env overrides so this never
	# clobbers a caller that pinned a specific manifest/keyring.
	PRODUCT="$want"
	MANIFEST_URL="${SHCP_UPDATE_MANIFEST_URL:-$(product_manifest_url "$PRODUCT")}"
	RELEASE_KEYRING="${SHCP_UPDATE_KEYRING:-$(product_keyring "$PRODUCT")}"
	RELEASE_FPR="${SHCP_UPDATE_RELEASE_FPR:-$(product_release_fpr "$PRODUCT")}"
	PANEL_LINK="${SHCP_UPDATE_PANEL_LINK:-$(product_panel_link "$PRODUCT")}"
	return 0
}

# require_product_resolved -- resolve or die loudly. The apply/resume entrypoints
# use this: a box that cannot name its product does not update (no fetch, no
# apply). SC-614 fail-closed.
require_product_resolved() {
	osf_detect_family
	if ! resolve_product; then
		die "product resolution failed: ${PRODUCT_RESOLVE_ERROR}"
	fi
	engine_log "resolved product: ${PRODUCT} (footprint $(product_footprint "$PRODUCT"))"
}
VERSION_FLOOR_FILE="${SHCP_UPDATE_VERSION_FLOOR:-/var/lib/shcp/.shcp-version-floor}"
# SC-472: the newest-accepted manifest generated_at.
# A monotonic floor mirroring the panel's SC-353, so the engine refuses a
# strictly-older (still validly-signed) manifest BEFORE rendering any value from
# it — the panel target OR the daemon_pins the series pin re-render reads.
MANIFEST_GENERATED_AT_FILE="${SHCP_UPDATE_MANIFEST_GENERATED_AT:-/var/lib/shcp/.shcp-manifest-generated-at}"
# EP-2 (#138): the versions manifest on-the-wire schema this engine speaks. 2
# is the current shape (series array, per-entry app+installer halves). A
# manifest stamped higher is refused fail-closed — a newer publisher must not
# drive an older engine through fields it would silently ignore. (The manifest
# also carries min_bootstrap_version; that field is the installer bootstrap
# stub's contract — shcp-installer/shcp.sh BOOTSTRAP_VERSION, enforced there —
# and the engine, which is not that stub, deliberately does not gate on it.)
MANIFEST_SCHEMA_MAX="${SHCP_UPDATE_MANIFEST_SCHEMA_MAX:-2}"
# EP-2 (#138, SC-633): manifest-fetch liveness. manifest_fetch stamps last-success /
# first-failure here so `check` can raise a loud signal DISTINCT from SC-319
# when the manifest has been unfetchable past the threshold. SC-319 ages the
# apt-derived last_security_success, which never observes the manifest
# endpoint at all.
MANIFEST_FETCH_STATE_FILE="${SHCP_UPDATE_MANIFEST_FETCH_STATE:-/var/lib/shcp/.shcp-manifest-fetch-state}"
SHCPD_BIN="${SHCP_UPDATE_SHCPD_BIN:-/usr/sbin/shcpd}"
# UPD-9 S3 (#370): the shcp-installer ships the shared apt-pin renderer
# (apt_pins.sh), its SC-001/SC-029 writer (apt_render_lib.sh) and the pin
# templates (preferences.d/*.template) here, root:root 0644. A series pin
# re-render SOURCES the same writer the installer uses rather than carrying a
# second, weaker copy that could drift (plan F6). Overridable for tests.
APT_RENDER_DIR="${SHCP_UPDATE_APT_RENDER_DIR:-/usr/share/shcp/apt}"

MAX_MANIFEST_BYTES=$((1024 * 1024))
MAX_ARTIFACT_BYTES=$((512 * 1024 * 1024))

# --- wp-cli version currency (SC-078/SC-089, kanban "wp-cli version currency").
# The installer lays down a PINNED, GPG-signature-verified wp-cli phar at
# /usr/local/bin/wp (shcp-installer functions/wpcli.sh, pinned by WP_CLI_VERSION_PIN
# in config.sh). An already-installed box NEVER reruns the installer, so a later
# release that bumps the pin never reaches it and the box stays on the old wp-cli
# forever — wpcli_ensure_pin re-applies it during a run (called from stage_apt),
# the same "the signed update is the only vehicle" reasoning runtime_deps_ensure
# (SC-543) uses for apt runtime deps. The wanted version comes from the SIGNED
# manifest; the phar is fetched from wp-cli's release tags and its detached GPG
# signature verified against the key below — NEVER a rolling `wp cli update` /
# gh-pages wp-cli.phar (the SC-067/SC-078 anti-pattern the installer refuses).
# Family-agnostic: /usr/local/bin/wp is the same absolute path on deb AND rpm, and
# nothing here consults a distro package name. All overridable for the test suite.
WPCLI_BIN="${SHCP_UPDATE_WPCLI_BIN:-/usr/local/bin/wp}"
WPCLI_RELEASE_BASE_URL="${SHCP_UPDATE_WPCLI_BASE_URL:-https://github.com/wp-cli/wp-cli/releases/download}"
# The trust anchor for the phar. Mirrors shcp-installer config.sh WP_CLI_RELEASE_KEY_FPR
# ("WP-CLI Releases <releases@wp-cli.org>"). The fingerprint assertion is the point:
# the embedded key below is just bytes, so anything that could rewrite it could sign
# its own phar — re-deriving the fingerprint from the imported material and comparing
# it here means moving the anchor takes TWO reviewed edits, one a reviewed constant.
WPCLI_RELEASE_FPR="${SHCP_UPDATE_WPCLI_RELEASE_FPR:-63AF7AA15067C05616FDDD88A3A2E8F226F0BC06}"
# A wp-cli phar is ~7 MB; 32 MB is generous headroom and refuses an oversized body
# before it reaches disk (the phar download's counterpart to MAX_ARTIFACT_BYTES).
MAX_WPCLI_PHAR_BYTES=$((32 * 1024 * 1024))

# wpcli_release_key <dest> — write the compiled-in WP-CLI release public key (armored)
# to <dest>. The key ships WITH the engine, so it is always exactly as fresh as the
# engine binary and needs no installer tree on-box — which a run does NOT have (unlike
# reconcile-config, run_stages never fetches/extracts the installer tarball). A test
# points SHCP_UPDATE_WPCLI_KEY_FILE at its own armored key to drive the verify path.
wpcli_release_key() {
	local dest="$1"
	if [[ -n "${SHCP_UPDATE_WPCLI_KEY_FILE:-}" ]]; then
		cat -- "$SHCP_UPDATE_WPCLI_KEY_FILE" > "$dest"
		return
	fi
	cat > "$dest" <<'WPCLI_RELEASE_KEY'
-----BEGIN PGP PUBLIC KEY BLOCK-----

mQENBFsQEBkBCADfGAhxQ71XaIk31SjD8dNH3uVNtPh/2SIIhYbObMvEG6uoMucP
t3jCsnkh/veKBkJ+HJ/XcARGoFYdaCZo5PTORBWOwHmeWPbu7aiAM4v3EKPc8wZP
jabtEejwZrRFlSlAu5YL25ldz5KNgvGOBdje9jUi2iovQ4lfjMEuH2sXhmDQPbDW
22Fb2xinvmlnyf5kJn9ADiWm6VEnaNvaL96TCC1iUrMJmYI0m29j2sVbKJIq8ZBO
CgtY4llPC7QskWw8VXEAq2WnQeZMVLqxOSoRDy/qUvw0RR+DfiXsrHjBxAH4uvHK
zUdnMl1Qbh2A/rfcsIaJubXg5pUXwF7TCvSPABEBAAG0JVdQLUNMSSBSZWxlYXNl
cyA8cmVsZWFzZXNAd3AtY2xpLm9yZz6JAU4EEwEIADgCGwMFCwkIBwIGFQgJCgsC
BBYCAwECHgECF4AWIQRjr3qhUGfAVhb93YijoujyJvC8BgUCWxAQ7AAKCRCjoujy
JvC8BmYTCACPEYQr89U/H/iQcI012UaOSLYLx+Qj9oA7p9gv2mZSBHNSijzhnizo
QBEg7q7BXF8B8UqL9ZhUWfG9PiR8kkFbBN1sIY0RM5cFltb3cJthVH4ZV8SUiGW6
zIOd8m5JXVnekmZyJFpufxDHms6F3Z2RNUbdZSp2Mj+5p/a4GtJhfGGpfYBbXOxG
gdx5dmipjlxfP5M3YL4QCJoySB+sY92de5b3S3tqmj7ldb/GRvN/7XjoMDwbRso6
jrhGtk8TjtAV0l/VdjebpI6zivrDLYDQq5vwi6hGPl7k88ElU47vaJiJX5yXaJ/k
LFQ3g8raFQq69nqcDjJLGds+Y+lAXrtzuQENBFsQEBkBCAC9ZUHiXbBLvCejMXKK
vFpKaVsovI6RBU8l0sC+00yvpP+TJmRXracnesTqHyTlhAUMhpbFvG0mBMBdROt5
IPRZ2S9JdJKZFVqO7Nop0elO8wUe6rsEHisUbEP49BcqDHGxfEnB/MubnJO1hHEH
1ftnZBEQW3jNagOTikki9675qF4ONvaSeCY9DkHa4lbau24SzePvtWuxYGsdX+Jf
ikFxq8N2ArPlSoVv/DKEbl5dgz1hYFJ9qBKoXSbaKk1TxZ4bA8nxcznZHS+8Lirv
tEjB8Z68Hz+pXIFJBbchC/FMatZC2hjFobedc4dT3nxf5iiH9XsHI5bqp3UEsPlx
8LP5ABEBAAGJATYEGAEIACACGwwWIQRjr3qhUGfAVhb93YijoujyJvC8BgUCWxAR
IQAKCRCjoujyJvC8BhGMCACkNhkshrOYDRoOwny8m1mI2nSIU0KnjEruaeAXrY2T
5VHNfLkTX3wD2HYO97r1CvUNBUWpmTwSicK0Z6TCDp4A9Oi+z5CA/5zBT/iydC6E
czNAPUehdLKka9Qs0vrVq22S0dDiA4xXZUvQpoo8VUKlvau9igF98mbd56U89s3L
gg7O72A/4x7rhDO0Q+U8SIBJtFmEHIcu6gHkooQW3d7opHKCjbnyqxDQ/iUY3b8o
n75TXnDJWbjCFTiTMyVTeyQfK0Us8FbJNXhMMagalRQu/sBH56S7Cg2OLciQB1B2
sLbhbfYDXYriI/OGVIPvCEIH6FX4+KiFy7RS+0JthI0R
=fQDM
-----END PGP PUBLIC KEY BLOCK-----
WPCLI_RELEASE_KEY
}

# --- OWASP CRS ruleset currency (SEC-6, SC-485 / SC-543 class, #304) -----------
# On families with no distro CRS package (el10 — RHEL 10 moved mod_security to
# EPEL and the CRS did not follow; EPEL10's mod_security_crs is 10.3-stream-only)
# the installer fetches a PINNED upstream OWASP CRS release at install and stages
# it under MODSEC_CRS_DIR as Apache's sole ordered loader (shcp-installer
# apache.sh install_owasp_crs, pinned by MODSEC_CRS_FETCH_VERSION + _SHA256 in
# config.sh). An already-installed box never reruns the installer, so a later
# release that bumps the pin never reaches it and the box's WAF rules slowly rot —
# the same gap runtime_deps_ensure / wpcli_ensure_pin (SC-543) close for apt deps
# and the wp-cli phar. crs_ensure_pin re-applies the manifest-declared pin during
# a run (called from stage_apt). The wanted version+digest come from the SIGNED
# manifest; the tarball is fetched from the coreruleset release tags and its
# sha256 verified against that pinned digest. There is NO upstream GPG signature
# for the CRS, so the digest carried in the signed manifest IS the authenticity
# anchor — its trust derives from the manifest's own signature (gpgv + pinned
# VALIDSIG + SC-472) — and the check FAILS CLOSED exactly as the installer's does:
# a WAF whose ruleset could be swapped by a CDN compromise or TLS MITM is worse
# than no update, so no digest match means the live ruleset is left untouched
# (SC-485). Family-neutral: the same tarball serves every family/arch, and the
# re-apply gates on MODSEC_CRS_DIR already existing — the on-box signal that THIS
# box manages its own upstream-fetched CRS (el10), never a distro package name
# (deb gets the CRS from a distro package, which apt already refreshes in
# this same stage). All overridable for the test suite.
MODSEC_CRS_DIR="${SHCP_UPDATE_MODSEC_CRS_DIR:-/var/lib/shcp/modsecurity/crs}"
CRS_RELEASE_BASE_URL="${SHCP_UPDATE_CRS_BASE_URL:-https://github.com/coreruleset/coreruleset/releases/download}"
# The applied-pin stamp the engine AND the installer write beside the ruleset, so
# a re-apply is skipped when the on-box ruleset already matches the pin. One line
# "<version> <sha256>"; BOTH must match or the ruleset is re-applied (a re-pin to
# the same version under a new digest — a re-cut upstream release — still applies).
CRS_VERSION_STAMP="${MODSEC_CRS_DIR}/.shcp-crs-version"
# A CRS minimal tarball is ~1 MB; 16 MB is generous headroom and refuses an
# oversized body before it reaches disk (the CRS counterpart to MAX_WPCLI_PHAR_BYTES).
MAX_CRS_TARBALL_BYTES=$((16 * 1024 * 1024))

# --- SHCP repo signing key (SC-493) ---------------------------------------------
# The SHCP REPO signing key fingerprint (distinct from the manifest RELEASE_FPR).
# series_dnf_key_pinned proves the on-box RPM-GPG-KEY-shcp IS this pinned key
# before a series flip lets dnf import it non-interactively. Mirrors
# shcp-installer config.sh SHCP_GPG_FINGERPRINT; moving the anchor takes two
# reviewed edits, one a reviewed constant (same rationale as WPCLI_RELEASE_FPR).
SHCP_REPO_GPG_FPR="${SHCP_UPDATE_REPO_GPG_FPR:-B9B7366DB831B2765ABB03D8BFAF79919CF7776E}"

# --- health stage tunables (UPD-5, §4.2-7: "3 attempts over 90 s") -------------
# Attempts land at t≈0/45/90, so the third is the last word inside the plan's
# window. Per-check timeouts bound each attempt; the outer bound stays the
# unit's TimeoutStartSec.
HEALTH_ATTEMPTS=3
HEALTH_BACKOFF_SEC="${SHCP_UPDATE_HEALTH_BACKOFF:-45}"
[[ "$HEALTH_BACKOFF_SEC" =~ ^[0-9]+$ ]] || HEALTH_BACKOFF_SEC=45
HEALTH_HTTP_TIMEOUT=10
HEALTH_CONSOLE_TIMEOUT=60
# Cap on the panel's own health report before it reaches jq --arg. Same reason
# as MAX_DB_LOG_BYTES and stage_error's clamp: the journal is re-serialized by
# every later write, and jq refuses invalid UTF-8.
MAX_HEALTH_REPORT_BYTES=$((16 * 1024))

# --- republish-vhosts tunable (WEB-5 / SC-436) --------------------------------
# The post-deploy tenant-vhost re-render is per-site work — two configtests and a
# graceful reload each — so a box with many sites takes real wall-clock. This
# bound exists to bound a HUNG apachectl, not to be precise: it is generous by
# design, its expiry only marks the stage `degraded` (never a rollback), and it
# is a SOFT bound (run_bounded is timeout(1) with no kill-after). Overridable for
# the rare box where half an hour is genuinely too short.
REPUBLISH_TIMEOUT="${SHCP_UPDATE_REPUBLISH_TIMEOUT:-1800}"
[[ "$REPUBLISH_TIMEOUT" =~ ^[0-9]+$ ]] || REPUBLISH_TIMEOUT=1800

# Semver compare, component-wise and numeric. Deliberately NOT
# `dpkg --compare-versions` (deb-only, and this runs behind the AD-11 EL seam
# too) and not `sort -V` (whose ordering of equal-length numerics is fine but
# whose availability is not worth depending on for three integers). 10# forces
# base-10 so a zero-padded component is not read as octal.
ver_valid() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
# The updater deb deliberately uses <contract>.<build>, not application SemVer.
# Keep this parser separate so neither contract can silently coerce the other.
# Since the package bundles shcp-conf it is <contract>.<build>.<conf> (the
# bundled parser's shcp-base commit count); both forms are valid, on either side
# of a comparison, so a 3-part engine still meets a manifest's 2-part minimum and
# a 3-part minimum is not "malformed". Canonical decimals only, at most 3 parts.
updater_version_valid() {
	[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ ]]
}

# updater_contract_version — reduce a PACKAGED updater version to its bare
# <contract>.<build>. osf_selfupdate_installed reports what the package manager
# holds, which is never bare: deb stamps a revision ('1.100-1'), rpm an EVR that
# may carry an epoch and always a release ('1:1.100-1.el10'). The manifest's
# min_updater_version speaks the family-neutral contract ('1.55') — the revision,
# release and epoch are packaging, not contract, so they must come off before the
# value meets updater_version_valid / updater_version_ge. Without this the check
# rejects every real box (updater#127): the app never flips and the release the
# box is entitled to never installs. Empty in, empty out — the caller reads that
# as "unknown" and fails closed. This is a reduction, not a validator: a value
# that is still not a strict contract.build[.conf] (a pre-release '1.100~rc1',
# junk) is left to updater_version_valid to reject.
updater_contract_version() {
	local v="${1#*:}"   # drop a leading rpm epoch 'N:'
	printf '%s' "${v%%-*}"   # drop the deb revision / rpm release from the first '-'
}

# Name a minimum-version refusal from a verified manifest (or a run's copy).
panel_updater_requirement_detail() {
	local manifest="$1" current="$2" series="${3:-}" want="${4:-}" scope="${5:-all}" reinstall="${6:-0}"
	local entry version minimum installed_raw installed
	[[ -n "$series" ]] || { ver_valid "$current" || return 1; series="$(ver_series "$current")"; }
	entry="$(jq -c --arg s "$series" '[.series[]? | select(.series == $s)] | first // empty' "$manifest" 2>/dev/null || true)"
	[[ -n "$entry" ]] || return 1
	[[ "$(jq -r '.status // empty' <<<"$entry")" == supported || "$(jq -r '.status // empty' <<<"$entry")" == security ]] || return 1
	version="$(jq -r '.version // empty' <<<"$entry")"
	ver_valid "$version" || return 1
	[[ -z "$want" || "$want" == "$version" ]] || return 1
	if ver_valid "$current" && ! ver_gt "$version" "$current"; then
		[[ "$reinstall" == 1 && "$version" == "$current" ]] || return 1
	fi
	[[ "$scope" != security || "$(jq -r '.app.critical // false' <<<"$entry")" == true ]] || return 1
	minimum="$(jq -r '.app.min_updater_version // empty' <<<"$entry")"
	updater_version_valid "$minimum" || return 1
	osf_detect_family
	installed_raw="${SHCP_UPDATE_ENGINE_VERSION:-$(osf_selfupdate_installed)}"
	installed="$(updater_contract_version "$installed_raw")"
	updater_version_valid "$installed" || return 1
	updater_version_ge "$installed" "$minimum" && return 1
	printf 'installed shcp-updater %s; release requires %s' "$installed_raw" "$minimum"
}

# Compare two already-validated non-negative decimal components without ever
# converting them to a Bash integer. Package build numbers are identifiers, not
# machine-sized counters; a signed minimum must compare identically everywhere.
decimal_component_ge() {
	local have="$1" need="$2" i hd nd
	(( ${#have} > ${#need} )) && return 0
	(( ${#have} < ${#need} )) && return 1
	for ((i = 0; i < ${#have}; i++)); do
		hd="${have:i:1}"
		nd="${need:i:1}"
		(( hd > nd )) && return 0
		(( hd < nd )) && return 1
	done
	return 0
}

# updater_version_ge <have> <need> — component-wise over contract, build, then
# conf; an absent conf part (a 2-part version) counts as 0, so
# 1.500 == 1.500.0 < 1.500.1 < 1.501 — the same order dpkg/rpm give the packages.
updater_version_ge() {
	local have="$1" need="$2" hc hb hf nc nb nf
	updater_version_valid "$have" && updater_version_valid "$need" || return 1
	IFS=. read -r hc hb hf <<<"$have"
	IFS=. read -r nc nb nf <<<"$need"
	if [[ "$hc" != "$nc" ]]; then
		decimal_component_ge "$hc" "$nc"
		return
	fi
	if [[ "$hb" != "$nb" ]]; then
		decimal_component_ge "$hb" "$nb"
		return
	fi
	decimal_component_ge "${hf:-0}" "${nf:-0}"
}
ver_series() { printf '%s' "${1%.*}"; }

ver_gt() {
	local -a a b
	IFS=. read -r -a a <<<"$1"
	IFS=. read -r -a b <<<"$2"
	local i
	for i in 0 1 2; do
		(( 10#${a[i]:-0} > 10#${b[i]:-0} )) && return 0
		(( 10#${a[i]:-0} < 10#${b[i]:-0} )) && return 1
	done
	return 1
}

# The running panel's version, from the VERSION file the packager stamps into
# the tarball root. Empty when absent or unshaped — callers treat that as
# "unknown", never as 0.0.0.
panel_current_version() {
	local vf="${PANEL_LINK}/VERSION" v=""
	if [[ -r "$vf" ]]; then
		v="$(tr -d '[:space:]' <"$vf" 2>/dev/null || true)"
	fi
	ver_valid "$v" && printf '%s' "$v"
	return 0
}

# UPD-9: is this run a SERIES (cross-minor) upgrade? True only when the validated
# request pins a series (REQ_SERIES — recovered on resume by
# recover_run_request_state, ~4298) that differs from the series of the version
# actually running. No series, an unknown running version, or a request for the
# running series is a ROUTINE run. Every series-gated stage (extended preflight,
# mandatory snapshot, the pre-apt suite/pin rewrite) runs BEFORE the panel flip,
# so panel_current_version here is always the pre-jump version.
is_series_run() {
	[[ -n "$REQ_SERIES" ]] || return 1
	local cur; cur="$(panel_current_version)"
	# Unknown running version: a pinned series request is still a cross-series
	# jump from an unknown baseline — treat it as a series run so the safety net
	# (mandatory snapshot, extended preflight) is MORE present, never less. Only a
	# series approval ever sets REQ_SERIES, so a routine run is never mis-flagged.
	ver_valid "$cur" || return 0
	[[ "$REQ_SERIES" != "$(ver_series "$cur")" ]]
}

# Wire <release>/var: cache and tmp per-release, every shared name a symlink into
# panel-state. Mirrors the installer's shcp_layout_link_release_var — the two
# implementations are separate because they live in different repos, but the
# CONTRACT they implement is one document (§4.12). A real dir where a symlink
# belongs has its contents copied across once, then is replaced: the release
# tarball ships a var/ skeleton, so this case is the norm after every extract,
# not an edge case.
# Create only the three supported release-local state directories. Validate the
# release and var parent before mkdir: mkdir -p follows a symlinked intermediate
# component, which would let a hostile old release redirect root's chown.
release_runtime_dirs_ensure() {
	local release="$1" dir
	[[ -n "$release" && -d "$release" && ! -L "$release" ]] || return 1
	if [[ -L "$release/var" || ( -e "$release/var" && ! -d "$release/var" ) ]]; then
		engine_log "release var: refusing unsafe path ${release}/var"
		return 1
	fi
	mkdir -p "$release/var" || return 1
	for dir in cache tmp root-cache; do
		if [[ -L "$release/var/$dir" || ( -e "$release/var/$dir" && ! -d "$release/var/$dir" ) ]]; then
			engine_log "release var: refusing unsafe runtime path ${release}/var/${dir}"
			return 1
		fi
		mkdir -p "$release/var/$dir" || return 1
	done
}

release_link_var() {
	local release="$1"
	release_runtime_dirs_ensure "$release" || return 1
	local name target link
	for name in "${PANEL_VAR_SHARED[@]}"; do
		if [[ "$name" == kv ]]; then
			target="$PANEL_KV_DIR"
		else
			target="${PANEL_VAR_DIR}/${name}"
		fi
		link="${release}/var/${name}"
		mkdir -p "$target" || return 1
		# The worker runs as shcp and must WRITE here (locks, sessions, logs).
		# A root-created target is a per-request 500 the health stage can miss
		# when the baseline was already degraded (demo, 2026-08-16: root-owned
		# panel-state/lock -> FlockStore "is not writable" on every request).
		# kv stays installer-owned. Same best-effort idiom as the staging chown.
		[[ "$name" != kv ]] && { chown shcp:shcp "$target" 2>/dev/null || true; }
		if [[ -L "$link" ]]; then
			[[ "$(readlink "$link")" == "$target" ]] && continue
			rm -f "$link" || return 1
		elif [[ -d "$link" ]]; then
			if [[ -n "$(ls -A "$link" 2>/dev/null)" ]]; then
				engine_log "migrating ${link} into shared ${target}"
				cp -a "${link}/." "${target}/" || return 1
			fi
			rm -rf "$link" || return 1
		elif [[ -e "$link" ]]; then
			engine_log "refusing to replace non-directory ${link}"
			return 1
		fi
		ln -s "$target" "$link" || return 1
	done
	return 0
}

# Apply the installed policy to release paths created by this engine (SC-858).
release_selinux_restore() {
	local path="$1" mode="$2"
	[[ "$OS_FAMILY" == rpm ]] || return 0
	command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled || return 0
	command -v restorecon >/dev/null 2>&1 || return 0
	restorecon "-${mode}F" -- "$path" 2>/dev/null || engine_log "release relabel failed: ${path}"
	return 0
}

# release_secure_ownership <release-tree> -- SC-355.
# Root executes code from active and rollback releases.  All application code
# is therefore root-owned and non-writable by shcp; only cache/tmp remain
# writable. root-cache is used by privileged console work and stays root-owned.
# Other mutable var entries are symlinks
# into the shared state roots established by release_link_var.
release_secure_ownership() {
	local release="$1" dir rc=0 root_cache stamp
	[[ -n "$release" && -d "$release" && ! -L "$release" ]] || {
		engine_log "release ownership: ${release} is not a real directory"
		return 1
	}
	# First pass: reject every existing unsafe component before recursion. Missing
	# leaves are allowed, but are not created until their parent has been locked.
	[[ -d "$release/var" && ! -L "$release/var" ]] || {
		engine_log "release ownership: unsafe var path under ${release}"
		return 1
	}
	for dir in "$release/var/cache" "$release/var/tmp" "$release/var/root-cache"; do
		[[ ! -e "$dir" && ! -L "$dir" ]] || [[ -d "$dir" && ! -L "$dir" ]] || {
			engine_log "release ownership: unsafe runtime path ${dir}"
			return 1
		}
	done
	chown -R root:shcp "$release" 2>/dev/null || return 1
	# Preserve artifact executable bits (notably Certbot hooks) while removing
	# every group/other write path through the release tree.
	chmod -R u=rwX,g=rX,o= "$release" 2>/dev/null || return 1
	# Revalidate after locking the formerly writable tree. Only now is mkdir safe:
	# shcp can no longer exchange var or a leaf between check and use.
	[[ -d "$release" && ! -L "$release" && -d "$release/var" && ! -L "$release/var" ]] || return 1
	for dir in "$release/var/cache" "$release/var/tmp" "$release/var/root-cache"; do
		[[ ! -e "$dir" && ! -L "$dir" ]] || [[ -d "$dir" && ! -L "$dir" ]] || return 1
	done
	for dir in "$release/var/cache" "$release/var/tmp"; do
		mkdir -p "$dir" || return 1
		[[ -d "$dir" && ! -L "$dir" ]] || return 1
	done
	# Generated container PHP is executable input to root. Never bless cache bytes
	# that arrived in an artifact or survived an earlier run: the parent is now
	# root-owned/non-writable, so this exact purge-and-recreate cannot be redirected.
	root_cache="$release/var/root-cache"
	rm -rf -- "$root_cache" || return 1
	mkdir "$root_cache" || return 1
	chown root:root "$root_cache" 2>/dev/null || return 1
	chmod 0755 "$root_cache" || return 1
	release_selinux_restore "$root_cache" ""
	for dir in "$release/var/cache" "$release/var/tmp"; do
		chown -R shcp:shcp "$dir" 2>/dev/null || rc=1
		chmod -R u+rwX,go-w "$dir" 2>/dev/null || rc=1
	done
	if [[ "$rc" -ne 0 ]]; then
		engine_log "release ownership: could not enforce ownership/modes under ${release}"
		return 1
	fi
	stamp="$release/.shcp-trusted-release-v1"
	rm -f -- "$stamp" || return 1
	: > "$stamp" || return 1
	chown root:root "$stamp" 2>/dev/null || return 1
	chmod 0444 "$stamp" || return 1
	return 0
}

release_trust_stamp_valid() {
	local release="$1" stamp uid gid mode
	stamp="$release/.shcp-trusted-release-v1"
	[[ -d "$release" && ! -L "$release" && -f "$stamp" && ! -L "$stamp" ]] || return 1
	read -r uid gid mode < <(stat -c '%u %g %a' "$stamp" 2>/dev/null) || return 1
	[[ "$uid" == "$SHCP_UPDATE_EXPECT_UID" && "$gid" == "$SHCP_UPDATE_EXPECT_GID" ]] || return 1
	(( (8#$mode & 0022) == 0 )) || return 1
}

# <release>/.env.local -> the canonical env. The installer links it on a fresh
# box and package.sh strips .env.local from the tarball, so a staged tree carries
# at most the symlink this function creates. A REAL file there is therefore never
# ours: it is a tampered or foreign tree, and linking over it (or promoting it,
# as the pre-1.0 engine did) would hand the panel secrets nothing authenticated.
# Refuse — the staging tree is discarded by the caller.
release_link_env() {
	# Two statements, deliberately: `local a="$1" b="${a}/x"` expands $a BEFORE
	# local assigns it, so under `set -u` it dies with "a: unbound variable".
	local release="$1"
	local conf="${release}/.env.local"

	if [[ -e "$conf" && ! -L "$conf" ]]; then
		engine_log "refusing to link ${conf}: a real file is present where the tarball ships none (tampered or foreign release tree)"
		return 1
	fi
	[[ -e "$PANEL_ENV" || -L "$conf" ]] || {
		engine_log "canonical env ${PANEL_ENV} is absent — leaving ${conf} alone"
		return 0
	}
	if [[ -L "$conf" ]]; then
		[[ "$(readlink "$conf")" == "$PANEL_ENV" ]] && return 0
		rm -f "$conf" || return 1
	fi
	ln -s "$PANEL_ENV" "$conf" || return 1
	return 0
}

# Atomic activate (plan AD-3): stage a link under a scratch name and rename over
# the old one. `ln -sfn` unlinks first and exposes a window where /opt/shcp
# resolves to nothing — for the panel symlink that window takes down shcpd, the
# root worker and every timer oneshot at once.
panel_flip() {
	local release="$1" staged="${PANEL_LINK}.activating.$$"
	[[ -d "$release" ]] || { engine_log "cannot activate missing release ${release}"; return 1; }
	rm -f "$staged"
	ln -s "$release" "$staged" || return 1
	chown -h root:root "$staged" 2>/dev/null || true
	if ! mv -T "$staged" "$PANEL_LINK"; then
		rm -f "$staged"
		return 1
	fi
	return 0
}

# --- promotion of a staged release (shcp-updater#183) ---------------------------
# stage_panel extracts beside the final path and renames the staged tree onto
# ${RELEASES_DIR}/<version>. A REINSTALL re-extracts the version already running,
# so that path is the LIVE release: rename(2) of a directory onto a non-empty one
# fails (ENOTEMPTY), and it did — every `apply --reinstall` of the active release
# died at "promotion failed" with the schema-pending gate left up.
#
# The live tree cannot be removed first (it is what a failed reinstall must go
# back to), and the new tree cannot live anywhere else (its compiled container is
# path-faithful to where it was warmed, see the rebuild note in stage_panel). So a
# reinstall SWAPS: the live tree is renamed aside, the staged tree takes its path,
# and the aside is either committed away (rebuild + smoke passed) or renamed back
# (anything failed before the flip). Both renames are same-filesystem rename(2),
# inside the outage window stage_panel already opened (maintenance up, workers
# stopped).
#
# A kill can land anywhere in that sequence, and the rebuild alone is ~30 s, so the
# on-disk state must say which tree is the good one. <final>.reinstall-swap is that
# record: created before the first rename, removed FIRST on commit and LAST on
# restore. Marker present => the swap is uncommitted and the aside (if any) is the
# known-good tree; marker absent => any aside is a committed leftover. Recovery runs
# at the start of every run and rollback (release_trees_recover), before anything
# reads the panel version: with the symlink dangling there is no version, so a
# recovery that waited for stage_panel would never be reached.
PANEL_PROMOTE_ASIDE=""

panel_promote_aside_of() { printf '%s.reinstall-prior' "$1"; }
panel_promote_swap_of() { printf '%s.reinstall-swap' "$1"; }

# panel_promote_release <staging> <final> — 0 when <staging> now lives at <final>.
panel_promote_release() {
	local staging="$1" final="$2" aside swap
	aside="$(panel_promote_aside_of "$final")"
	swap="$(panel_promote_swap_of "$final")"
	PANEL_PROMOTE_ASIDE=""
	if [[ -d "$final" ]] && panel_link_points_at "$final"; then
		# Only a reinstall may replace the running tree. Anything else reaching here
		# (a VERSION that disagrees with its directory name) fails as it always did.
		if [[ "${REQ_REINSTALL:-0}" != "1" ]]; then
			engine_log "panel: refusing to promote over the ACTIVE release ${final} outside a reinstall"
			return 1
		fi
		# panel_promote_recover ran first, so an aside here is a committed leftover.
		rm -rf "$aside" || return 1
		: > "$swap" || return 1
		if ! mv -T "$final" "$aside"; then
			rm -f "$swap"
			return 1
		fi
		PANEL_PROMOTE_ASIDE="$aside"
		engine_log "panel: reinstall of the active release — live tree moved aside to ${aside}"
		if ! mv -T "$staging" "$final"; then
			if mv -T "$aside" "$final"; then
				PANEL_PROMOTE_ASIDE=""
				rm -f "$swap"
			else
				engine_log "panel: CRITICAL — could not move ${aside} back to ${final}; the panel symlink dangles"
			fi
			return 1
		fi
		return 0
	fi
	# Not the active release: a leftover tree at <final> (a pruned-then-refetched
	# version, a crashed earlier attempt) is replaced, never merged.
	if [[ -e "$final" || -L "$final" ]]; then
		rm -rf "$final" || return 1
	fi
	mv -T "$staging" "$final"
}

# panel_promote_restore <final> — undo a reinstall swap: the new tree is removed
# and the prior one renamed back. 0 when nothing was swapped or it is back.
panel_promote_restore() {
	local final="$1"
	[[ -n "$PANEL_PROMOTE_ASIDE" ]] || return 0
	[[ -d "$PANEL_PROMOTE_ASIDE" ]] || return 1
	# Rename first, delete last: /opt/shcp never resolves to a tree being removed.
	local discard="${final}.reinstall-discard"
	rm -rf "$discard" || return 1
	# <final> may already be GONE: when panel_promote_release's second rename
	# failed AND its move-back of the aside failed (its "CRITICAL" branch), the
	# live path holds nothing. An unconditional rename here then fails on every
	# attempt, this function returns 1 forever, the aside — the only good tree
	# — is never retried, and the schema-pending gate stays up (SC-318).
	if [[ -e "$final" || -L "$final" ]]; then
		mv -T "$final" "$discard" || return 1
	fi
	if ! mv -T "$PANEL_PROMOTE_ASIDE" "$final"; then
		if [[ -e "$discard" || -L "$discard" ]]; then
			mv -T "$discard" "$final" || true
		fi
		return 1
	fi
	rm -f "$(panel_promote_swap_of "$final")"
	rm -rf "$discard" || true
	engine_log "panel: reinstall abandoned — the prior tree is back at ${final}"
	PANEL_PROMOTE_ASIDE=""
	return 0
}

# panel_promote_commit <final> — the swapped-in tree passed rebuild + smoke at the
# live path; drop the prior tree. The marker goes first: once it is gone the aside
# is a leftover. If the marker cannot be removed the aside is kept (a later
# recovery would otherwise restore it over the verified tree — and the stage fails).
panel_promote_commit() {
	local final="$1"
	[[ -n "$PANEL_PROMOTE_ASIDE" ]] || return 0
	if ! rm -f "$(panel_promote_swap_of "$final")"; then
		engine_log "panel: could not remove the reinstall swap marker for ${final}"
		return 1
	fi
	rm -rf "$PANEL_PROMOTE_ASIDE" || engine_log "panel: could not remove ${PANEL_PROMOTE_ASIDE} (removed on the next run)"
	PANEL_PROMOTE_ASIDE=""
	return 0
}

# panel_promote_recover <final> — repair what a killed reinstall left.
#   marker + aside : uncommitted swap — the aside is the known-good tree; whatever
#                    is at <final> (unwarmed, half-rebuilt, or nothing) is replaced.
#   marker only    : killed before the first rename or after a restore — <final> is good.
#   aside only     : committed leftover (or <final> gone) — drop it, or move it back.
panel_promote_recover() {
	local final="$1" aside swap
	aside="$(panel_promote_aside_of "$final")"
	swap="$(panel_promote_swap_of "$final")"
	if [[ -e "$swap" || -L "$swap" ]]; then
		if [[ -d "$aside" && ! -L "$aside" ]]; then
			engine_log "panel: an interrupted reinstall of ${final} was never committed — restoring the prior tree"
			# Rename first, delete last (this can run with shcpd and workers live).
			local discard="${final}.reinstall-discard"
			rm -rf "$discard" || return 1
			if [[ -e "$final" || -L "$final" ]]; then
				mv -T "$final" "$discard" || return 1
			fi
			mv -T "$aside" "$final" || return 1
			rm -rf "$discard" || true
		fi
		rm -f "$swap"
		return
	fi
	[[ -e "$aside" || -L "$aside" ]] || return 0
	if [[ ! -e "$final" && ! -L "$final" ]]; then
		engine_log "panel: recovering ${aside} -> ${final} (a reinstall was interrupted between its renames)"
		mv -T "$aside" "$final"
		return
	fi
	rm -rf "$aside"
}

# release_trees_recover — panel_promote_recover for every reinstall record under
# RELEASES_DIR. Called before a run or rollback reads the panel version.
release_trees_recover() {
	[[ -d "$RELEASES_DIR" ]] || return 0
	local m final b rc=0
	for m in "$RELEASES_DIR"/*.reinstall-swap "$RELEASES_DIR"/*.reinstall-prior "$RELEASES_DIR"/*.reinstall-discard; do
		[[ -e "$m" || -L "$m" ]] || continue
		final="${m%.reinstall-*}"
		if [[ "$m" == *.reinstall-discard ]]; then
			# Only ever a tree already replaced at the live path.
			ver_valid "${final##*/}" && [[ -e "$final" ]] && rm -rf "$m"
			continue
		fi
		b="${final##*/}"
		ver_valid "$b" || continue   # never build a path from an unchecked name
		panel_promote_recover "$final" || { engine_log "panel: could not recover the interrupted reinstall of ${final}"; rc=1; }
	done
	return "$rc"
}

# panel_preflip_abort <final> — stage_panel failed after raising the schema-pending
# gate and stopping the workers, but BEFORE the flip: the live release is the one
# that was running (a reinstall's prior tree is put back first), the database is
# untouched, and nothing the gate protects has happened. Restart the workers and
# drop the gate. The auto-rollback that follows owns any package changes and raises
# its own gate. Leaving this gate up was the second half of shcp-updater#183: a
# failed promotion with nothing else to roll back ended `failed` with the panel
# 503-gated as schema-pending until an operator cleared it by hand.
# If a reinstall's prior tree cannot be restored the gate STAYS UP (SC-318).
panel_preflip_abort() {
	local final="$1"
	if ! panel_promote_restore "$final"; then
		engine_log "panel: the prior release tree could not be restored at ${final} — maintenance gate LEFT UP (SC-318)"
		panel_workers_start || true
		return 0
	fi
	panel_workers_start || true
	maintenance_clear_if_mine "$CURRENT_RUN_ID"
	panel_timers_release_unless_gated || true
	return 0
}

# --- panel timers held across the code switch (shcp-updater#302) ---------------
# Between the release flip (stage_panel) and schema:update (stage_db) the NEW
# code runs on the OLD schema. The workers are stopped for that window and the
# panel's reads are gated, but the panel's timers were not: a job that fired
# there ran the new console against the old schema, failed, and left its unit
# failed until its next run (a daily job stayed red for a day), having possibly
# done part of its work first. The same holds in reverse while a rollback puts
# the old code and the snapshot back.
#
# So the timers are stopped for the window and started again after it: by
# stage_db once the schema is the live code's, and otherwise by whatever puts
# the box back in service. The rule the EXIT trap applies: timers still held
# when the engine exits stay held only while the maintenance gate is this
# run's, or says `schema-pending`. That is a run that died inside the window,
# or a rollback that did not converge: the database is in a state nobody has
# proven, the workers are deliberately left stopped, and a panel job firing
# there is the failure this exists to prevent. The timers follow the workers.
# Finishing the run (`resume`), a rollback that converges and
# `maintenance --clear` start them, and a reboot starts every enabled timer
# regardless (a stop is never a disable or a mask).
#
# Which timers: every ACTIVE shcp-*.timer whose service starts the panel's own
# code (shcpd's php-cli, or the shcp console wrapper), read from systemd. No
# list of names to drift from the installer's; the engine's own timers and the
# firewall guards run other binaries and are never touched. Only a timer this
# run found active is stopped, and only those are started again, so one an
# operator had stopped stays stopped. The set is journaled BEFORE the first
# stop: a kill between the two leaves a timer that is started needlessly, never
# one that is stopped and forgotten.
#
# Best effort by design: a timer that cannot be listed or stopped is logged and
# the run goes on. Refusing an update over it would cost more than the job it
# might break.
PANEL_TIMER_RE='^shcp-[a-z0-9]+(-[a-z0-9]+)*\.timer$'

panel_timers_active() {
	local listing line t svc exec
	command -v systemctl >/dev/null 2>&1 || return 0
	listing="$(systemctl list-units --type=timer --state=active --plain --no-legend 'shcp-*.timer' 2>/dev/null)" || return 1
	while IFS= read -r line; do
		t="${line%%[[:space:]]*}"
		[[ "$t" =~ $PANEL_TIMER_RE ]] || continue
		svc="$(systemctl show -p Unit --value "$t" 2>/dev/null)" || return 1
		[[ "$svc" =~ ^shcp-[a-z0-9]+(-[a-z0-9]+)*\.service$ ]] || continue
		exec="$(systemctl show -p ExecStart --value "$svc" 2>/dev/null)" || return 1
		case "$exec" in
			*"path=${SHCPD_BIN} ;"*|*"path=${SHCP_CONSOLE_WRAPPER} ;"*) printf '%s\n' "$t" ;;
		esac
	done <<<"$listing"
	return 0
}

panel_timers_hold() {
	local run_id="${1:-$CURRENT_RUN_ID}" active t
	if ! active="$(panel_timers_active)"; then
		engine_log "timers: the panel's timers could not be listed — none held; a job may fire on the wrong schema (shcp-updater#302)"
		return 0
	fi
	[[ -n "$active" ]] || return 0
	journal_update "$run_id" '.timers.held = (((.timers.held // []) + $t) | unique)' \
		--argjson t "$(jq -Rsc 'split("\n") | map(select(length > 0))' <<<"$active")"
	while IFS= read -r t; do
		systemctl stop "$t" >/dev/null 2>&1 \
			|| engine_log "timers: could not stop ${t}; its job may fire on the wrong schema"
	done <<<"$active"
	return 0
}

# panel_timers_release [run-id] — start what this run stopped. A timer whose
# unit is gone (the new release dropped it) is forgotten; one that will not
# start stays journaled, so the next release retries it. Non-zero if any did not
# start.
panel_timers_release() {
	local run_id="${1:-$CURRENT_RUN_ID}" jf held t unstarted=()
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" ]] || return 0
	held="$(jq -r '.timers.held[]? // empty' "$jf" 2>/dev/null)" || return 0
	[[ -n "$held" ]] || return 0
	while IFS= read -r t; do
		[[ "$t" =~ $PANEL_TIMER_RE ]] || continue
		systemctl cat "$t" >/dev/null 2>&1 || { engine_log "timers: ${t} is no longer installed; not started"; continue; }
		systemctl start "$t" >/dev/null 2>&1 || unstarted+=("$t")
	done <<<"$held"
	journal_update "$run_id" '.timers.held = $f | .timers.released_at = $now' --arg now "$(now_utc)" \
		--argjson f "$(printf '%s\n' "${unstarted[@]+"${unstarted[@]}"}" | jq -Rsc 'split("\n") | map(select(length > 0))')"
	(( ${#unstarted[@]} == 0 )) && return 0
	engine_log "timers: FAILED to start ${unstarted[*]} — start them by hand: systemctl start ${unstarted[*]}"
	return 1
}

# panel_timers_release_unless_gated [run-id] — release, except while the gate
# is still up for this run or says the schema is pending: the box is not back
# in service. Then say so, with the way out, whenever timers are really held.
# Another owner's flag without that marker (an operator's `maintenance --set`)
# holds nothing.
panel_timers_release_unless_gated() {
	local run_id="${1:-$CURRENT_RUN_ID}" flag held
	if [[ -f "$MAINT_FLAG" ]]; then
		flag="$(jq -r '"\(.run_id // "")|\(.phase // "")"' "$MAINT_FLAG" 2>/dev/null || true)"
		if [[ "${flag%%|*}" == "$run_id" || "${flag##*|}" == schema-pending ]]; then
			held="$(jq -r '(.timers.held // []) | join(" ")' "$(journal_path "$run_id")" 2>/dev/null || true)"
			[[ -z "$held" ]] || engine_log "timers: the panel's timers stay STOPPED while the maintenance gate is up (${held}). They start when the run is completed (\`shcp-update resume\`) or rolled back (\`shcp-update rollback ${run_id}\`), or, once the panel is known good, with \`shcp-update maintenance --clear\`."
			return 0
		fi
	fi
	panel_timers_release "$run_id"
}

# Restart the workers the panel and db stages stopped. Three callers, all of them
# real: stage_panel's failure paths (a failed flip must not leave the box with
# its queues dead), stage_restart at the end of the window (§4.2-6), and
# rollback_run step 5 — which is the one §4.2 assigns worker restoration to, and
# which calls this ONLY when the database is in a known state. See the branch
# there for why a half-migrated, unrestorable schema leaves them stopped instead.
#
# RETURNS NON-ZERO when a unit would not start, and names the units in
# PANEL_WORKERS_FAILED. It used to swallow every failure with `|| true` while
# rollback_run asserted `workers_started=true` right after calling it — so the
# outcome predicate's worker term was a constant, and a rollback that left the
# root Messenger worker dead (bad unit file after a downgrade, missing
# dependency, masked unit) still reported `rolled_back` and dropped the
# maintenance gate. Callers that genuinely do not care still say `|| true`, but
# they say it themselves.
PANEL_WORKERS_FAILED=()
PANEL_WORKER_SNAPSHOT=()
PANEL_MESSENGER_UNITS=()

panel_messenger_inventory() {
	local inventory line unit
	PANEL_MESSENGER_UNITS=(shcp-worker.service shcp-backup-worker.service
		shcp-upload-cleanup.service shcp-verify-worker.service shcp-webhook-worker.service
		shcp-mailhealth-worker.service shcp-transfer-worker.service shcp-audit-forward-worker.service
		shcp-dbio-worker.service shcp-privileged-slow-worker.service)
	local release managed managed_rc=0
	release="$(panel_link_target)"
	if [[ -n "$release" ]]; then
		managed="$(managed_worker_inventory "$release")" || managed_rc=$?
		(( managed_rc == 0 )) || return 1
		while IFS= read -r unit; do [[ -n "$unit" ]] && PANEL_MESSENGER_UNITS+=("$unit"); done <<<"$managed"
	fi
	inventory="$(systemctl list-units --all --type=service --plain --no-legend 'shcp-cron-worker@*.service' 2>/dev/null)" || {
		engine_log "workers: systemd cron-worker inventory failed"
		return 1
	}
	while IFS= read -r line; do
		[[ -n "$line" ]] || continue
		unit="${line%%[[:space:]]*}"
		[[ "$unit" =~ ^shcp-cron-worker@[^[:space:]@/]+\.service$ ]] || {
			engine_log "workers: malformed cron-worker inventory record: ${line}"
			return 1
		}
		PANEL_MESSENGER_UNITS+=("$unit")
	done <<<"$inventory"
	mapfile -t PANEL_MESSENGER_UNITS < <(printf '%s\n' "${PANEL_MESSENGER_UNITS[@]}" | awk '!seen[$0]++')
}

panel_messenger_units() {
	panel_messenger_inventory || return 1
	printf '%s\n' "${PANEL_MESSENGER_UNITS[@]}"
}

panel_worker_unit_allowed() {
	case "$1" in
		shcp-worker.service|shcp-backup-worker.service|shcp-upload-cleanup.service|shcp-verify-worker.service|shcp-webhook-worker.service|shcp-mailhealth-worker.service|shcp-transfer-worker.service|shcp-audit-forward-worker.service|shcp-dbio-worker.service|shcp-privileged-slow-worker.service) return 0 ;;
		shcp-*-worker.service) [[ "$1" =~ ^shcp-[a-z0-9]+(-[a-z0-9]+)*-worker\.service$ ]] ;;
		shcp-cron-worker@*.service) [[ "$1" =~ ^shcp-cron-worker@[^[:space:]@/]+\.service$ ]] ;;
		*) return 1 ;;
	esac
}

panel_worker_state() {
	local state
	state="$(systemctl is-active "$1" 2>/dev/null)" || true
	state="${state%%$'\n'*}"
	case "$state" in active|activating|reloading|deactivating|inactive|failed|unknown) printf '%s' "$state" ;; *) return 1 ;; esac
}

# worker_unit_armed <unit> -- 0 iff systemd is set to keep this unit running:
# is-enabled reports enabled or enabled-runtime. disabled, masked, static, indirect
# and generated all return non-zero, so an operator's disable/mask is honoured and a
# unit systemd would not start on its own is never force-started (shcp-updater#258).
worker_unit_armed() {
	local en
	en="$(systemctl is-enabled "$1" 2>/dev/null || true)"
	en="${en%%$'\n'*}"
	case "$en" in enabled|enabled-runtime) return 0 ;; *) return 1 ;; esac
}

# worker_enable_state <unit.service> -- `systemctl is-enabled`, first line verbatim;
# "unknown" when systemd says nothing. Recorded in the preflight worker baseline
# so health can tell an operator's disable from one this run made (SC-757).
worker_enable_state() {
	local en
	en="$(systemctl is-enabled "$1" 2>/dev/null || true)"
	en="${en%%$'\n'*}"
	printf '%s' "${en:-unknown}"
}

# preflight_workers_baseline_json -- the worker inventory as preflight saw it:
# {"<unit>": {"active": <is-active>, "enabled": <is-enabled>}}, keyed without
# .service. `null` when there is no systemctl or the inventory cannot be read,
# which health reads as "unattributable", never as "was fine". This is what
# stage_health judges workers against (SC-757): a worker stopped before the run
# that the run left stopped is reported, not failed.
preflight_workers_baseline_json() {
	command -v systemctl >/dev/null 2>&1 || { printf 'null'; return 0; }
	local units u
	units="$(panel_messenger_units 2>/dev/null)" || { printf 'null'; return 0; }
	while IFS= read -r u; do
		[[ -n "$u" ]] || continue
		printf '%s\t%s\t%s\n' "${u%.service}" "$(unit_state "${u%.service}")" "$(worker_enable_state "$u")"
	done <<<"$units" | jq -R -s -c '
		split("\n") | map(select(length > 0) | split("\t")) | map(select(length == 3))
		| map({(.[0]): {active: .[1], enabled: .[2]}}) | add // {}'
}

panel_workers_snapshot_once() {
	local run_id="${1:-$CURRENT_RUN_ID}" jf unit enabled active snapshot_lines state
	jf="$(journal_path "$run_id")"
	PANEL_WORKER_SNAPSHOT=()
	if jq -e '.workers.snapshot | type == "array"' "$jf" >/dev/null 2>&1; then
		snapshot_lines="$(jq -er '.workers.snapshot[] | [.unit,(.enabled|tostring),(.active|tostring)] | join("|")' "$jf")" || return 1
		while IFS='|' read -r unit enabled active; do
			panel_worker_unit_allowed "$unit" || return 1
			[[ "$enabled" =~ ^(true|false)$ && "$active" =~ ^(true|false)$ ]] || return 1
			PANEL_WORKER_SNAPSHOT+=("${unit}|${enabled}|${active}")
		done <<<"$snapshot_lines"
		(( ${#PANEL_WORKER_SNAPSHOT[@]} > 0 )) || return 1
		return 0
	fi
	panel_messenger_inventory || return 1
	local -a snapshot_json=()
	for unit in "${PANEL_MESSENGER_UNITS[@]}"; do
		enabled=false; active=false
		systemctl is-enabled "$unit" >/dev/null 2>&1 && enabled=true
		state="$(panel_worker_state "$unit")" || { engine_log "workers: could not prove ${unit} state"; return 1; }
		case "$state" in active|activating|reloading|deactivating) active=true ;; esac
		PANEL_WORKER_SNAPSHOT+=("${unit}|${enabled}|${active}")
		snapshot_json+=("$(jq -nc --arg u "$unit" --argjson e "$enabled" --argjson a "$active" '{unit:$u,enabled:$e,active:$a}')")
	done
	journal_update "$run_id" '.workers.snapshot = $s' --argjson s "$(printf '%s\n' "${snapshot_json[@]}" | jq -s -c '.')"
}

# Resolved /etc unit path of an auxiliary worker unit (env override for tests).
aux_worker_unit_path() {
	case "$1" in
		shcp-webhook-worker)    printf '%s' "${SHCP_UPDATE_WEBHOOK_WORKER_UNIT:-/etc/systemd/system/shcp-webhook-worker.service}" ;;
		shcp-mailhealth-worker) printf '%s' "${SHCP_UPDATE_MAILHEALTH_WORKER_UNIT:-/etc/systemd/system/shcp-mailhealth-worker.service}" ;;
		shcp-transfer-worker)   printf '%s' "${SHCP_UPDATE_TRANSFER_WORKER_UNIT:-/etc/systemd/system/shcp-transfer-worker.service}" ;;
		shcp-audit-forward-worker) printf '%s' "${SHCP_UPDATE_AUDIT_FORWARD_WORKER_UNIT:-/etc/systemd/system/shcp-audit-forward-worker.service}" ;;
		shcp-dbio-worker)       printf '%s' "${SHCP_UPDATE_DBIO_WORKER_UNIT:-/etc/systemd/system/shcp-dbio-worker.service}" ;;
		*) return 1 ;;
	esac
}

panel_worker_target_compatible() {
	case "$1" in
		shcp-upload-cleanup.service) [[ -f "$UPLOAD_CLEANUP_UNIT_PATH" && ! -L "$UPLOAD_CLEANUP_UNIT_PATH" ]] ;;
		shcp-verify-worker.service) [[ -f "${VERIFY_WORKER_UNIT_PATH:-/nonexistent}" && ! -L "${VERIFY_WORKER_UNIT_PATH:-/nonexistent}" ]] ;;
		shcp-webhook-worker.service|shcp-mailhealth-worker.service|shcp-transfer-worker.service|shcp-audit-forward-worker.service|shcp-dbio-worker.service) local p; p="$(aux_worker_unit_path "${1%.service}")"; [[ -f "$p" && ! -L "$p" ]] ;;
		shcp-privileged-slow-worker.service) local p; p="$(managed_worker_unit_path "$1")"; [[ -f "$p" && ! -L "$p" ]] ;;
		*) return 0 ;;
	esac
}
panel_workers_start() {
	local restore_run_id="${1:-}" os_only_restore="${2:-false}" cleanup_enabled=true cleanup_active=true s rc=0
	PANEL_WORKERS_FAILED=()
	(( ${#PANEL_WORKER_SNAPSHOT[@]} > 0 )) || panel_workers_snapshot_once "$restore_run_id"
	# Rollback restores the target's journaled cleanup state before any start is
	# issued. active=false must never transiently activate this receiver: a
	# start-then-stop window can consume a queued message.
	# Only the panel stage snapshots it. A run that never reached that stage (a
	# packages-only security run) has no journaled state: a missing snapshot is
	# not "disabled and stopped", and reading it so left the unit dead after
	# every such rollback (shcp-master#3224). The worker snapshot covers it.
	local restore_jf=""
	[[ -n "$restore_run_id" ]] && restore_jf="$(journal_path "$restore_run_id")"
	if [[ -n "$restore_run_id" && -f "$UPLOAD_CLEANUP_UNIT_PATH" ]] \
			&& jq -e '.upload_cleanup.snapshot != null' "$restore_jf" >/dev/null 2>&1; then
		cleanup_enabled="$(jq -r '.upload_cleanup.snapshot.enabled // false' "$restore_jf" 2>/dev/null || echo false)"
		cleanup_active="$(jq -r '.upload_cleanup.snapshot.active // false' "$restore_jf" 2>/dev/null || echo false)"
		if [[ "$cleanup_enabled" == true ]]; then
			systemctl enable "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
		else
			systemctl disable "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
		fi
		if [[ "$cleanup_active" != true ]]; then
			systemctl stop "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
			[[ "$(unit_state shcp-upload-cleanup)" != active ]] || return 1
		fi
	fi
	if [[ -f "$UPLOAD_CLEANUP_UNIT_PATH" && "$cleanup_active" == true ]] \
			&& ! printf '%s\n' "${PANEL_WORKER_SNAPSHOT[@]}" | grep -q '^shcp-upload-cleanup.service|[^|]*|true$'; then
		PANEL_WORKER_SNAPSHOT+=("shcp-upload-cleanup.service|true|true")
	fi
	# An installed unit is not proof that it was running before this run.
	if [[ -n "$restore_run_id" && -f "${VERIFY_WORKER_UNIT_PATH:-/nonexistent}" ]] \
			&& ! printf '%s\n' "${PANEL_WORKER_SNAPSHOT[@]}" | grep -q '^shcp-verify-worker.service|' \
			&& jq -e '.verify_worker.snapshot.active == true' "$(journal_path "$restore_run_id")" >/dev/null 2>&1; then
		PANEL_WORKER_SNAPSHOT+=("shcp-verify-worker.service|true|true")
	fi
	local unit enabled active
	for s in "${PANEL_WORKER_SNAPSHOT[@]}"; do
		IFS='|' read -r unit enabled active <<<"$s"
		panel_worker_target_compatible "$unit" || continue
		[[ "$unit" != shcp-upload-cleanup.service || "$cleanup_active" == true ]] || active=false
		if [[ "$enabled" == true ]]; then
			systemctl enable "$unit" >/dev/null 2>&1 || { PANEL_WORKERS_FAILED+=("${unit%.service}"); rc=1; continue; }
		elif [[ "$enabled" == false ]]; then
			if systemctl is-enabled "$unit" >/dev/null 2>&1; then
				systemctl disable "$unit" >/dev/null 2>&1 || { PANEL_WORKERS_FAILED+=("${unit%.service}"); rc=1; continue; }
			fi
		fi
		[[ "$active" == true ]] || continue
		[[ "$os_only_restore" != true || "$enabled" == true ]] || continue
		if ! systemctl start "$unit" >/dev/null 2>&1; then
			PANEL_WORKERS_FAILED+=("${unit%.service}")
			rc=1
		fi
	done
	return $rc
}

panel_workers_stop() {
	local s state rc=0
	(( ${#PANEL_WORKER_SNAPSHOT[@]} > 0 )) || panel_workers_snapshot_once
	panel_messenger_inventory || return 1
	for s in "${PANEL_MESSENGER_UNITS[@]}"; do
		state="$(panel_worker_state "$s")" || { engine_log "workers: could not prove ${s} state"; rc=1; continue; }
		# An optional consumer on a box that predates it is not loaded: `systemctl is-active`
		# prints `inactive` (rc 4), so `stop` would fail rc 5 ("Unit not loaded") and abort the
		# stage with the main workers already down. Skip a not-loaded unit — but ONLY when it is
		# also not running. A unit whose fragment is gone yet whose process is still up (LoadState
		# not-found, ActiveState active) must still be stopped, or a root consumer survives the
		# flip and the DB restore.
		if ! systemctl cat "$s" >/dev/null 2>&1; then
			case "$state" in inactive|failed|unknown) continue ;; esac
		fi
		case "$state" in unknown) continue ;; esac
		if ! systemctl stop "$s" >/dev/null 2>&1; then
			engine_log "workers: failed to stop ${s}"
			rc=1
			continue
		fi
		state="$(panel_worker_state "$s")" || { engine_log "workers: could not re-prove ${s} state after stop"; rc=1; continue; }
		case "$state" in inactive|failed|unknown) ;; *) engine_log "workers: ${s} remains ${state}"; rc=1 ;; esac
	done
	return $rc
}

# The release directory /opt/shcp currently resolves to, or empty.
panel_link_target() {
	[[ -e "$PANEL_LINK" ]] || return 0
	readlink -f "$PANEL_LINK" 2>/dev/null || true
}

# True when the panel symlink really points at $1. This is the ground truth for
# "did the flip happen" — see the gate in stage_db for why neither `.panel` nor
# `.panel.flipped` can answer it on their own.
panel_link_points_at() {
	local want="$1" have
	[[ -n "$want" ]] || return 1
	have="$(panel_link_target)"
	[[ -n "$have" ]] || return 1
	# Compare resolved to resolved: .panel.release_dir is written unresolved.
	local want_resolved
	want_resolved="$(readlink -f "$want" 2>/dev/null || printf '%s' "$want")"
	[[ "$have" == "$want_resolved" ]]
}

# --- manifest freshness / anti-replay (SC-472) --------
# Canonicalize an ISO-8601 UTC stamp (exactly what now_utc emits — YYYY-MM-
# DDTHH:MM:SSZ, the shape the manifest generator writes and the fixture carries)
# to one comparable integer. Refuses any other shape: a freshness floor is only
# monotonic if what it compares is a fixed-width sortable stamp, and feeding an
# unvalidated string to `date -d` is a parser surface a SIGNED-but-hostile
# manifest field should never reach. Non-zero + empty output on a malformed stamp.
ts_canon_num() {
	local t="$1"
	[[ "$t" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || return 1
	printf '%s' "${t//[-:TZ]/}"
}

manifest_floor_write() {
	printf '%s\n' "$1" | atomic_write "$MANIFEST_GENERATED_AT_FILE" 0644
}

# SC-472: refuse a fetched manifest whose generated_at
# is strictly OLDER than the newest one already accepted. Equal is accepted (the
# orchestrator re-signs unchanged manifests with the same stamp); newer is
# accepted and advances the floor. The generator always emits generated_at, so
# a manifest with a missing or malformed stamp is refused like any other
# malformed manifest. The floor advances ONLY here, on a document
# that has already passed gpgv + the pinned-primary VALIDSIG check, so a bad
# signature never moves it. This is the panel's SC-353 mirrored on the engine
# side, which renders privileged Priority-1001 apt pins with no working panel.
manifest_replay_ok() {
	local mf="$1" got stored got_num floor_num
	got="$(jq -r '.generated_at // empty' "$mf" 2>/dev/null || true)"
	stored=""
	[[ -r "$MANIFEST_GENERATED_AT_FILE" ]] \
		&& stored="$(tr -d '[:space:]' <"$MANIFEST_GENERATED_AT_FILE" 2>/dev/null || true)"

	if [[ -z "$got" ]]; then
		engine_log "manifest carries no generated_at — refusing (SC-472)"
		return 1
	fi
	if ! got_num="$(ts_canon_num "$got")"; then
		engine_log "manifest generated_at '${got}' is malformed — refusing (SC-472)"
		return 1
	fi
	if [[ -z "$stored" ]]; then
		# No floor yet: seed it from this signature-valid, well-formed stamp.
		manifest_floor_write "$got"
		return 0
	fi
	if ! floor_num="$(ts_canon_num "$stored")"; then
		# A corrupt stored floor would otherwise refuse every manifest forever;
		# re-seed from this signature-valid document instead.
		manifest_floor_write "$got"
		return 0
	fi
	if (( 10#$got_num < 10#$floor_num )); then
		engine_log "manifest generated_at ${got} is older than the accepted floor ${stored} — refusing replay (SC-472)"
		return 1
	fi
	(( 10#$got_num > 10#$floor_num )) && manifest_floor_write "$got"
	return 0
}

# --- signed manifest + artifact verification (SC-064/207, SC-067/115, SC-122) --
# Fetch and verify the versions manifest. Mirrors the panel's
# UpdateManifestClient exactly where it matters: identity encoding (so a gzip
# bomb cannot expand past the cap), a size cap enforced BEFORE any parse, gpgv
# against the installer-exported keyring, and a VALIDSIG assertion on the PRIMARY
# fingerprint — verifying "some valid signature" is not the check, chaining to
# the pinned release primary is.
# EP-2 schema negotiation (#138). A manifest whose schema is newer than this
# engine speaks must not be applied — fail closed before any value is rendered
# from it. A pure-JSON check (no crypto) so it is unit-testable on its own, run
# from _manifest_fetch_impl on a document already proven signed + shaped. Kept a
# separate function for the same reason manifest_replay_ok is. (min_bootstrap_version
# is the installer stub's contract, not the engine's — deliberately not gated here.)
manifest_schema_ok() {
	local mf="$1" msv
	msv="$(jq -r 'if (.schema_version | type) == "number" and (.schema_version == (.schema_version | floor)) then .schema_version else empty end' "$mf" 2>/dev/null || true)"
	if [[ -z "$msv" ]]; then
		engine_log "manifest carries no integer schema_version — refusing (#138)"
		return 1
	fi
	# jq prints a large integral number as `1e+300`, which passes the integer
	# test above but is not a bash integer: `(( msv > MAX ))` then errors, the
	# `if` reads that as false, and the manifest was ACCEPTED — the gate failed
	# open on exactly the shape it exists to refuse. Only plain decimal digits
	# reach the comparison, and only enough of them to stay inside bash's
	# signed 64-bit arithmetic (a 30-digit value would wrap, possibly negative).
	if ! [[ "$msv" =~ ^[0-9]{1,9}$ ]]; then
		engine_log "manifest schema_version '${msv}' is not a plain decimal integer — refusing (#138)"
		return 1
	fi
	if (( msv > MANIFEST_SCHEMA_MAX )); then
		engine_log "manifest schema_version ${msv} is newer than the ${MANIFEST_SCHEMA_MAX} this engine speaks — refusing (#138)"
		return 1
	fi
	return 0
}

# EP-2 manifest-fetch liveness (#138, SC-633). SC-319 keys off the apt-derived
# last_security_success and so never observes the manifest endpoint: an
# unreachable, mis-certed, unsigned, or un-negotiable manifest leaves
# _manifest_fetch_impl returning non-zero with only a debug log, and the
# default scopes DEGRADE past it (cmd_preflight) — so panel-update checks and
# the withdrawal lever go blind SILENTLY. These stamps let `check` age that
# blindness and raise a loud, distinct advisory. Best-effort: a stamp that
# cannot be written must never fail the fetch it is only observing.
manifest_fetch_stamp_success() {
	local dir; dir="$(dirname "$MANIFEST_FETCH_STATE_FILE")"
	mkdir -p "$dir" 2>/dev/null || return 0
	jq -nc --arg at "$(now_utc)" '{last_success: $at, failing_since: null}' \
		| atomic_write "$MANIFEST_FETCH_STATE_FILE" 0644 2>/dev/null || true
	return 0
}

manifest_fetch_stamp_failure() {
	local dir since ok; dir="$(dirname "$MANIFEST_FETCH_STATE_FILE")"
	mkdir -p "$dir" 2>/dev/null || return 0
	# Preserve the EARLIEST unbroken failure so the age spans the whole outage,
	# not just the latest attempt. Only the first failure after a success sets it.
	since=""; ok=""
	if [[ -r "$MANIFEST_FETCH_STATE_FILE" ]]; then
		since="$(jq -r '.failing_since // empty' "$MANIFEST_FETCH_STATE_FILE" 2>/dev/null || true)"
		ok="$(jq -r '.last_success // empty' "$MANIFEST_FETCH_STATE_FILE" 2>/dev/null || true)"
	fi
	[[ -n "$since" ]] || since="$(now_utc)"
	jq -nc --arg ok "$ok" --arg since "$since" \
		'{last_success: (if $ok == "" then null else $ok end), failing_since: $since}' \
		| atomic_write "$MANIFEST_FETCH_STATE_FILE" 0644 2>/dev/null || true
	return 0
}

_manifest_fetch_impl() {
	local out="$1" tmp sig status
	tmp="$(mktemp)" || return 1
	sig="$(mktemp)" || { rm -f "$tmp"; return 1; }

	if ! curl -fsSL --max-time 30 --max-filesize "$MAX_MANIFEST_BYTES" \
			-H 'Accept-Encoding: identity' "$MANIFEST_URL" -o "$tmp" \
		|| ! curl -fsSL --max-time 30 --max-filesize "$MAX_MANIFEST_BYTES" \
			-H 'Accept-Encoding: identity' "${MANIFEST_URL}.sig" -o "$sig"; then
		engine_log "manifest fetch failed"
		rm -f "$tmp" "$sig"
		return 1
	fi
	# --max-filesize only fires when the server declares Content-Length, so the
	# cap is re-checked against what actually landed.
	if [[ "$(stat -c %s "$tmp")" -gt "$MAX_MANIFEST_BYTES" ]]; then
		engine_log "manifest exceeds ${MAX_MANIFEST_BYTES} bytes"
		rm -f "$tmp" "$sig"
		return 1
	fi
	if [[ ! -r "$RELEASE_KEYRING" ]]; then
		# The installer writes this file on every box, so a missing one means the
		# box was tampered with or damaged. The engine never materialises a trust
		# anchor itself (SC-530): the remedy is a reinstall.
		engine_log "release keyring ${RELEASE_KEYRING} missing — the installer writes it on every box, so this box is tampered or damaged: reinstall (the engine never materialises a trust anchor)"
		rm -f "$tmp" "$sig"
		return 1
	fi
	status="$(gpgv --keyring "$RELEASE_KEYRING" --status-fd 1 "$sig" "$tmp" 2>/dev/null)" || {
		engine_log "manifest signature verification failed"
		rm -f "$tmp" "$sig"
		return 1
	}
	if ! grep -qE "^\[GNUPG:\] VALIDSIG .*[[:space:]]${RELEASE_FPR}[[:space:]]*$" <<<"$status"; then
		engine_log "manifest verifies but not against the pinned release primary"
		rm -f "$tmp" "$sig"
		return 1
	fi
	if ! jq -e 'type == "object"' "$tmp" >/dev/null 2>&1; then
		engine_log "manifest is not a JSON object"
		rm -f "$tmp" "$sig"
		return 1
	fi
	# EP-2 (#138): refuse a manifest this engine cannot understand BEFORE any
	# value is rendered from it — schema too new, or a bootstrap floor we do not
	# meet. Fail closed, like the shape and anti-replay gates that bracket it.
	if ! manifest_schema_ok "$tmp"; then
		rm -f "$tmp" "$sig"
		return 1
	fi
	# Anti-replay LAST, so it runs on a document already proven signed+shaped, and
	# BEFORE the verified bytes are handed to any consumer (panel_target, or the
	# S3 daemon_pins pin re-render). SC-472.
	if ! manifest_replay_ok "$tmp"; then
		rm -f "$tmp" "$sig"
		return 1
	fi
	# atomic_write, NOT `mv -f "$tmp" "$out"`: $tmp lives in the unit's
	# PrivateTmp and $out is runs/<id>/manifest.json under /var/lib — different
	# filesystems, so that mv was a copy+unlink, and a kill mid-copy left a
	# TRUNCATED manifest that the later `-s` re-checks (stage_panel, the pin
	# readers) accept and jq then fails on with a misleading "series not in
	# manifest". atomic_write stages beside $out and rename(2)s into place, so
	# the name either holds the whole verified document or nothing.
	if ! atomic_write "$out" <"$tmp"; then
		rm -f "$tmp" "$sig"
		return 1
	fi
	rm -f "$tmp" "$sig"
	return 0
}

# Public entry: run the fetch, then stamp liveness exactly ONCE from its
# result (#138). One recording site keeps the impl's many failure exits free
# of per-branch bookkeeping — a usable manifest was produced, or it was not.
manifest_fetch() {
	if _manifest_fetch_impl "$@"; then
		manifest_fetch_stamp_success
		return 0
	fi
	manifest_fetch_stamp_failure
	return 1
}

# Resolve the panel release to install, or nothing. Emits a compact JSON object
# {version,url,sig_url,sha256}. Every gate that says "no" here is a gate that
# exists because saying "yes" wrongly is unrecoverable on a customer's box.
panel_target() {
	local manifest="$1" current="$2" scope="$3" want_version="$4" want_series="$5"
	local reinstall="${6:-0}"
	local series entry

	# Series selection. A series upgrade is ALWAYS manual (AD-10), so the default
	# is the running series and a different one only via an explicit request.
	if [[ -n "$want_series" ]]; then
		series="$want_series"
	elif ver_valid "$current"; then
		series="$(ver_series "$current")"
	else
		engine_log "panel target: current version unknown and no series requested"
		return 1
	fi

	entry="$(jq -c --arg s "$series" \
		'[.series[]? | select(.series == $s)] | first // empty' "$manifest" 2>/dev/null || true)"
	if [[ -z "$entry" ]]; then
		engine_log "panel target: series ${series} is not in the manifest"
		return 1
	fi

	# SC-207: only a security-maintained series is installable. An unsupported
	# or EOL series must not be silently upgraded into.
	#
	# `security` is INSTALLABLE, not excluded. The manifest's three states are
	# supported -> security -> unsupported (§1, §4.7, SC-207's Rule), and
	# `security` is the state a series is in while it is receiving security-only
	# point releases. Refusing it stops a box taking its patches at exactly the
	# moment those patches are the only ones being published for it — the
	# inverse of what this gate is for. The other two implementations of this
	# same rule already accept both (shcp-installer/shcp.sh `supported|security)`
	# and shcp-base ManifestSeriesEntry::isInstallable), so the engine was the
	# odd one out and the disagreement was invisible: nothing cross-checks them.
	local status; status="$(jq -r '.status // empty' <<<"$entry")"
	case "$status" in
		supported|security) ;;
		*)
			engine_log "panel target: series ${series} status is '${status:-unknown}', not installable (SC-207)"
			return 1 ;;
	esac

	local version url sig_url sha256 critical min_updater receivers installed_raw installed_updater
	version="$(jq -r '.version // empty' <<<"$entry")"
	if ! ver_valid "$version"; then
		engine_log "panel target: series ${series} carries no usable version"
		return 1
	fi

	# An explicit target must be the version the manifest actually publishes for
	# that series — the engine never constructs a download URL from a requested
	# string (SC-317: no panel-supplied value reaches a command or a URL).
	if [[ -n "$want_version" && "$want_version" != "$version" ]]; then
		engine_log "panel target: requested ${want_version} but series ${series} publishes ${version}"
		return 1
	fi

	# SC-064 ratchet: never below the recorded floor, so a downgrade cannot be
	# replayed by serving an older (still validly signed) manifest.
	if [[ -r "$VERSION_FLOOR_FILE" ]]; then
		local floor; floor="$(tr -d '[:space:]' <"$VERSION_FLOOR_FILE" 2>/dev/null || true)"
		if ver_valid "$floor" && ver_gt "$floor" "$version"; then
			engine_log "panel target: ${version} is below the version floor ${floor} (SC-064)"
			return 1
		fi
	fi

	# Nothing to do when the manifest is not ahead of us. Equal is a no-op, not a
	# reinstall — REINSTALL is its own explicit verb, and this is where that verb
	# is actually honoured. Until UPD-8 the comment described behaviour nothing
	# implemented: `apply --reinstall` journaled `reinstall: true`, fell into this
	# guard, resolved no target, and completed "successfully" having re-extracted
	# nothing at all.
	if ver_valid "$current" && ! ver_gt "$version" "$current"; then
		if [[ "$reinstall" != "1" ]]; then
			return 1
		fi
		# A reinstall re-extracts the version ALREADY INSTALLED. It is never a
		# downgrade vehicle: if the manifest has moved BELOW us, `not ver_gt`
		# is also true and installing it would walk the panel backwards past the
		# SC-064 ratchet — the exact rollback-by-stale-manifest this engine
		# refuses everywhere else.
		if [[ "$version" != "$current" ]]; then
			engine_log "panel target: reinstall wants ${current} but series ${series} publishes ${version} — refusing (SC-064)"
			return 1
		fi
		engine_log "panel target: reinstall — re-extracting ${version}"
	fi

	# AD-12: a security-scope run installs a panel release ONLY when the release
	# is flagged critical. Ordinary point releases wait for a normal run.
	critical="$(jq -r '.app.critical // false' <<<"$entry")"
	if [[ "$scope" == "security" && "$critical" != "true" ]]; then
		engine_log "panel target: ${version} is not critical — skipped under scope=security (AD-12)"
		return 1
	fi

	url="$(jq -r '.app.url // empty' <<<"$entry")"
	sig_url="$(jq -r '.app.sig_url // empty' <<<"$entry")"
	sha256="$(jq -r '.app.sha256 // empty' <<<"$entry")"
	min_updater="$(jq -r '.app.min_updater_version // empty' <<<"$entry")"
	receivers="$(jq -c '.app.receivers // []' <<<"$entry" 2>/dev/null || true)"
	if ! jq -e 'type == "array" and all(.[]; type == "string")' <<<"$receivers" >/dev/null 2>&1; then
		engine_log "panel target: malformed app.receivers in the manifest"
		return 1
	fi
	if [[ -z "$min_updater" ]]; then
		if jq -e 'index("upload_cleanup") != null' <<<"$receivers" >/dev/null 2>&1; then
			engine_log "panel target: cleanup-capable artifact has no min_updater_version — refusing"
			return 1
		fi
	else
		if ! updater_version_valid "$min_updater"; then
			engine_log "panel target: malformed min_updater_version '${min_updater}'"
			return 1
		fi
		osf_detect_family
		# osf_selfupdate_installed reports the PACKAGED version ('1.100-1', or an
		# rpm EVR); reduce it to the bare contract.build[.conf] the manifest speaks before
		# validating/comparing, or every real box is refused its app (updater#127).
		installed_raw="${SHCP_UPDATE_ENGINE_VERSION:-$(osf_selfupdate_installed)}"
		installed_updater="$(updater_contract_version "$installed_raw")"
		if ! updater_version_valid "$installed_updater"; then
			engine_log "panel target: installed updater version '${installed_raw:-unknown}' is not a strict contract.build[.conf] version"
			return 1
		fi
		if ! updater_version_ge "$installed_updater" "$min_updater"; then
			engine_log "panel target: updater ${installed_raw} is older than required ${min_updater}"
			return 1
		fi
	fi
	# https only, and a sha256 that is actually a sha256 — a manifest field is
	# signed data, not trusted data, and a malformed one must fail here rather
	# than turn into a curl argument.
	if [[ "$url" != https://* || "$sig_url" != https://* ]]; then
		engine_log "panel target: non-https artifact URL in the manifest"
		return 1
	fi
	if [[ ! "$sha256" =~ ^[0-9a-fA-F]{64}$ ]]; then
		engine_log "panel target: malformed sha256 in the manifest"
		return 1
	fi

	jq -nc --arg v "$version" --arg u "$url" --arg s "$sig_url" --arg h "$sha256" \
		--arg m "$min_updater" --argjson r "$receivers" \
		'{version: $v, url: $u, sig_url: $s, sha256: $h,
		  min_updater_version: (if $m == "" then null else $m end), receivers: $r}'
	return 0
}

# SC-064 version-floor WRITER — the counterpart to the reader above in
# panel_target. stage_finalize calls this on EVERY health-green run that did not
# roll back (routine point releases too, not series runs only): the floor guards
# the panel version, and the common case is exactly where a downgrade-by-stale-
# manifest replay would otherwise stay possible. Advance-only — it never lowers
# the floor — and it compares with the SAME ver_gt the reader uses, so writer and
# reader can never disagree about ordering. Mirrors the installer's
# mark_installation_complete ratchet: two writers in two repos maintaining ONE
# file (VERSION_FLOOR_FILE) under one contract.
version_floor_advance() {
	local ver="$1" dir prev
	ver_valid "$ver" || return 0
	dir="$(dirname "$VERSION_FLOOR_FILE")"
	mkdir -p "$dir" 2>/dev/null || { engine_log "version floor: cannot create ${dir}"; return 1; }
	if [[ -r "$VERSION_FLOOR_FILE" ]]; then
		prev="$(tr -d '[:space:]' <"$VERSION_FLOOR_FILE" 2>/dev/null || true)"
		# Advance only: leave the floor alone when it is already >= this version.
		if ver_valid "$prev" && ! ver_gt "$ver" "$prev"; then
			return 0
		fi
	fi
	printf '%s\n' "$ver" | atomic_write "$VERSION_FLOOR_FILE" 0644
	engine_log "version floor advanced to ${ver} (SC-064)"
	return 0
}

# Download the tarball and prove it is what the signed manifest described, in
# this order: size cap, GPG signature (SC-067/115), sha256 (SC-122's companion),
# mime type, then member safety. Extraction happens only after all five.
#
# Transfer reuse (SC-355, shcp-updater#136). One run fetches the SAME panel
# candidate at up to three sites — console preflight, stage_panel's re-check,
# stage_panel's body — all resolving one path per (run id, version) through
# panel_candidate_cache_path. When that path already holds the tarball and its
# detached signature, both uid-0 regular files free of group/other write in a
# directory with the same property, and the tarball's sha256 equals the digest
# handed in, the DOWNLOAD is skipped. Only the download: the size cap, gpgv, the
# sha256 gate, the mime gate and the member gate all run on the reused bytes.
#
# gpgv is re-run on purpose. A digest match authenticates bytes only when the
# digest is itself authenticated, and on a resume it is not provably so: the
# re-check reads it back from journal.json and the body re-resolves it from the
# run's saved manifest.json, both under /var/lib/shcp, which the panel user owns
# — the root-owned update/ subtree can be renamed aside and replaced, and only a
# parked --at-boot resume vets ownership. A failed verification also leaves its
# unverified bytes at this path. Skipping gpgv would turn a forged digest plus
# such bytes into an unsigned release; re-running it costs a local hash.
#
# The verified bytes reach the caller as an OPEN FILE, never as the path. That
# same rename-aside hands the panel account the path itself: it can swap the tree
# between the last gate and the caller's tar, and every by-path open after the
# check would read its bytes as root. So the file is opened once, right after the
# download; every gate below reads that inode through /proc/self/fd, and on
# success it stays open — ARTIFACT_HELD names it for the caller's tar, and the
# caller MUST artifact_close once it has read what it needs. A rename cannot
# change an inode already held. On failure nothing is left open.
ARTIFACT_FD=""
ARTIFACT_HELD=""

artifact_close() {
	[[ -z "$ARTIFACT_FD" ]] || exec {ARTIFACT_FD}<&-
	ARTIFACT_FD="" ARTIFACT_HELD=""
	return 0
}

artifact_fetch_verify() {
	local url="$1" sig_url="$2" want_sha="$3" dest="$4"
	local sig="${dest}.sig" got_sha=""
	artifact_close

	# stat does not follow links, and a symlink always reports 0777, so a planted
	# link fails path_uid0_not_writable like any other writable file.
	if [[ -f "$dest" && -f "$sig" ]] \
			&& path_uid0_not_writable "$dest" && path_uid0_not_writable "$sig" \
			&& path_uid0_not_writable "$(dirname -- "$dest")"; then
		got_sha="$(sha256sum -- "$dest" 2>/dev/null | awk '{print $1}')"
	fi
	if [[ -n "$got_sha" && "${got_sha,,}" == "${want_sha,,}" ]]; then
		engine_log "artifact cache hit: ${dest} matches manifest sha256 — skipping the download, re-verifying"
	else
		# Never write THROUGH whatever is there: root's curl would follow a
		# planted symlink and clobber its target.
		rm -f -- "$dest" "$sig"
		# A link that dies mid-transfer sends nothing and closes nothing: without
		# the speed floor curl sat out the whole --max-time (10 minutes, holding the
		# update lock) before failing (#3337). Under 1 KiB/s for a minute is dead.
		if ! curl -fsSL --connect-timeout 15 --speed-limit 1024 --speed-time 60 \
				--max-time 600 --max-filesize "$MAX_ARTIFACT_BYTES" \
				-H 'Accept-Encoding: identity' "$url" -o "$dest"; then
			engine_log "artifact download failed: ${url}"
			return 1
		fi
		if ! curl -fsSL --max-time 60 -H 'Accept-Encoding: identity' "$sig_url" -o "$sig"; then
			engine_log "artifact signature download failed: ${sig_url}"
			return 1
		fi
		# Root-only, like the run log (SC-416): a tenant that guesses the run id
		# reads nothing, and the reuse test above demands no group/other write.
		chmod 0600 -- "$dest" "$sig" 2>/dev/null || true
	fi
	# Read-only: a FIFO planted since the download blocks this open, which costs
	# the run and nothing else; the regular-file gate refuses devices.
	if ! { exec {ARTIFACT_FD}<"$dest"; } 2>/dev/null; then
		ARTIFACT_FD=""
		engine_log "artifact: cannot open ${dest}"
		return 1
	fi
	ARTIFACT_HELD="/proc/self/fd/${ARTIFACT_FD}"
	artifact_verify_held "$sig" "$want_sha" && return 0
	artifact_close
	return 1
}

# The gates of artifact_fetch_verify, over the inode it holds. Every command
# re-opens ARTIFACT_HELD, which yields that inode whatever the path now names;
# stat and file need -L because the name is a /proc magic link.
artifact_verify_held() {
	local sig="$1" want_sha="$2" held="$ARTIFACT_HELD" got_sha magic
	if [[ ! -f "$held" ]]; then
		engine_log "artifact is not a regular file, or /proc/self/fd is unavailable — refusing"
		return 1
	fi
	if [[ "$(stat -L -c %s -- "$held")" -gt "$MAX_ARTIFACT_BYTES" ]]; then
		engine_log "artifact exceeds the size cap"
		return 1
	fi
	local status
	status="$(gpgv --keyring "$RELEASE_KEYRING" --status-fd 1 "$sig" "$held" 2>/dev/null)" || {
		engine_log "artifact signature verification failed"
		return 1
	}
	if ! grep -qE "^\[GNUPG:\] VALIDSIG .*[[:space:]]${RELEASE_FPR}[[:space:]]*$" <<<"$status"; then
		engine_log "artifact verifies but not against the pinned release primary"
		return 1
	fi
	got_sha="$(sha256sum -- "$held" | awk '{print $1}')"
	if [[ "${got_sha,,}" != "${want_sha,,}" ]]; then
		engine_log "artifact sha256 mismatch (manifest ${want_sha}, got ${got_sha})"
		return 1
	fi
	# SC-122: type-check before extraction. A signed artifact that is not a gzip
	# stream means the pipeline shipped the wrong thing, and tar should never be
	# the component that finds out. The check reads the two-byte gzip magic
	# (RFC 1952: 0x1f 0x8b) directly, NOT `file --mime-type`: file(1) is a
	# dependency of neither package (build-deb.sh/build-rpm.sh) and is absent
	# from minimal Debian and RHEL 10 images, where the old call printed
	# nothing and this gate refused EVERY panel update as "mime '', not gzip".
	# od/head are coreutils, which the engine already cannot run without.
	magic="$(head -c 2 -- "$held" 2>/dev/null | od -An -tx1 | tr -d ' \n')"
	if [[ "$magic" != "1f8b" ]]; then
		engine_log "artifact does not start with the gzip magic 1f8b (got '${magic:-<empty>}'), not gzip (SC-122)"
		return 1
	fi
	# Defense in depth behind the signature: refuse absolute paths and any `..`
	# component. A traversing member is only reachable with the release key, but
	# the cost of checking is one tar listing. Listed in full BEFORE matching: in
	# `tar -t | grep -q` grep exits at the first hit, tar dies of SIGPIPE, and
	# under pipefail that non-zero pipeline reads as "no bad member" — the gate
	# failed open on exactly the archive it exists to refuse.
	local members
	if ! members="$(tar -tzf "$held" 2>/dev/null)"; then
		engine_log "artifact member listing failed — refusing to extract"
		return 1
	fi
	if grep -qE '^/|(^|/)\.\.(/|$)' <<<"$members"; then
		engine_log "artifact contains absolute or traversing members — refusing to extract"
		return 1
	fi
	return 0
}

# path_chain_root_held <abs-path> — <abs-path> and every directory above it is
# root's (or SHCP_UPDATE_EXPECT_UID's) and not group/other-writable unless
# sticky, so no unprivileged account can rename an entry of the chain aside and
# put its own in its place. stat does not follow links and a symlink reports
# 0777, so a symlinked component fails the mode test like any other writable
# one. Sets CHAIN_BREAK to the first component that fails.
CHAIN_BREAK=""
path_chain_root_held() {
	local p="$1" st uid mode
	CHAIN_BREAK="$p"
	while [[ "$p" == /* ]]; do
		CHAIN_BREAK="$p"
		st="$(stat -c '%u %a' -- "$p" 2>/dev/null)" || return 1
		uid="${st%% *}" mode="${st##* }"
		[[ ( "$uid" == 0 || "$uid" == "$SHCP_UPDATE_EXPECT_UID" ) && "$mode" =~ ^[0-7]{3,4}$ ]] || return 1
		(( (8#$mode & 8#022) == 0 || (8#$mode & 8#1000) != 0 )) || return 1
		[[ "$p" == / ]] && return 0
		p="$(dirname -- "$p")"
	done
	return 1
}

# private_scratch_dir — print a fresh 0700 directory no unprivileged account can
# rename or replace, for bytes that are verified and then used BY PATH: an
# extracted installer tree, an ephemeral keyring and the phar it verifies. An fd
# cannot carry those. mktemp lands in the unit's PrivateTmp /tmp. A chain that
# is not root-held (path_chain_root_held) removes the directory and stages
# nothing (fail closed).
private_scratch_dir() {
	local dir
	dir="$(mktemp -d)" || return 1
	if path_chain_root_held "$dir"; then
		printf '%s\n' "$dir"
		return 0
	fi
	engine_log "scratch: ${CHAIN_BREAK} is not held by root — refusing to stage verified bytes under it"
	rm -rf -- "$dir"
	return 1
}

# panel_candidate_cache_path <version> — the one on-disk path every fetch of the
# panel candidate in a run resolves (shcp-updater#136): console preflight,
# stage_panel's re-check and stage_panel's body. Keyed by run id AND version, so
# another run's or another version's cache is a different path and is never
# consulted. It lives in the run dir, so run retention removes it with the run.
#
# Non-zero, and the caller must not use it, outside a run context; for a version
# that is not plain x.y.z (the resumed re-check reads it from the journal, and it
# becomes a path component); and when the run dir is not uid 0 or is
# group/other-writable (a symlink never passes: stat reports its own 0777). That
# refusal is the pre-seeding guard (SC-355): /var/lib/shcp is panel-owned, so a
# replaced update/ tree could otherwise hand root a download dir a tenant
# controls. Once the run dir passes, only root can put anything inside it.
panel_candidate_cache_path() {
	local version="$1" run dir
	[[ -n "$CURRENT_RUN_ID" ]] && ver_valid "$version" || return 1
	run="${RUNS_DIR}/${CURRENT_RUN_ID}"
	dir="${run}/download"
	[[ -d "$run" ]] && path_uid0_not_writable "$run" || return 1
	# chmod follows links; never let it retarget onto whatever one points at.
	[[ ! -L "$dir" ]] || return 1
	mkdir -p -- "$dir" 2>/dev/null || return 1
	chmod 0700 -- "$dir" 2>/dev/null || true
	printf '%s/shcp-base-%s.tar.gz\n' "$dir" "$version"
}

# Rebuild the prod cache IN the staged tree, as the panel user, before anything
# validates or flips it.
#
# The tarball ships a compiled container pre-warmed on the BUILD host, and a
# compiled container is not path-portable: %kernel.project_dir% is resolved at
# warmup, so every one of its absolute paths names the packager's staging dir
# (322 files in the 0.0.43 artifact carried /tmp/shcp-base-build.*). A fresh
# install never notices — the installer re-warms on-box as its deploy gate —
# but an upgrade that keeps the shipped cache boots a container whose lock dir,
# cache pools and resource paths point at a directory that exists on no
# customer machine. First live proof: demo run 20260815-213109-3b88e2 — /up
# answered 500, the health stage said unhealthy, and auto_rollback returned the
# box to 0.0.42. Correct behaviour, wrong place to find out: rebuilding HERE
# turns "flip, fail health, roll back" into "refuse to flip, nothing applied".
#
# Runs as the panel user (the same posture as the installer's deploy gate) so
# the rebuilt cache is owned by the runtime that must read and write it, and
# runs BEFORE panel_smoke so the smoke validates the container the box will
# actually boot, not the foreign one this function deletes.
# panel_console_runas — fill PANEL_CONSOLE_RUNAS with the wrapper that drops a
# console invocation in a STAGED tree to the panel user. One seam for both
# panel_cache_rebuild and panel_smoke: a Symfony boot as root leaves root-owned
# files under var/ (cache pools, the lock dir, log files) that the shcp runtime
# then cannot write, so every root console call against a tree the panel will
# serve must go through this. Drops privileges only where that is actually
# possible — a real box (root, shcp exists). The test harness sources this file
# unprivileged with a stubbed SHCPD_BIN, and a hard runuser dependency would
# make the callers untestable rather than safer; a suite proves the wiring by
# overriding this function.
PANEL_CONSOLE_RUNAS=()
panel_console_runas() {
	PANEL_CONSOLE_RUNAS=()
	if [[ "$EUID" -eq 0 ]] && id -u shcp >/dev/null 2>&1; then
		PANEL_CONSOLE_RUNAS=(runuser -u shcp --)
	fi
}

panel_cache_rebuild() {
	local dir="$1"
	[[ -x "$SHCPD_BIN" ]] || { engine_log "cache rebuild: ${SHCPD_BIN} not executable"; return 1; }
	panel_console_runas
	rm -rf "${dir}/var/cache/prod"
	if ! ( cd "$dir" && "${PANEL_CONSOLE_RUNAS[@]+"${PANEL_CONSOLE_RUNAS[@]}"}" \
			"$SHCPD_BIN" php-cli "${dir}/bin/console" cache:clear --env=prod --no-interaction --no-warmup ) \
			>/dev/null 2>&1; then
		engine_log "cache rebuild: cache:clear failed in ${dir}"
		return 1
	fi
	if ! ( cd "$dir" && "${PANEL_CONSOLE_RUNAS[@]+"${PANEL_CONSOLE_RUNAS[@]}"}" \
			"$SHCPD_BIN" php-cli "${dir}/bin/console" cache:warmup --env=prod --no-interaction ) \
			>/dev/null 2>&1; then
		engine_log "cache rebuild: cache:warmup failed in ${dir}"
		return 1
	fi
	# cache:warmup exits 0 even when it writes nothing (the installer learned
	# this the hard way) — the compiled container is the affirmative signal.
	if ! compgen -G "${dir}/var/cache/prod/"'*'"/*KernelProdContainer.php" >/dev/null \
			&& ! compgen -G "${dir}/var/cache/prod/*KernelProdContainer.php" >/dev/null; then
		engine_log "cache rebuild: warmup produced no compiled container in ${dir}/var/cache/prod"
		return 1
	fi
	return 0
}

# SC-782: pinned shcpd's php-cli exited 0 when a console command died on an
# uncaught throwable, with nothing on stdout, and a box keeps such a binary until
# the patched one reaches it. So no console verb the engine decides on is believed
# on its exit status alone; each also has to show output only a finished run prints.
#
# console_update_db_ok < report — exactly one `shcp:update:db --json` document,
# and it reports success.
console_update_db_ok() {
	jq -es 'length == 1 and (.[0] | type == "object"
		and (.status | IN("applied", "up_to_date")) and .error == null)' >/dev/null 2>&1
}

# console_update_db_check_status < report — the `.status` of exactly one
# `shcp:update:db --check --json` document (SC-806): `check` is true (a panel
# that does not know --check never prints it, and the plain apply report says
# false), `error` is null and the status is one the read-only mode reports.
# Prints nothing and fails for anything else, so a caller cannot mistake an
# apply report — or a crash's empty stdout (SC-782) — for a proof.
console_update_db_check_status() {
	jq -esr 'if length == 1 and (.[0] | type == "object" and .check == true and .error == null
		and (.status | IN("up_to_date", "pending"))) then .[0].status else empty end' 2>/dev/null
}

# console_reported_ok <log> — the SymfonyStyle "[OK] <summary>" block that
# republish-vhosts, reconcile-overrides and republish-pools print right before
# returning SUCCESS.
console_reported_ok() {
	grep -Eq '^[[:space:]]*\[OK\][[:space:]]' -- "$1" 2>/dev/null
}

# console_about_ok <output> — `bin/console about --env=prod` lists the booted
# kernel's environment; a crashed boot lists nothing.
console_about_ok() {
	grep -Eq '^[[:space:]]*Environment[[:space:]]+prod[[:space:]]*$' <<<"$1"
}

# `bin/console about` under the STAGED tree. This is the last point where a
# broken release costs nothing: it runs before the maintenance flag, before the
# workers stop, and before the flip. Runs as the panel user through the same
# wrapper as panel_cache_rebuild: as root, the Symfony boot `about` performs
# can create root-owned entries under var/ (a cache pool touched on boot, the
# lock dir) that the shcp runtime then cannot write — the exact damage the
# rebuild deliberately avoids by dropping privileges.
panel_smoke() {
	local dir="$1" out
	[[ -x "$SHCPD_BIN" ]] || { engine_log "smoke: ${SHCPD_BIN} not executable"; return 1; }
	panel_console_runas
	# SC-782: exit 0 alone is not proof `about` ran — an unpatched php-cli reports
	# a crashed command as exit 0 with nothing on stdout. The booted prod kernel
	# must be listed.
	out="$( ( cd "$dir" && "${PANEL_CONSOLE_RUNAS[@]+"${PANEL_CONSOLE_RUNAS[@]}"}" \
			"$SHCPD_BIN" php-cli "${dir}/bin/console" about --env=prod --no-interaction ) \
		2>/dev/null )" || return 1
	console_about_ok "$out" || { engine_log "smoke: bin/console about exited 0 but reported no prod kernel (SC-782)"; return 1; }
}

# Owner of a generated helper HMAC key: root:shcp, so shcpd, the workers and the
# daemon itself (all in group shcp) can read it and nothing outside that group
# ever can — mirrors PANEL_ENV_OWNER. Overridable for the test harness only.
HELPER_KEY_OWNER="${SHCP_UPDATE_HELPER_KEY_OWNER:-root:shcp}"

# --- helper daemon HMAC key reconciliation ------------------------------------
# Mirrors shcp-installer functions/shcp.sh: shcp_helper_key_path /
# shcp_helper_key_file_valid / ensure_shcp_helper_key (installer#437 + #447,
# SC-212 / SC-221 / SC-263). DUPLICATED, not sourced: the engine is a single
# standalone /usr/sbin/shcp-update with no access to the installer tree at
# runtime — keep the two copies in step.
#
# shcp-file-broker and shcp-backup-stream each read a generate-once HMAC secret
# from the --key-file path their unit declares; the panel PHP client reads the
# SAME unit-derived path, so following the unit keeps both ends on one secret
# without shipping a secret in the release artifact. A daemon started against a
# path that holds no key dies, yet a binaries+units-only redeploy would report
# success — so the key MUST be reconciled at the unit-declared path BEFORE
# restart. Paths are the same on deb and rpm (SYS_GROUP_SHCP=shcp both
# families); CONFIG_ROOT prefixes them for the test harness (it is / on a real
# box), as the config-tar paths already do.

# Emit the one supported --key-file path a helper unit declares, or fail closed.
# Accept only the canonical /etc/shcp/<comp>.key; reject any other path and
# ambiguous/expanded/quoted ExecStart declarations.
helper_key_path() {
	local unit_file="$1" comp="$2"
	local -a exec_lines=() key_args=()
	[[ -f "$unit_file" && ! -L "$unit_file" ]] \
		|| { engine_log "helpers: unit missing or not a regular file: ${unit_file}"; return 1; }
	mapfile -t exec_lines < <(awk '
		function emit() { gsub(/\\[[:space:]]*$/, "", buf); print buf; buf=""; continued=0 }
		/^[[:space:]]*#/ { next }
		/^[[:space:]]*ExecStart=/ {
			if (buf != "") emit()
			buf=$0
			if ($0 ~ /\\[[:space:]]*$/) continued=1; else emit()
			next
		}
		continued { buf=buf " " $0; if ($0 !~ /\\[[:space:]]*$/) emit() }
		END { if (buf != "") emit() }
	' "$unit_file")
	[[ "${#exec_lines[@]}" -eq 1 ]] \
		|| { engine_log "helpers: unit ${unit_file} must declare exactly one ExecStart"; return 1; }
	if [[ "${exec_lines[0]}" == *'$'* || "${exec_lines[0]}" == *'"'* || "${exec_lines[0]}" == *"'"* ]]; then
		engine_log "helpers: unit ${unit_file} uses unsupported expansion or quoting in ExecStart"
		return 1
	fi
	mapfile -t key_args < <(grep -oE -- '(^|[[:space:]])--key-file[[:space:]]+[^[:space:]\\]+' <<<"${exec_lines[0]}" | sed -E 's/^[[:space:]]+//' || true)
	[[ "${#key_args[@]}" -eq 1 ]] \
		|| { engine_log "helpers: unit ${unit_file} must declare exactly one --key-file"; return 1; }
	local key="${key_args[0]#--key-file}"
	key="${key#"${key%%[![:space:]]*}"}"
	local base="${CONFIG_ROOT%/}"
	case "$key" in
		"${base}/etc/shcp/${comp}.key") printf '%s\n' "$key" ;;
		*) engine_log "helpers: unit ${unit_file} declares unsupported key path: ${key}"; return 1 ;;
	esac
}

# Accept only 64 lowercase hex bytes, with at most one final LF. Check the file
# itself — command substitution would discard NUL bytes and trailing newlines.
# Fixed /usr/bin/env + /usr/bin/grep so a readonly LC_ALL cannot make the content
# check silently no-op and an exported env/grep function cannot bypass it (#447).
helper_key_file_valid() {
	local key="$1" size
	[[ -f "$key" && ! -L "$key" ]] || return 1
	size="$(stat -c '%s' "$key" 2>/dev/null)" || return 1
	[[ "$size" -eq 64 || "$size" -eq 65 ]] || return 1
	/usr/bin/env LC_ALL=C /usr/bin/grep -qEx '[0-9a-f]{64}' "$key" || return 1
	if [[ "$size" -eq 65 ]]; then
		[[ "$(tail -c 1 "$key" | od -An -tu1 | tr -d '[:space:]')" == "10" ]] || return 1
	fi
}

# The systemd-effective helper unit (/etc outranks /lib), or fail if none.
helper_effective_unit() {
	local comp="$1" base="${CONFIG_ROOT%/}" u
	for u in "${base}/etc/systemd/system/shcp-${comp}.service" "${base}/lib/systemd/system/shcp-${comp}.service"; do
		[[ -f "$u" && ! -L "$u" ]] && { printf '%s\n' "$u"; return 0; }
	done
	return 1
}

# Generate the helper key once at the unit-declared path, or preserve a valid
# existing key byte-for-byte; refuse symlinks and malformed survivors before the
# daemon restart. Idempotent: a no-op (bar a perms re-assert) when already valid.
helper_key_ensure() {
	local comp="$1" unit_file="$2"
	local key parent tmpkey generated
	key="$(helper_key_path "$unit_file" "$comp")" || return 1
	parent="$(dirname "$key")"
	[[ ! -L "$parent" ]] || { engine_log "helpers: key directory is a symlink: ${parent}"; return 1; }
	if [[ -e "$parent" && ! -d "$parent" ]]; then
		engine_log "helpers: key parent is not a directory: ${parent}"; return 1
	fi
	mkdir -p "$parent" || { engine_log "helpers: failed to create key directory ${parent}"; return 1; }
	if (( EUID == 0 )) && [[ "$(stat -c '%U:%G:%a' "$parent" 2>/dev/null)" != "root:root:755" ]]; then
		engine_log "helpers: unsafe key directory metadata: ${parent}"; return 1
	fi
	if [[ -e "$key" || -L "$key" ]]; then
		helper_key_file_valid "$key" \
			|| { engine_log "helpers: existing key is malformed: ${key}"; return 1; }
	else
		tmpkey="$(mktemp "${parent}/.${comp}.key.XXXXXX")" \
			|| { engine_log "helpers: mktemp for ${comp} key failed"; return 1; }
		generated="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')"
		if [[ ! "$generated" =~ ^[0-9a-f]{64}$ ]]; then
			rm -f "$tmpkey"; engine_log "helpers: generated ${comp} key is not 64 hex chars"; return 1
		fi
		printf '%s' "$generated" >"$tmpkey" \
			|| { rm -f "$tmpkey"; engine_log "helpers: failed to write ${comp} key"; return 1; }
		chown "$HELPER_KEY_OWNER" "$tmpkey" && chmod 0640 "$tmpkey" \
			|| { rm -f "$tmpkey"; engine_log "helpers: failed to set perms on ${comp} key"; return 1; }
		if ! ln "$tmpkey" "$key" 2>/dev/null; then
			helper_key_file_valid "$key" \
				|| { rm -f "$tmpkey"; engine_log "helpers: failed to publish ${key}"; return 1; }
		fi
		rm -f "$tmpkey"
		helper_key_file_valid "$key" \
			|| { engine_log "helpers: ${key} missing or malformed after keygen"; return 1; }
	fi
	chown "$HELPER_KEY_OWNER" "$key" && chmod 0640 "$key" \
		|| { engine_log "helpers: failed to re-assert perms on ${key}"; return 1; }
}

# Redeploy the helper daemons the new release bundles: HEAL each daemon's
# unit-declared HMAC key (see above) FIRST, then install the host-arch binary +
# unit, then restart. Best-effort by design: a release that ships no helpers dir
# is not an error. A key that cannot be reconciled is a shortfall (rc=1): that
# helper is NOT installed and NOT restarted, so the on-disk binary and the
# running daemon stay the same version. It used to install first and check the
# key second, and on a key failure `continue`d without restarting — leaving the
# new binary on disk with the OLD daemon still running (and the caller treating
# rc=1 as non-fatal, so the run reported healthy over a helper the next reboot
# would start onto a key it cannot read). HELPERS_SHORTFALLS names each helper
# left behind so stage_panel can carry it in its result.
HELPERS_SHORTFALLS=()
helpers_redeploy() {
	local release="$1" arch bin_dir comp unit rc=0 base rel_unit etc_unit
	local -a to_restart=()
	HELPERS_SHORTFALLS=()
	arch="$(uname -m)"
	base="${CONFIG_ROOT%/}"
	bin_dir="${release}/helpers/bin/${arch}"
	[[ -d "$bin_dir" ]] || { engine_log "helpers: no bundled binaries for ${arch}, skipping"; return 0; }
	for comp in backup-stream file-broker; do
		# ! -L: never install a symlinked source (it would follow to an shcp-chosen
		# target the protector's own ! -L skip leaves untouched — #70/master#926).
		[[ -f "${bin_dir}/shcp-${comp}" && ! -L "${bin_dir}/shcp-${comp}" ]] || continue
		# The unit whose --key-file will be in force AFTER the install: an /etc
		# override outranks anything this release ships into /lib; otherwise the
		# release's own unit is what the install below puts there; failing both,
		# whatever is effective today.
		rel_unit="${release}/helpers/systemd/shcp-${comp}.service"
		etc_unit="${base}/etc/systemd/system/shcp-${comp}.service"
		if [[ -f "$etc_unit" && ! -L "$etc_unit" ]]; then
			unit="$etc_unit"
		elif [[ -f "$rel_unit" && ! -L "$rel_unit" ]]; then
			unit="$rel_unit"
		elif ! unit="$(helper_effective_unit "$comp")"; then
			engine_log "helpers: no unit for shcp-${comp}; cannot reconcile its key — NOT installing it"
			HELPERS_SHORTFALLS+=("shcp-${comp}: no unit declares its key path; binary and unit left at their prior version")
			rc=1; continue
		fi
		helper_key_ensure "$comp" "$unit" \
			|| { engine_log "helpers: HMAC key reconciliation for shcp-${comp} failed — NOT installing it (binary and running daemon stay matched)"; HELPERS_SHORTFALLS+=("shcp-${comp}: HMAC key could not be reconciled; binary and unit left at their prior version"); rc=1; continue; }
		install -m 0755 -o root -g root "${bin_dir}/shcp-${comp}" "${base}/usr/sbin/shcp-${comp}" \
			|| { engine_log "helpers: failed to install shcp-${comp}"; HELPERS_SHORTFALLS+=("shcp-${comp}: binary install failed"); return 1; }
		if [[ -f "$rel_unit" && ! -L "$rel_unit" ]]; then
			install -m 0644 -o root -g root "$rel_unit" "${base}/lib/systemd/system/shcp-${comp}.service" \
				|| { HELPERS_SHORTFALLS+=("shcp-${comp}: unit install failed"); return 1; }
		fi
		to_restart+=("$comp")
	done
	if [[ -f "${bin_dir}/rclone" && ! -L "${bin_dir}/rclone" ]]; then
		install -m 0755 -o root -g root "${bin_dir}/rclone" "${base}/usr/sbin/rclone" || return 1
	fi
	# Offline CLIs the panel shells out to: the Postfix converter and the pinned
	# configuration parser (shcp-base#1788), plus the SC-236 outbound-policy Go
	# runner every shcp-outbound-policy@ instance execs (SC-756, SC-781). They must
	# move with the release that ships them — before this loop an update never
	# refreshed them, so a security fix to the runner never reached an existing box
	# (shcp-master#2291). Staged beside the target and renamed, so a caller mid-run
	# never execs a torn binary. No restart: the policy socket is Accept=yes with one
	# request per instance, so the next connection execs the new file and one in
	# flight finishes on the old inode. A release that does not ship one skips it
	# (release-boundary lag, installer#785).
	#
	# The staged file lands bin_t and rename(2) keeps that, but the runner's unit
	# names shcp_mailpolicy_t, whose only entrypoint is shcp_mailpolicy_exec_t: left
	# bin_t, every instance is refused at exec and every authenticated send defers.
	# Only restorecon applies the shcpd module's fcontext, as the installer's
	# _install_offline_cli does. No-op where restorecon is absent (deb).
	#
	# The tenant shell launcher (SC-901, SC-902) is every shell account's login shell
	# once the panel sets it, and the cpmove importer's supervisor and in-unit entry
	# (SC-858) are root-run: all three are installed only by a fresh install, so a box
	# that reached the release that ships them by update would otherwise never get
	# them, or keep an older build (shcp-updater#305). The cpmove entry is the parser
	# domain's SELinux entrypoint, so the relabel below matters for it as it does for
	# the runner; the launcher stays bin_t.
	for comp in postfix-conf conf mail-policy jail-shell cpmove-supervisor cpmove-entry; do
		[[ -f "${bin_dir}/shcp-${comp}" && ! -L "${bin_dir}/shcp-${comp}" ]] || continue
		local staged="${base}/usr/sbin/.shcp-${comp}.new.$$"
		install -m 0755 -o root -g root "${bin_dir}/shcp-${comp}" "$staged" \
			&& mv -f "$staged" "${base}/usr/sbin/shcp-${comp}" \
			|| { rm -f "$staged"; engine_log "helpers: failed to install shcp-${comp}"; return 1; }
		command -v restorecon >/dev/null 2>&1 || continue
		restorecon "${base}/usr/sbin/shcp-${comp}" \
			|| { engine_log "helpers: could not relabel shcp-${comp}"; rc=1; }
	done
	systemctl daemon-reload >/dev/null 2>&1 || true
	for comp in "${to_restart[@]+"${to_restart[@]}"}"; do
		systemctl restart "shcp-${comp}.service" >/dev/null 2>&1 \
			|| engine_log "helpers: restart of shcp-${comp} failed (reported, not fatal)"
	done
	return "$rc"
}

# verify_worker_redeploy <release-dir> — target-aware managed verify unit delivery.
# verify worker (shcp-verify-worker.service) from the panel release tree.
#
# A SEPARATE function, NOT code inside helpers_redeploy: that function early-
# returns when a release ships no helpers/bin/<arch> (an unrelated concern), which
# would silently swallow this install. And it installs to /etc/systemd/system/
# (NOT /lib, where helpers_redeploy puts the daemon units) so it agrees with the
# installer's shcp_messenger_setup/install_verify_worker — /etc outranks /lib, and
# a split would let systemd pin a stale copy after a fresh-then-upgraded sequence.
#
# UNLIKE helpers_redeploy, a failure here is FATAL to stage_panel by contract:
# once the new panel routes VerifySmarthostMessage to `verify`, a box that cannot
# bring the consumer up would hang the relay-verify button forever. Returning
# non-zero fails stage_panel, which is in run_stages' [R] set → auto-rollback to
# the prior panel (which routes verify -> privileged, a live consumer). This is
# the real guarantor: the health stage's unit diff canNOT catch a unit introduced
# by the same release (it is a regression diff vs the pre-flip baseline, where the
# unit did not exist). See SC-513.
#
# A release that PREDATES the verify worker legitimately has no unit here (a
# rollback target, or a box updating from an old panel that still routes verify ->
# privileged): nothing to do, return 0. A box without systemd (unit-test/CI env):
# skip, consistent with unit_state / the health stage.
verify_worker_redeploy() {
	local release="$1" state
	state="$(verify_worker_release_state "$release")" || return 1
	case "$state" in
		verify) verify_worker_install_for_release "$release" ;;
		privileged) verify_worker_remove_managed ;;
		*) return 1 ;;
	esac
}

VERIFY_WORKER_UNIT=shcp-verify-worker.service
VERIFY_WORKER_UNIT_PATH="${SHCP_UPDATE_VERIFY_WORKER_UNIT:-/etc/systemd/system/${VERIFY_WORKER_UNIT}}"
verify_worker_source() { printf '%s/config/system/systemd/%s' "$1" "$VERIFY_WORKER_UNIT"; }

verify_worker_unit_contract_ok() {
	local path="$1"
	[[ -f "$path" && ! -L "$path" ]] || return 1
	awk '
		BEGIN {
			expected["Unit" SUBSEP "Description"] = "SHCP Smarthost Verify Worker"; expected["Unit" SUBSEP "After"] = "network.target"
			expected["Service" SUBSEP "Type"] = "simple"; expected["Service" SUBSEP "User"] = "root"; expected["Service" SUBSEP "WorkingDirectory"] = "/opt/shcp"; expected["Service" SUBSEP "ExecStart"] = "/usr/sbin/shcpd php-cli bin/console messenger:consume verify --time-limit=3600 --memory-limit=128M"; expected["Service" SUBSEP "Restart"] = "always"; expected["Service" SUBSEP "RestartSec"] = "5"
			kill_mode_key = "Service" SUBSEP "KillMode"; stop_timeout_key = "Service" SUBSEP "TimeoutStopSec"
			expected[kill_mode_key] = "mixed"; expected[stop_timeout_key] = "300"
			expected["Service" SUBSEP "NoNewPrivileges"] = "yes"; expected["Service" SUBSEP "PrivateTmp"] = "yes"; expected["Service" SUBSEP "ProtectSystem"] = "strict"; expected["Service" SUBSEP "ReadWritePaths"] = "/opt/shcp/var /var/lib/shcp"; expected["Service" SUBSEP "ProtectHome"] = "yes"; expected["Service" SUBSEP "ProtectKernelTunables"] = "yes"; expected["Service" SUBSEP "ProtectKernelModules"] = "yes"; expected["Service" SUBSEP "ProtectControlGroups"] = "yes"; expected["Service" SUBSEP "RestrictSUIDSGID"] = "yes"; expected["Service" SUBSEP "LockPersonality"] = "yes"; expected["Service" SUBSEP "RestrictRealtime"] = "yes"; expected["Service" SUBSEP "RestrictNamespaces"] = "yes"; expected["Service" SUBSEP "RestrictAddressFamilies"] = "AF_UNIX AF_INET AF_INET6"; expected["Service" SUBSEP "SystemCallArchitectures"] = "native"; expected["Service" SUBSEP "SystemCallFilter"] = "@system-service"; expected["Service" SUBSEP "InaccessiblePaths"] = "-/run/shcp"
			expected["Install" SUBSEP "WantedBy"] = "multi-user.target"
			environment[1] = "APP_ENV=prod"; environment[2] = "PHPRC=/etc/shcp"; environment[3] = "PHP_INI_SCAN_DIR=/etc/shcp/conf.d"; environment[4] = "GODEBUG=cgocheck=0"
		}
		{ line=$0; sub(/^[[:space:]]+/, "", line); sub(/[[:space:]]+$/, "", line) }
		line == "" || line ~ /^#/ { next }
		line ~ /^\[[^]]+\]$/ { section=substr(line,2,length(line)-2); if (section != "Unit" && section != "Service" && section != "Install") bad=1; section_count[section]++; next }
		{ pos=index(line,"="); if (section == "" || pos < 2) { bad=1; next }; directive=substr(line,1,pos-1); value=substr(line,pos+1); key=section SUBSEP directive; if (key == "Service" SUBSEP "Environment") { environment_count++; environment_actual[environment_count]=value } else if (!(key in expected)) bad=1; else { count[key]++; actual[key]=value } }
		END {
			if (count[kill_mode_key] == 0 && count[stop_timeout_key] == 0) { delete expected[kill_mode_key]; delete expected[stop_timeout_key] }
			if (section_count["Unit"] != 1 || section_count["Service"] != 1 || section_count["Install"] != 1) bad=1; for (key in expected) if (count[key] != 1 || actual[key] != expected[key]) bad=1; if (environment_count != 4) bad=1; for (i=1;i<=4;i++) if (environment_actual[i] != environment[i]) bad=1; exit bad ? 1 : 0
		}
	' "$path"
}

verify_worker_unit_hash_for_release() {
	local src
	src="$(verify_worker_source "$1")"
	# This hash decides whether an installed unit is ours to snapshot and remove:
	# only a release unit that meets the hardened contract may claim one.
	verify_worker_unit_contract_ok "$src" || return 1
	sha256sum "$src" | awk '{print $1}'
}


# Emits exactly verify or privileged. Any malformed/list/duplicate/unknown route,
# or route/unit disagreement, is unsupported and fails closed.
verify_worker_release_state() {
	local release="$1" route src
	route="${release}/config/packages/messenger.yaml"
	src="$(verify_worker_source "$release")"
	[[ -f "$route" && ! -L "$route" ]] || return 1
	local destination
	destination="$(awk '
		BEGIN {want="Shcp\\Message\\Email\\VerifySmarthostMessage"; framework=messenger=routing=-1}
		/^[[:space:]]*#/ || /^[[:space:]]*$/ {next}
		{
			match($0,/^[ ]*/); ind=RLENGTH; text=substr($0,ind+1)
			if (text ~ /^framework:[[:space:]]*$/ && ind == 0) {framework=ind; messenger=routing=-1; next}
			if (text ~ /^messenger:[[:space:]]*$/ && framework >= 0 && ind > framework) {messenger=ind; routing=-1; next}
			if (text ~ /^routing:[[:space:]]*$/ && messenger >= 0 && ind > messenger) {routing=ind; next}
			if (framework >= 0 && ind <= framework) {framework=messenger=routing=-1}
			else if (messenger >= 0 && ind <= messenger) {messenger=routing=-1}
			else if (routing >= 0 && ind <= routing) {routing=-1}
		}
		index($0, "VerifySmarthostMessage") {
			line=$0; sub(/^[[:space:]]*/, "", line); p=index(line, ":"); if (!p) exit 2
			key=substr(line,1,p-1); value=substr(line,p+1)
			gsub(/^[[:space:]'\''\"]+|[[:space:]'\''\"]+$/, "", key)
			gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
			if (routing < 0 || ind <= routing || key != want || (value != "verify" && value != "privileged")) exit 2
			print value; n++
		}
		END {if (n != 1) exit 3}
	' "$route")" || return 1
	case "$destination" in
		verify) verify_worker_unit_contract_ok "$src" || return 1 ;;
		privileged) [[ ! -e "$src" && ! -L "$src" ]] || return 1 ;;
		*) return 1 ;;
	esac
	printf '%s\n' "$destination"
}

verify_worker_snapshot_once() {
	local jf; jf="$(journal_path "$CURRENT_RUN_ID")"
	jq -e '.verify_worker.snapshot != null' "$jf" >/dev/null 2>&1 && return 0
	local present=false enabled=false active=false hash='' source_release='' source_hash=''
	if [[ -e "$VERIFY_WORKER_UNIT_PATH" || -L "$VERIFY_WORKER_UNIT_PATH" ]]; then
		[[ -f "$VERIFY_WORKER_UNIT_PATH" && ! -L "$VERIFY_WORKER_UNIT_PATH" ]] || return 1
		present=true
		hash="$(sha256sum "$VERIFY_WORKER_UNIT_PATH" 2>/dev/null | awk '{print $1}')"
		source_release="$(panel_link_target)"
		[[ -n "$source_release" ]] && source_hash="$(verify_worker_unit_hash_for_release "$source_release")"
		[[ -n "$hash" && "$source_hash" == "$hash" ]] \
			|| { engine_log "verify worker: refusing unmanaged installed unit"; return 1; }
		systemctl is-enabled "$VERIFY_WORKER_UNIT" >/dev/null 2>&1 && enabled=true
		[[ "$(unit_state shcp-verify-worker)" == active ]] && active=true
	fi
	journal_update "$CURRENT_RUN_ID" '.verify_worker.snapshot = $s' --argjson s "$(jq -nc \
		--argjson p "$present" --argjson e "$enabled" --argjson a "$active" --arg h "$hash" --arg r "$source_release" \
		'{present:$p,enabled:$e,active:$a,unit_hash:(if $h=="" then null else $h end),source_release:(if $r=="" then null else $r end)}')"
}

verify_worker_install_for_release() {
	local release="$1" src staged expected actual
	[[ "$(verify_worker_release_state "$release")" == verify ]] || return 1
	src="$(verify_worker_source "$release")"
	# Stage BESIDE the unit and prove the staged bytes before the rename: a unit
	# that does not hash to the release source never reaches the unit path.
	verify_worker_unit_contract_ok "$src" || return 1
	expected="$(sha256sum "$src" 2>/dev/null | awk '{print $1}')"; [[ -n "$expected" ]] || return 1
	staged="${VERIFY_WORKER_UNIT_PATH}.shcp-new"
	rm -f -- "$staged"
	install -m 0644 -o root -g root "$src" "$staged" || { rm -f -- "$staged"; return 1; }
	actual="$(sha256sum "$staged" 2>/dev/null | awk '{print $1}')"
	if [[ "$actual" != "$expected" ]] || ! verify_worker_unit_contract_ok "$staged" \
			|| { (( EUID == 0 )) && [[ "$(stat -c '%u:%g:%a' "$staged")" != 0:0:644 ]]; }; then
		rm -f -- "$staged"
		return 1
	fi
	mv -f -- "$staged" "$VERIFY_WORKER_UNIT_PATH" || { rm -f -- "$staged"; return 1; }
	systemctl daemon-reload >/dev/null 2>&1 || return 1
	journal_update "$CURRENT_RUN_ID" '.verify_worker.installed = $i' --argjson i "$(jq -nc --arg h "$actual" --arg r "$release" '{unit_hash:$h,source_release:$r}')"
	systemctl enable "$VERIFY_WORKER_UNIT" >/dev/null 2>&1 || return 1
	[[ "$(unit_state shcp-verify-worker)" != active ]] || return 1
}

verify_worker_remove_managed() {
	local run_id="${1:-$CURRENT_RUN_ID}" jf expected source_release source_hash actual
	[[ -e "$VERIFY_WORKER_UNIT_PATH" || -L "$VERIFY_WORKER_UNIT_PATH" ]] || return 0
	[[ -f "$VERIFY_WORKER_UNIT_PATH" && ! -L "$VERIFY_WORKER_UNIT_PATH" ]] || return 1
	jf="$(journal_path "$run_id")"
	expected="$(jq -r '.verify_worker.installed.unit_hash // empty' "$jf" 2>/dev/null || true)"
	source_release="$(jq -r '.verify_worker.installed.source_release // empty' "$jf" 2>/dev/null || true)"
	source_hash="$(verify_worker_unit_hash_for_release "$source_release")"
	actual="$(sha256sum "$VERIFY_WORKER_UNIT_PATH" 2>/dev/null | awk '{print $1}')"
	[[ -n "$expected" && "$source_hash" == "$expected" && "$actual" == "$expected" ]] || return 1
	systemctl stop "$VERIFY_WORKER_UNIT" >/dev/null 2>&1 || return 1
	case "$(unit_state shcp-verify-worker)" in inactive|failed|unknown) ;; *) return 1 ;; esac
	systemctl disable "$VERIFY_WORKER_UNIT" >/dev/null 2>&1 || return 1
	rm -f -- "$VERIFY_WORKER_UNIT_PATH" || return 1
	systemctl daemon-reload >/dev/null 2>&1 || return 1
	[[ ! -e "$VERIFY_WORKER_UNIT_PATH" && ! -L "$VERIFY_WORKER_UNIT_PATH" ]] || return 1
	systemctl is-enabled "$VERIFY_WORKER_UNIT" >/dev/null 2>&1 && return 1
	return 0
}

verify_worker_reassign_pending() {
	local db="$SHCP_UPDATE_DB" out sb dbefore affected sa da expected_da
	# EP-4b: the panel `verify` queue is schema-specific. A product that declares
	# queue-drain-check N/A has no such queue to drain -- SKIP, do not error, and
	# leave the database untouched. Inert when PRODUCT is unset (legacy/tests).
	if [[ -n "$PRODUCT" ]] && ! product_db_op_applies "$PRODUCT" queue-drain-check; then
		engine_log "verify worker: queue-drain-check N/A for product ${PRODUCT} -- no verify queue to drain (skipped)"
		return 0
	fi
	[[ -f "$db" && ! -L "$db" ]] || return 1
	# .timeout like every other open of the live DB: shcpd may still hold a
	# read transaction, and a bare open would fail BEGIN IMMEDIATE with SQLITE_BUSY
	# instead of waiting. Both opens below run as root and re-materialize
	# root-owned -wal/-shm beside the panel DB, so db_fix_ownership follows each,
	# success or failure — otherwise a rollback leaves the panel with "attempt to
	# write a readonly database".
	out="$(sqlite3 -batch -bail -cmd ".timeout ${DB_BUSY_MS}" "$db" <<'SQL'
BEGIN IMMEDIATE;
CREATE TEMP TABLE verify_worker_proof(source_before INTEGER, dest_before INTEGER, affected INTEGER);
CREATE TEMP TABLE verify_worker_guard(ok INTEGER CHECK(ok=1));
INSERT INTO verify_worker_proof SELECT
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='verify'),
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='privileged'), 0;
UPDATE messenger_messages SET queue_name='privileged' WHERE queue_name='verify';
UPDATE verify_worker_proof SET affected=changes();
INSERT INTO verify_worker_guard
SELECT ((affected=source_before)
 AND ((SELECT COUNT(*) FROM messenger_messages WHERE queue_name='verify')=0)
 AND ((SELECT COUNT(*) FROM messenger_messages WHERE queue_name='privileged')=dest_before+source_before))
FROM verify_worker_proof;
SELECT source_before,dest_before,affected,
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='verify'),
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='privileged') FROM verify_worker_proof;
COMMIT;
SQL
)" || { db_fix_ownership "$db"; return 1; }
	db_fix_ownership "$db"
	IFS='|' read -r sb dbefore affected sa da <<<"$out"
	[[ "$sb" =~ ^[0-9]+$ && "$dbefore" =~ ^[0-9]+$ && "$affected" =~ ^[0-9]+$ && "$sa" =~ ^[0-9]+$ && "$da" =~ ^[0-9]+$ ]] || return 1
	# Bash arithmetic is signed; reject values outside its safe decimal domain.
	for out in "$sb" "$dbefore" "$affected" "$sa" "$da"; do (( ${#out} < 19 )) || return 1; done
	[[ "${SHCP_UPDATE_TEST_VERIFY_PROOF_MISMATCH:-0}" != 1 ]] || affected=$((affected + 1))
	expected_da=$((dbefore + sb)); (( expected_da >= dbefore )) || return 1
	(( affected == sb && sa == 0 && da == expected_da )) || return 1
	local recheck
	recheck="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$db" "SELECT COUNT(*) FROM messenger_messages WHERE queue_name='verify';" 2>/dev/null || true)"
	db_fix_ownership "$db"
	[[ "$recheck" == 0 ]] || return 1
	journal_update "$CURRENT_RUN_ID" '.verify_worker.rollback_queue = $q' --argjson q "$(jq -nc \
		--argjson sb "$sb" --argjson db "$dbefore" --argjson a "$affected" --argjson sa "$sa" --argjson da "$da" \
		'{source_count:$sb,destination_count_before:$db,affected_count:$a,source_count_after:$sa,destination_count_after:$da}')"
}

# Set by verify_worker_target_release_db_ok: the named reason when the proof
# was REFUSED (SC-806) rather than failed, for the caller's shortfall; empty
# otherwise.
VERIFY_WORKER_TARGET_DB_REASON=""

verify_worker_target_release_db_ok() {
	local release="$1" state="$2" run_id="${3:-$CURRENT_RUN_ID}" db="$SHCP_UPDATE_DB" count console
	# EP-4b: this gate proves the panel `verify` queue drained before flipping a
	# release to the privileged consumer. A product with queue-drain-check N/A
	# has no such queue -- the gate is vacuously satisfied (skip). Inert when
	# PRODUCT is unset.
	if [[ -n "$PRODUCT" ]] && ! product_db_op_applies "$PRODUCT" queue-drain-check; then
		engine_log "verify worker: queue-drain-check N/A for product ${PRODUCT} -- target verify-queue gate skipped"
		return 0
	fi
	console="${release}/bin/console"
	VERIFY_WORKER_TARGET_DB_REASON=""
	[[ -x "$SHCPD_BIN" ]] || { engine_log "verify worker: target schema proof has no executable ${SHCPD_BIN}"; return 1; }
	[[ -f "$console" ]] || { engine_log "verify worker: target schema proof has no console at ${console}"; return 1; }
	# SC-806: this is a PROOF that the rollback target's schema is what the live
	# database carries, so it runs the target's console in `--check` mode, which
	# computes the pending diff and applies nothing. The plain command answered
	# the same question by migrating the database to the target's schema (DDL
	# with foreign keys suspended, pool refreshes queued) — a second, unjournaled
	# schema change on the rollback path, as root, before the restore decision.
	# Three outcomes: exit 0 with an `up_to_date` check report is the proof;
	# exit 3 with a `pending` report is a REFUSAL, because the target would have
	# had to migrate the live database, which is exactly what a proof must not
	# do; anything else — a crash, an apply-shaped report, a target release too
	# old to know `--check` — is a failure. Believed only with the report on
	# stdout, never on the exit status alone (SC-782).
	#
	# The console still runs as root against the LIVE DB (the diff is read from
	# it), and the sqlite3 opens below do the same, so root-owned -wal/-shm
	# sidecars are re-materialized beside the panel DB. Repair ownership after
	# the console and again after the last open, on every exit path, or the
	# rolled-back panel starts with "attempt to write a readonly database".
	local console_rc=0 report="" status=""
	report="$( ( cd "$release" && "$SHCPD_BIN" php-cli "$console" shcp:update:db --check --env=prod --no-interaction --json ) \
		2>/dev/null )" || console_rc=$?
	db_fix_ownership "$db"
	status="$(console_update_db_check_status <<<"$report")" || status=""
	case "${console_rc}:${status}" in
		0:up_to_date) ;;
		3:pending)
			VERIFY_WORKER_TARGET_DB_REASON="the rollback target's schema is not what the live database carries; the proof would have had to migrate it (SC-806)"
			engine_log "verify worker: REFUSED — ${VERIFY_WORKER_TARGET_DB_REASON}"
			return 1 ;;
		*)
			engine_log "verify worker: target shcp:update:db --check exited ${console_rc} without an up_to_date check report (SC-782, SC-806)"
			return 1 ;;
	esac
	[[ -f "$db" && ! -L "$db" ]] || { engine_log "verify worker: target database is absent or unsafe"; return 1; }
	[[ "$(db_integrity "$db")" == ok ]] \
		|| { engine_log "verify worker: target database integrity proof failed"; return 1; }
	# shcp-updater#167: db_snapshot_usable's own comment already establishes
	# that foreign_key_check is deliberately NOT part of the snapshot gate --
	# refusing to protect/restore a host over a data wart that predates this
	# run is a worse outcome than the wart. This gate used to demand zero
	# violations outright, which stranded every box carrying an ordinary
	# pre-existing orphan (base#1382's purge leftovers make that common) with
	# workers left stopped after an otherwise-clean rollback. Compare against
	# this run's OWN recorded baseline instead, and fail only on a violation
	# the baseline did not already carry -- see db_fk_new_violations for why
	# that comparison is by RELATION (table, parent, fkid) and not by the raw
	# "table|rowid|parent|fkid" row: the --check proof directly above rewrites
	# nothing (SC-806), but the db stage of the run being rolled back may have
	# rebuilt tables before this point, which renumbers rowid, and a
	# rowid-exact comparison would misreport every pre-existing orphan in a
	# rebuilt table as new.
	local fk="" new_violations=""
	fk="$(db_foreign_keys "$db")"
	if [[ "$fk" == error:* ]]; then
		engine_log "verify worker: target database foreign-key check could not run: ${fk}"
		return 1
	fi
	if [[ "$fk" != ok ]]; then
		new_violations="$(db_fk_new_violations "$fk" "$(verify_worker_db_fk_baseline "$run_id")")"
		if [[ -n "$new_violations" ]]; then
			engine_log "verify worker: target database foreign-key proof failed -- violation(s) not present in this run's recorded baseline: ${new_violations//$'\n'/ }"
			return 1
		fi
	fi
	count="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$db" "SELECT COUNT(*) FROM messenger_messages WHERE queue_name='verify';" 2>/dev/null || true)"
	db_fix_ownership "$db"
	[[ "$count" =~ ^[0-9]+$ ]] || return 1
	[[ "$state" != privileged || "$count" == 0 ]]
}

verify_worker_checkpoint() {
	local point="$1"
	[[ -z "${SHCP_UPDATE_VERIFY_EVENT_LOG:-}" ]] || printf '%s\n' "$point" >> "$SHCP_UPDATE_VERIFY_EVENT_LOG"
	[[ "${SHCP_UPDATE_VERIFY_FAILPOINT:-}" != "$point" ]]
}

# The license recovery pair is release-owned state outside the release tree. It
# is installed while every panel consumer is quiesced, but is deliberately left
# disabled and stopped until stage_restart has proved the target schema. The
# signed required-state declaration is the closed inventory; a file alone is not
# authority to install a root unit (SC-531), and detect/apply use the same pair
# (SC-480).
LICENSE_RECOVER_UNITS=(shcp-license-recover.service shcp-license-recover.timer)
license_recover_unit_path() {
	[[ "$1" == shcp-license-recover.service || "$1" == shcp-license-recover.timer ]] || return 1
	printf '%s/%s' "${SHCP_UPDATE_SYSTEMD_DIR:-/etc/systemd/system}" "$1"
}
license_recover_is_active() { [[ "$(systemctl is-active "$1" 2>/dev/null)" == active ]]; }
# A 1.0 release always declares the pair as non-optional: a missing or partial
# declaration and an undeclared source are errors, never authority to install
# root units (SC-531).
license_recover_inventory() {
	local release="$1" decl="${1}/config/system/required-state.json" unit src
	[[ -f "$decl" && ! -L "$decl" ]] || { engine_log "license recovery: release inventory ${decl} is absent or unsafe"; return 1; }
	jq -e 'type=="object" and ((keys - ["env","files","half","schema_version","units","vhost"])|length==0) and .schema_version==1 and .half=="release" and (.units|type=="array") and
		(all(.units[]; type=="object" and ((keys - ["id","optional","os_family","ref","unit","why"])|length==0) and (.id|type)=="string" and (.unit|type)=="string" and (.optional|type)=="boolean")) and
		([.units[].id]|length==(unique|length)) and ([.units[].unit]|length==(unique|length))' "$decl" >/dev/null 2>&1 \
		|| { engine_log "license recovery: malformed release inventory"; return 1; }
	jq -e '([.units[] | select(.optional==false and (.unit=="shcp-license-recover.service" or .unit=="shcp-license-recover.timer")) | .unit] | sort) == ["shcp-license-recover.service","shcp-license-recover.timer"]' "$decl" >/dev/null 2>&1 \
		|| { engine_log "license recovery: incomplete release inventory"; return 1; }
	for unit in "${LICENSE_RECOVER_UNITS[@]}"; do
		src="${release}/config/system/systemd/${unit}"
		[[ -f "$src" && ! -L "$src" ]] \
			|| { engine_log "license recovery: source ${src} is absent or unsafe"; return 1; }
	done
}
# license_recover_snapshot_once [target-release] — journal the installed pair
# once per run. An installed unit is OURS when it hashes to the CURRENT
# release's copy or to the TARGET release's copy: the installer writes the same
# bytes shcp-base ships, but an installer newer than the release it installed
# (shcp-master#2128: a revised .service landed in the installer and the next
# shcp-base together) leaves the box carrying the target's bytes before the
# flip. Either match is ours; anything else is root-local and refused. No
# historical hash allow-list — a byte-for-byte match against a release tree
# this run verified is the only evidence accepted.
license_recover_snapshot_once() {
	local target="${1:-}" target_hash
	local jf unit dest present enabled active hash source source_hash snapshot_dir snapshot_path trusted records=()
	jf="$(journal_path "$CURRENT_RUN_ID")"
	jq -e '.license_recover.snapshot != null' "$jf" >/dev/null 2>&1 && return 0
	source="$(panel_link_target)"
	snapshot_dir="$(dirname "$jf")/license-recover-snapshot"
	for unit in "${LICENSE_RECOVER_UNITS[@]}"; do
		dest="$(license_recover_unit_path "$unit")" || return 1
		present=false; enabled=false; active=false; hash=""; snapshot_path=""; trusted=false
		if [[ -e "$dest" || -L "$dest" ]]; then
			[[ -f "$dest" && ! -L "$dest" ]] || { engine_log "license recovery: refusing unexpected unit type at ${dest}"; return 1; }
			present=true; hash="$(sha256sum "$dest" | awk '{print $1}')"
			if [[ -n "$source" && -f "${source}/config/system/systemd/${unit}" && ! -L "${source}/config/system/systemd/${unit}" ]]; then
				source_hash="$(sha256sum "${source}/config/system/systemd/${unit}" | awk '{print $1}')"
				[[ "$source_hash" == "$hash" ]] && trusted=true
			fi
			if [[ "$trusted" != true && -n "$target" && "$target" != "$source" \
					&& -f "${target}/config/system/systemd/${unit}" && ! -L "${target}/config/system/systemd/${unit}" ]]; then
				target_hash="$(sha256sum "${target}/config/system/systemd/${unit}" | awk '{print $1}')"
				[[ "$target_hash" == "$hash" ]] && trusted=true
			fi
			[[ "$trusted" == true ]] || { engine_log "license recovery: refusing unmanaged local ${unit} (matches neither the running nor the target release's copy)"; return 1; }
			mkdir -p "$snapshot_dir" || return 1
			snapshot_path="${snapshot_dir}/${unit}"
			install -m 0600 -o root -g root "$dest" "$snapshot_path" || return 1
			[[ "$(sha256sum "$snapshot_path" | awk '{print $1}')" == "$hash" ]] || return 1
			systemctl is-enabled "$unit" >/dev/null 2>&1 && enabled=true
			license_recover_is_active "$unit" && active=true
		fi
		records+=("$(jq -nc --arg u "$unit" --argjson p "$present" --argjson e "$enabled" --argjson a "$active" --arg h "$hash" --arg r "$source" \
			--arg s "$snapshot_path" '{unit:$u,present:$p,enabled:$e,active:$a,unit_hash:(if $h=="" then null else $h end),source_release:(if $p then $r else null end),snapshot_path:(if $s=="" then null else $s end)}')")
	done
	journal_update "$CURRENT_RUN_ID" '.license_recover.snapshot = $s' --argjson s "$(printf '%s\n' "${records[@]}" | jq -s -c '.')"
}
license_recover_install() {
	local release="$1" rc=0 unit src dest hash planned
	license_recover_inventory "$release" || rc=$?
	(( rc == 0 )) || return 1
	license_recover_snapshot_once "$release" || return 1
	for unit in "${LICENSE_RECOVER_UNITS[@]}"; do
		src="${release}/config/system/systemd/${unit}"; dest="$(license_recover_unit_path "$unit")" || return 1
		planned="$(sha256sum "$src" | awk '{print $1}')"
		[[ "$planned" =~ ^[0-9a-f]{64}$ ]] || return 1
		# Journal the exact intended bytes before touching the destination. A
		# crash after this write is recoverable whether install(1) ran or not.
		journal_update "$CURRENT_RUN_ID" '.license_recover.installed = ((.license_recover.installed // []) | if any(.[]; .unit==$i.unit) then map(if .unit==$i.unit then $i else . end) else . + [$i] end)' \
			--argjson i "$(jq -nc --arg u "$unit" --arg h "$planned" --arg r "$release" '{unit:$u,unit_hash:$h,source_release:$r,installed:false}')" || return 1
		# systemctl returns non-zero for a unit that has never existed on this
		# host: that is the backfill case, not a failure. Refuse only if the
		# postcondition says the unit is still active/enabled.
		if ! systemctl stop "$unit" >/dev/null 2>&1; then
			[[ "$(unit_state "${unit%.*}")" != active ]] || return 1
		fi
		if ! systemctl disable "$unit" >/dev/null 2>&1; then
			systemctl is-enabled "$unit" >/dev/null 2>&1 && return 1
		fi
		install -m 0644 -o root -g root "$src" "$dest" || return 1
		hash="$(sha256sum "$dest" | awk '{print $1}')"
		[[ "$hash" == "$planned" ]] || return 1
		(( EUID != 0 )) || [[ "$(stat -c '%u:%g:%a' "$dest")" == 0:0:644 ]] || return 1
		journal_update "$CURRENT_RUN_ID" '(.license_recover.installed // []) |= map(if .unit==$u then .installed=true else . end)' --arg u "$unit" || return 1
	done
	systemctl daemon-reload >/dev/null 2>&1 || return 1
	for unit in "${LICENSE_RECOVER_UNITS[@]}"; do
		systemctl is-enabled "$unit" >/dev/null 2>&1 && return 1
		! license_recover_is_active "$unit" || return 1
	done
}
license_recover_activate() {
	local release="$1" rc=0
	license_recover_inventory "$release" || rc=$?
	(( rc == 0 )) || return 1
	systemctl enable shcp-license-recover.timer >/dev/null 2>&1 || return 1
	systemctl start shcp-license-recover.timer >/dev/null 2>&1 || return 1
}
license_recover_restore_snapshot() {
	local jf rec unit dest installed_hash present old_hash snapshot_path actual changed=0
	jf="$(journal_path "$CURRENT_RUN_ID")"
	jq -e '.license_recover.snapshot | type=="array" and length==2' "$jf" >/dev/null 2>&1 || return 1
	while IFS= read -r rec; do
		unit="$(jq -r '.unit' <<<"$rec")"; dest="$(license_recover_unit_path "$unit")" || return 1
		installed_hash="$(jq -r --arg u "$unit" 'first((.license_recover.installed // [])[] | select(.unit==$u) | .unit_hash) // empty' "$jf")"
		present="$(jq -r '.present' <<<"$rec")"; old_hash="$(jq -r '.unit_hash // empty' <<<"$rec")"
		if [[ -z "$installed_hash" ]]; then
			if [[ "$present" == true ]]; then
				[[ -f "$dest" && ! -L "$dest" && "$old_hash" =~ ^[0-9a-f]{64}$ && "$(sha256sum "$dest" | awk '{print $1}')" == "$old_hash" ]] || return 1
			else
				[[ ! -e "$dest" && ! -L "$dest" ]] || return 1
			fi
			continue
		fi
		[[ "$installed_hash" =~ ^[0-9a-f]{64}$ ]] || return 1
		actual=""
		if [[ -e "$dest" || -L "$dest" ]]; then
			[[ -f "$dest" && ! -L "$dest" ]] || return 1
			actual="$(sha256sum "$dest" | awk '{print $1}')"
		fi
		# Either the planned new bytes are present and need undoing, or this
		# unit was already restored before an interruption. No third state is
		# accepted.
		if [[ "$present" == true && "$actual" == "$old_hash" ]] || [[ "$present" != true && -z "$actual" ]]; then
			continue
		fi
		[[ "$actual" == "$installed_hash" ]] \
			|| { engine_log "license recovery: refusing rollback over changed ${unit}"; return 1; }
		systemctl stop "$unit" >/dev/null 2>&1 || return 1
		systemctl disable "$unit" >/dev/null 2>&1 || return 1
		if [[ "$present" == true ]]; then
			snapshot_path="$(jq -r '.snapshot_path // empty' <<<"$rec")"
			[[ "$old_hash" =~ ^[0-9a-f]{64}$ && -f "$snapshot_path" && ! -L "$snapshot_path" ]] || return 1
			[[ "$(sha256sum "$snapshot_path" | awk '{print $1}')" == "$old_hash" ]] || return 1
			install -m 0644 -o root -g root "$snapshot_path" "$dest" || return 1
		else
			rm -f -- "$dest" || return 1
		fi
		journal_update "$CURRENT_RUN_ID" '(.license_recover.installed // []) |= map(if .unit==$u then .restored=true else . end)' --arg u "$unit" || return 1
		changed=1
	done < <(jq -c '.license_recover.snapshot[]' "$jf")
	(( changed == 0 )) || systemctl daemon-reload >/dev/null 2>&1 || return 1
	while IFS= read -r rec; do
		unit="$(jq -r '.unit' <<<"$rec")"
		[[ "$(jq -r '.enabled' <<<"$rec")" != true ]] || systemctl enable "$unit" >/dev/null 2>&1 || return 1
		[[ "$(jq -r '.active' <<<"$rec")" != true ]] || systemctl start "$unit" >/dev/null 2>&1 || return 1
	done < <(jq -c '.license_recover.snapshot[]' "$jf")
}
license_recover_rollback_if_recorded() {
	local jf; jf="$(journal_path "$CURRENT_RUN_ID")"
	jq -e '.license_recover.snapshot != null' "$jf" >/dev/null 2>&1 || return 0
	license_recover_restore_snapshot
}

# The ordinary Messenger worker inventory comes from required-state.json inside
# the signature-verified panel release. Unit names and sources remain closed;
# verify/upload-cleanup keep their separate transition contracts (SC-513/531).
managed_worker_inventory() {
	# Strict for every release, outgoing or target: a 1.0 release always ships
	# required-state.json declaring every worker it carries, so a shipped-but-
	# undeclared worker or a missing declaration is an integrity fault.
	local release="$1" decl="${1}/config/system/required-state.json" unit src
	[[ -f "$decl" && ! -L "$decl" ]] || { engine_log "managed workers: release inventory ${decl} is absent or unsafe"; return 1; }
	jq -e 'type=="object" and ((keys - ["env","files","half","schema_version","units","vhost"])|length==0) and .schema_version==1 and .half=="release" and (.units|type=="array") and
		(all(.units[]; type=="object" and ((keys - ["id","optional","os_family","ref","unit","why"])|length==0) and (.id|type)=="string" and (.unit|type)=="string" and (.optional|type)=="boolean")) and
		([.units[].id]|length==(unique|length)) and ([.units[].unit]|length==(unique|length))' "$decl" >/dev/null 2>&1 \
		|| { engine_log "managed workers: malformed release inventory"; return 1; }
	while IFS= read -r unit; do
		# Exempt the base workers BEFORE the release-specific unit-name regex:
		# shcp-worker.service has no middle segment and so fails the regex, but it
		# is a known-safe base name the engine manages directly (and every release
		# declares it). Checking the regex first rejected it as an "unsafe unit
		# name" and dead-ended the flip TO any release that declares it — i.e.
		# every 0.0.50+ (updater#131). The regex still guards release-specific
		# worker names, which fall through to it.
		case "$unit" in shcp-worker.service|shcp-backup-worker.service|shcp-verify-worker.service|shcp-upload-cleanup.service) continue ;; esac
		[[ "$unit" =~ ^shcp-[a-z0-9]+(-[a-z0-9]+)*-worker\.service$ ]] \
			|| { engine_log "managed workers: unsafe unit name ${unit}"; return 1; }
		src="${release}/config/system/systemd/${unit}"
		[[ -f "$src" && ! -L "$src" ]] \
			|| { engine_log "managed workers: source ${src} is absent or unsafe"; return 1; }
		printf '%s\n' "$unit"
	done < <(jq -r '.units[] | select(.optional==false) | .unit | select(endswith("-worker.service"))' "$decl")
	for src in "${release}/config/system/systemd"/shcp-*-worker.service; do
		[[ -e "$src" || -L "$src" ]] || continue
		[[ -f "$src" && ! -L "$src" ]] || return 1
		unit="${src##*/}"
		case "$unit" in shcp-worker.service|shcp-backup-worker.service|shcp-verify-worker.service|shcp-upload-cleanup.service) continue ;; esac
		jq -e --arg u "$unit" '[.units[] | select(.unit==$u and .optional==false)] | length==1' "$decl" >/dev/null \
			|| { engine_log "managed workers: shipped ${unit} is not uniquely declared"; return 1; }
	done
}

managed_worker_unit_path() {
	[[ "$1" =~ ^shcp-[a-z0-9]+(-[a-z0-9]+)*-worker\.service$ ]] || return 1
	printf '%s/%s' "${SHCP_UPDATE_SYSTEMD_DIR:-/etc/systemd/system}" "$1"
}

# A routine run activates the live release's workers without reinstalling them.
managed_worker_installed_check() {
	local release="$1" inventory unit dest src
	REQUIRED_UNIT_BLOCKER="" REQUIRED_UNIT_DETAIL=""
	inventory="$(managed_worker_inventory "$release")" || {
		REQUIRED_UNIT_BLOCKER=required_unit_inventory_invalid
		REQUIRED_UNIT_DETAIL="required_unit_inventory_invalid: current release worker inventory is unsafe"
		return 1
	}
	while IFS= read -r unit; do
		[[ -n "$unit" ]] || continue
		dest="$(managed_worker_unit_path "$unit")" || return 1
		src="${release}/config/system/systemd/${unit}"
		if [[ ! -f "$dest" || -L "$dest" ]]; then
			REQUIRED_UNIT_BLOCKER=required_unit_missing
			REQUIRED_UNIT_DETAIL="required_unit_missing: ${unit} is not installed from the current release"
			return 1
		fi
		if ! cmp -s -- "$src" "$dest"; then
			REQUIRED_UNIT_BLOCKER=required_unit_mismatch
			REQUIRED_UNIT_DETAIL="required_unit_mismatch: ${unit} differs from the current release"
			return 1
		fi
	done <<<"$inventory"
}

# Install authenticated bytes before DB, but deliberately do not enable them:
# enabled workers can auto-start after a reboot in the flip-to-schema gap.

managed_workers_remove_absent() {
	local release="$1" target="$2" jf rec unit expected source source_hash dest actual state removed=0
	jf="$(journal_path "$CURRENT_RUN_ID")"
	while IFS= read -r rec; do
		[[ -n "$rec" ]] || continue
		unit="$(jq -r '.unit // empty' <<<"$rec")"
		expected="$(jq -r '.unit_hash // empty' <<<"$rec")"
		source="$(jq -r '.source_release // empty' <<<"$rec")"
		[[ "$unit" =~ ^shcp-[a-z0-9]+(-[a-z0-9]+)*-worker\.service$ && "$expected" =~ ^[0-9a-f]{64}$ && -n "$source" ]] || return 1
		grep -qxF -- "$unit" <<<"$target" && continue
		dest="$(managed_worker_unit_path "$unit")" || return 1
		[[ -f "${source}/config/system/systemd/${unit}" && ! -L "${source}/config/system/systemd/${unit}" ]] || return 1
		source_hash="$(sha256sum "${source}/config/system/systemd/${unit}" | awk '{print $1}')"
		[[ "$source_hash" == "$expected" ]] || return 1
		if [[ ! -e "$dest" && ! -L "$dest" ]]; then
			journal_update "$CURRENT_RUN_ID" '(.managed_workers.installed // []) |= map(if .unit == $u then . + {removed:true} else . end)' --arg u "$unit"
			continue
		fi
		[[ -f "$dest" && ! -L "$dest" ]] || return 1
		actual="$(sha256sum "$dest" | awk '{print $1}')"
		[[ "$actual" == "$expected" ]] || { engine_log "managed workers: refusing removal of changed ${unit}"; return 1; }
		systemctl stop "$unit" >/dev/null 2>&1 || return 1
		state="$(unit_state "${unit%.service}")"; case "$state" in inactive|failed|unknown) ;; *) return 1 ;; esac
		systemctl disable "$unit" >/dev/null 2>&1 || return 1
		rm -f -- "$dest" || return 1
		[[ ! -e "$dest" && ! -L "$dest" ]] || return 1
		journal_update "$CURRENT_RUN_ID" '(.managed_workers.installed // []) |= map(if .unit == $u then . + {removed:true} else . end)' --arg u "$unit"
		removed=1
	done < <(jq -c '.managed_workers.installed[]?' "$jf" 2>/dev/null || true)
	(( removed == 0 )) || systemctl daemon-reload >/dev/null 2>&1
}
managed_workers_install() {
	local release="$1" unit src dest actual inventory rc
	rc=0; inventory="$(managed_worker_inventory "$release")" || rc=$?
	(( rc == 0 )) || return 1
	managed_workers_remove_absent "$release" "$inventory" || return 1
	[[ -n "$inventory" ]] || return 0
	while IFS= read -r unit; do
		src="${release}/config/system/systemd/${unit}"; dest="$(managed_worker_unit_path "$unit")" || return 1
		[[ "$(unit_state "${unit%.service}")" != active ]] || return 1
		# best-effort: a worker this release INTRODUCES has no unit on the box yet,
		# so `systemctl disable` returns not-found (rc 1). That is not a failure —
		# an absent unit is already "not enabled". The invariant (must not be
		# enabled before we install it fresh) is enforced by the is-enabled check
		# on the next line, which correctly reads not-found as "not enabled". Hard-
		# failing here made every release that adds a managed worker un-flippable
		# (0.0.50's shcp-audit-forward-worker; updater#133).
		systemctl disable "$unit" >/dev/null 2>&1 || true
		systemctl is-enabled "$unit" >/dev/null 2>&1 && return 1
		install -m 0644 -o root -g root "$src" "$dest" || return 1
		actual="$(sha256sum "$dest" | awk '{print $1}')"
		[[ "$actual" == "$(sha256sum "$src" | awk '{print $1}')" ]] || return 1
		(( EUID != 0 )) || [[ "$(stat -c '%u:%g:%a' "$dest")" == 0:0:644 ]] || return 1
		journal_update "$CURRENT_RUN_ID" '.managed_workers.installed = ((.managed_workers.installed // []) + [$x] | unique_by(.unit))' \
			--argjson x "$(jq -nc --arg u "$unit" --arg h "$actual" --arg r "$release" '{unit:$u,unit_hash:$h,source_release:$r}')"
	done <<<"$inventory"
	systemctl daemon-reload >/dev/null 2>&1 || return 1
	while IFS= read -r unit; do
		[[ "$(unit_state "${unit%.service}")" != active ]] || return 1
	done <<<"$inventory"
}

# Enable only after schema proof; stage_restart starts or restarts from the
# run's worker snapshot and verifies the activation timestamp (SC-513, SC-996).
# managed_workers_install disabled every managed worker for the flip, so the
# enable state comes from the pre-flip snapshot: a worker the operator had
# disabled stays disabled; one the snapshot does not know is new and is enabled.
managed_workers_activate() {
	local release="$1" inventory rc unit jf="" was_enabled
	rc=0; inventory="$(managed_worker_inventory "$release")" || rc=$?
	(( rc == 0 )) || return 1
	[[ -z "${CURRENT_RUN_ID:-}" ]] || jf="$(journal_path "$CURRENT_RUN_ID")"
	while IFS= read -r unit; do
		[[ -n "$unit" ]] || continue
		was_enabled=""
		[[ -z "$jf" ]] || was_enabled="$(jq -r --arg u "$unit" '[.workers.snapshot[]? | select(.unit == $u) | .enabled | tostring][0] // empty' "$jf" 2>/dev/null)"
		[[ "$was_enabled" != false ]] || continue
		systemctl enable "$unit" >/dev/null 2>&1 || return 1
	done <<<"$inventory"
}

# The upload cleanup unit is release-owned but installed out-of-tree. Unlike the
# aux workers, this unit's exact consumer contract is additionally verified against
# the signed manifest (upload_cleanup_artifact_agrees) before it is activated.
UPLOAD_CLEANUP_UNIT="shcp-upload-cleanup.service"
UPLOAD_CLEANUP_UNIT_PATH="${SHCP_UPDATE_UPLOAD_CLEANUP_UNIT:-/etc/systemd/system/${UPLOAD_CLEANUP_UNIT}}"

upload_cleanup_source() { printf '%s/config/system/systemd/%s' "$1" "$UPLOAD_CLEANUP_UNIT"; }

upload_cleanup_unit_contract_ok() {
	local path="$1"
	[[ -f "$path" && ! -L "$path" ]] || return 1
	local required
	for required in \
		'User=shcp' \
		'WorkingDirectory=/opt/shcp' \
		'ExecStart=/usr/sbin/shcpd php-cli bin/console messenger:consume upload_cleanup --time-limit=3600 --memory-limit=128M' \
		'Restart=always' 'RestartSec=5' 'Environment=APP_ENV=prod' \
		'NoNewPrivileges=yes' 'PrivateTmp=yes' 'ProtectSystem=full' \
		'ProtectHome=read-only' 'RestrictSUIDSGID=yes' \
		'RestrictAddressFamilies=AF_UNIX' 'SystemCallArchitectures=native' \
		'SystemCallFilter=@system-service' 'WantedBy=multi-user.target'; do
		grep -Fxq "$required" "$path" || return 1
	done
	return 0
}

upload_cleanup_release_declares() {
	local release="$1" src route
	src="$(upload_cleanup_source "$release")"
	route="${release}/config/packages/messenger.yaml"
	[[ -f "$route" && ! -L "$route" ]] || return 1
	grep -Fxq "            'Shcp\\Message\\Files\\AbandonFileUploadSessionMessage': upload_cleanup" "$route" \
		&& upload_cleanup_unit_contract_ok "$src"
}

upload_cleanup_artifact_agrees() {
	local release="$1" receivers="$2" manifest_declares=false artifact_declares=false
	jq -e 'index("upload_cleanup") != null' <<<"$receivers" >/dev/null 2>&1 && manifest_declares=true
	upload_cleanup_release_declares "$release" && artifact_declares=true
	[[ "$manifest_declares" == "$artifact_declares" ]] || {
		engine_log "upload cleanup: authenticated manifest receivers and extracted route/unit disagree"
		return 1
	}
}

upload_cleanup_snapshot_once() {
	local jf; jf="$(journal_path "$CURRENT_RUN_ID")"
	jq -e '.upload_cleanup.snapshot != null' "$jf" >/dev/null 2>&1 && return 0
	local present=false enabled=false active=false hash="" source_release=""
	if [[ -e "$UPLOAD_CLEANUP_UNIT_PATH" || -L "$UPLOAD_CLEANUP_UNIT_PATH" ]]; then
		[[ -f "$UPLOAD_CLEANUP_UNIT_PATH" && ! -L "$UPLOAD_CLEANUP_UNIT_PATH" ]] || {
			engine_log "upload cleanup: refusing unexpected unit type at ${UPLOAD_CLEANUP_UNIT_PATH}"
			return 1
		}
		present=true
		hash="$(sha256sum "$UPLOAD_CLEANUP_UNIT_PATH" 2>/dev/null | awk '{print $1}')"
		source_release="$(panel_link_target)"
		local source_hash=""
		[[ -n "$source_release" ]] && source_hash="$(sha256sum "$(upload_cleanup_source "$source_release")" 2>/dev/null | awk '{print $1}')"
		[[ -n "$source_release" && "$source_hash" == "$hash" ]] || {
			engine_log "upload cleanup: refusing unmanaged local unit ${UPLOAD_CLEANUP_UNIT_PATH} (${hash:-unreadable})"
			return 1
		}
		systemctl is-enabled "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 && enabled=true
		[[ "$(unit_state shcp-upload-cleanup)" == active ]] && active=true
	fi
	journal_update "$CURRENT_RUN_ID" '.upload_cleanup.snapshot = $s' --argjson s "$(jq -nc \
		--argjson p "$present" --argjson e "$enabled" --argjson a "$active" \
		--arg h "$hash" --arg r "$source_release" \
		'{present:$p,enabled:$e,active:$a,unit_hash:(if $h=="" then null else $h end),source_release:(if $r=="" then null else $r end)}')"
}

upload_cleanup_install_for_release() {
	local release="$1" src expected actual
	src="$(upload_cleanup_source "$release")"
	[[ -f "$src" ]] || return 0
	upload_cleanup_unit_contract_ok "$src" || {
		engine_log "upload cleanup: canonical unit in ${release} violates the exact command/User contract"
		return 1
	}
	expected="$(sha256sum "$src" | awk '{print $1}')" || return 1
	install -m 0644 -o root -g root "$src" "$UPLOAD_CLEANUP_UNIT_PATH" || return 1
	actual="$(sha256sum "$UPLOAD_CLEANUP_UNIT_PATH" | awk '{print $1}')" || return 1
	[[ "$actual" == "$expected" ]] || { engine_log "upload cleanup: installed unit hash mismatch"; return 1; }
	upload_cleanup_unit_contract_ok "$UPLOAD_CLEANUP_UNIT_PATH" || return 1
	if (( EUID == 0 )); then
		[[ "$(stat -c '%u:%g:%a' "$UPLOAD_CLEANUP_UNIT_PATH" 2>/dev/null)" == '0:0:644' ]] || {
			engine_log "upload cleanup: installed unit owner/mode is not root:root 0644"
			return 1
		}
	fi
	systemctl daemon-reload >/dev/null 2>&1 || return 1
	# Enable only. stage_restart/panel_workers_start is the sole activation seam,
	# after the target schema/shared-table verification has completed.
	systemctl enable "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
	[[ "$(unit_state shcp-upload-cleanup)" != active ]] || {
		engine_log "upload cleanup: unit became active before worker restoration"
		return 1
	}
	journal_update "$CURRENT_RUN_ID" '.upload_cleanup.installed = $i' --argjson i "$(jq -nc \
		--arg h "$actual" --arg r "$release" \
		'{unit_hash:$h,source_release:$r,target_enabled:true,target_active:true}')"
}

upload_cleanup_reassign_pending() {
	local db="$SHCP_UPDATE_DB" out sb dbefore affected sa da expected_da
	# EP-4b: the panel `upload_cleanup` queue is schema-specific. A product that
	# declares queue-drain-check N/A has no such queue to drain -- SKIP, do not
	# error, leaving the database untouched. Inert when PRODUCT is unset.
	if [[ -n "$PRODUCT" ]] && ! product_db_op_applies "$PRODUCT" queue-drain-check; then
		engine_log "upload cleanup: queue-drain-check N/A for product ${PRODUCT} -- no upload_cleanup queue to drain (skipped)"
		return 0
	fi
	[[ -f "$db" ]] || { engine_log "upload cleanup: canonical Messenger database ${db} is absent"; return 1; }
	# .timeout + db_fix_ownership after each root open, for the reasons given at
	# verify_worker_reassign_pending: wait out a live reader rather than fail
	# BEGIN IMMEDIATE, and never leave root-owned sidecars beside the panel DB.
	out="$(sqlite3 -batch -bail -cmd ".timeout ${DB_BUSY_MS}" "$db" <<'SQL'
BEGIN IMMEDIATE;
CREATE TEMP TABLE upload_cleanup_proof(source_before INTEGER, dest_before INTEGER, affected INTEGER);
INSERT INTO upload_cleanup_proof(source_before,dest_before,affected)
SELECT
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='upload_cleanup'),
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='backup'), 0;
UPDATE messenger_messages SET queue_name='backup' WHERE queue_name='upload_cleanup';
UPDATE upload_cleanup_proof SET affected=changes();
SELECT source_before,dest_before,affected,
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='upload_cleanup'),
 (SELECT COUNT(*) FROM messenger_messages WHERE queue_name='backup')
FROM upload_cleanup_proof;
COMMIT;
SQL
)" || { db_fix_ownership "$db"; engine_log "upload cleanup: transactional queue reassignment failed"; return 1; }
	db_fix_ownership "$db"
	IFS='|' read -r sb dbefore affected sa da <<<"$out"
	[[ "$sb" =~ ^[0-9]+$ && "$dbefore" =~ ^[0-9]+$ && "$affected" =~ ^[0-9]+$ \
		&& "$sa" =~ ^[0-9]+$ && "$da" =~ ^[0-9]+$ ]] || return 1
	# Bash arithmetic is signed; reject values outside its safe decimal domain.
	for out in "$sb" "$dbefore" "$affected" "$sa" "$da"; do (( ${#out} < 19 )) || return 1; done
	[[ "${SHCP_UPDATE_TEST_QUEUE_PROOF_MISMATCH:-0}" == 1 ]] && affected=$((affected + 1))
	expected_da=$((dbefore + sb)); (( expected_da >= dbefore )) || return 1
	if (( affected != sb || sa != 0 || da != expected_da )); then
		engine_log "upload cleanup: queue count proof mismatch source=${sb} affected=${affected} source_after=${sa} dest_before=${dbefore} dest_after=${da}"
		return 1
	fi
	local recheck
	recheck="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$db" "SELECT COUNT(*) FROM messenger_messages WHERE queue_name='upload_cleanup';" 2>/dev/null || true)"
	db_fix_ownership "$db"
	[[ "$recheck" == 0 ]] || return 1
	journal_update "$CURRENT_RUN_ID" '.upload_cleanup.rollback_queue = $q' --argjson q "$(jq -nc \
		--argjson s "$sb" --argjson a "$affected" --argjson d "$da" \
		'{source_count:$s,affected_count:$a,destination_count_after:$d}')"
}

upload_cleanup_remove_managed() {
	local run_id="${1:-$CURRENT_RUN_ID}" jf expected_hash source_release source_hash
	[[ -e "$UPLOAD_CLEANUP_UNIT_PATH" || -L "$UPLOAD_CLEANUP_UNIT_PATH" ]] || return 0
	[[ -f "$UPLOAD_CLEANUP_UNIT_PATH" && ! -L "$UPLOAD_CLEANUP_UNIT_PATH" ]] || return 1
	jf="$(journal_path "$run_id")"
	expected_hash="$(jq -r '.upload_cleanup.installed.unit_hash // empty' "$jf" 2>/dev/null || true)"
	source_release="$(jq -r '.upload_cleanup.installed.source_release // empty' "$jf" 2>/dev/null || true)"
	source_hash="$(sha256sum "$(upload_cleanup_source "$source_release")" 2>/dev/null | awk '{print $1}')"
	[[ -n "$expected_hash" && -n "$source_release" && "$source_hash" == "$expected_hash" \
		&& "$(sha256sum "$UPLOAD_CLEANUP_UNIT_PATH" 2>/dev/null | awk '{print $1}')" == "$expected_hash" ]] || {
		engine_log "upload cleanup: refusing to remove unmanaged unit ${UPLOAD_CLEANUP_UNIT_PATH}"
		return 1
	}
	systemctl stop "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
	systemctl disable "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
	rm -f -- "$UPLOAD_CLEANUP_UNIT_PATH" || return 1
	systemctl daemon-reload >/dev/null 2>&1 || return 1
}

# --- panel DB: snapshot, integrity, restore (UPD-4; AD-7, §4.2-2, §4.5-4) ------
# The panel database is the one thing an update can destroy that no artifact can
# rebuild. Everything in this section exists to make exactly two claims
# checkable:
#   1. before anything is migrated there is a file that is PROVABLY a complete,
#      self-consistent copy of the live database, and
#   2. restoring that file yields that file — not a blend of it and whatever the
#      panel wrote afterwards.
#
# Snapshots land in the app-owned backup tree (SC-272): 0600, owned by the panel
# user so the unprivileged web app can list/serve/delete them, and every path
# component is refused if it is a symlink — sqlite3 writes THROUGH a symlinked
# destination (verified: `.backup` onto a symlink grows the link's target and
# leaves the symlink in place), so the check has to happen before sqlite3 runs.
DB_BACKUP_DIR="${SHCP_UPDATE_DB_BACKUP_DIR:-/var/lib/shcp/db-backups/pre-update}"
DB_OWNER="${SHCP_UPDATE_DB_OWNER:-shcp:shcp}"
CONFIG_ROOT="${SHCP_UPDATE_CONFIG_ROOT:-/}"

# §4.5 retention: the last 2 pre-update snapshots. Pruned in FINALIZE and never
# mid-run — a failed run never reaches finalize, which is exactly why its
# snapshot (the one its rollback needs) survives.
SNAPSHOT_KEEP=2

# Busy timeout for every engine open of the live DB. The snapshot is taken at
# stage 2, before the maintenance flag goes up at stage 4, so it runs against a
# fully live and writable panel: without a timeout a `.backup` can lose the race
# with a committing writer and return "database is locked", failing a run over a
# millisecond of contention.
DB_BUSY_MS=15000

# Cap on the schema-upgrade transcript copied into the journal. The full output
# stays on disk: journal_update re-serializes the WHOLE document on every call,
# so an unbounded blob would be rewritten by every later stage.
MAX_DB_LOG_BYTES=$((64 * 1024))

DB_SNAPSHOT_INTEGRITY=""
DB_SNAPSHOT_FK=""
DB_SNAPSHOT_WHY=""

# path_no_symlink <absolute-path> — true when no component of the path is a
# symlink. Checked component by component from the root down, because a symlink
# ANYWHERE above the leaf is enough to redirect the write, and the leaf itself
# counts. Fails closed on a relative path (there is no cwd worth trusting here).
path_no_symlink() {
	local p="$1"
	[[ "$p" == /* ]] || return 1
	local -a parts=()
	IFS='/' read -r -a parts <<<"${p#/}"
	local acc="" part
	for part in "${parts[@]+"${parts[@]}"}"; do
		[[ -n "$part" ]] || continue
		acc="${acc}/${part}"
		[[ -L "$acc" ]] && return 1
	done
	return 0
}

# db_chown_target_safe <path> — true only when <path> is safe to hand to a root
# chown/chmod: no component of its path is a symlink, it is a regular file (not a
# symlink, FIFO, device, or directory), and its link count is exactly 1.
#
# This is the R4 half of SC-663. The panel account owns the DB's directory, so it
# can plant a name where a root DB-ownership repair expects a sidecar. A symlink
# there (caught by path_no_symlink + -f) would redirect a following chown/chmod
# onto e.g. /etc/shadow; a HARDLINK to a root-owned file (caught by the link-count
# check) would do the same while passing every symlink test. Owner is NOT a safe
# discriminator — a hardlink to root-owned /etc/shadow is itself root-owned and
# would pass an "owned by root or shcp" test — so nlink==1 is the hardlink guard:
# a legitimate SQLite DB or sidecar is always a lone regular file. (SC-033)
db_chown_target_safe() {
	local p="$1" nlink
	path_no_symlink "$p" || return 1
	[[ -f "$p" && ! -L "$p" ]] || return 1
	nlink="$(stat -c %h -- "$p" 2>/dev/null)" || return 1
	[[ "$nlink" == 1 ]] || return 1
	return 0
}

# db_chown_target_verify <path> <owner:group> <octal-mode> — true when an lstat of
# <path> shows a regular file whose owner:group and mode are exactly as set. The
# post-op check that closes the chmod TOCTOU (SC-663): both mutating ops are
# no-follow (`chown -h`, db_chmod_nofollow), so a path swapped to a symlink
# in the window between them left the real target untouched — this detects that
# the final path is no longer the regular file we wrote and lets the caller log
# and refuse rather than trust a raced repair. `stat` without -L lstats (verified
# on Debian 13 and AlmaLinux 10.2); a 0-byte file reports "regular empty file".
db_chown_target_verify() {
	local p="$1" want_owner="$2" want_mode="$3" ftype owner mode
	ftype="$(stat -c %F -- "$p" 2>/dev/null)" || return 1
	[[ "$ftype" == "regular file" || "$ftype" == "regular empty file" ]] || return 1
	owner="$(stat -c '%U:%G' -- "$p" 2>/dev/null)" || return 1
	mode="$(stat -c '%a' -- "$p" 2>/dev/null)" || return 1
	[[ "$owner" == "$want_owner" && "$mode" == "$want_mode" ]]
}

# db_chmod_nofollow <octal-mode> <path> — chmod <path> without following a symlink
# at its last component. Opens the name O_PATH|O_NOFOLLOW, refuses unless the
# opened inode is a regular file with exactly one link, then chmods THAT inode
# through its /proc/self/fd magic link — the mechanism glibc's fchmodat(
# AT_SYMLINK_NOFOLLOW) uses, so a name swapped after the open cannot redirect it.
# Returns non-zero on a refusal, a failed chmod, or no python3.
#
# NOT `chmod --no-dereference`: that option is coreutils 9.5+. Ubuntu 24.04 ships
# 9.4, which rejects it as an unrecognized option, and the old `|| true` turned
# that into a silent skip of every DB mode repair on Noble (shcp-updater#187; the
# installer hit the same wall loudly, installer#986/#995, whose
# _shcp_chmod_nofollow this mirrors). python3 is on every panel box on both
# families (the installer installs it); -I keeps PYTHON* environment and the user
# site directory out of a root process, as the engine's other python3 calls do.
db_chmod_nofollow() {
	command -v python3 >/dev/null 2>&1 || return 1
	python3 -I - "$1" "$2" 2>/dev/null <<'PY'
import os, stat, sys
try:
    mode = int(sys.argv[1], 8)
    fd = os.open(sys.argv[2], os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC)
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
            sys.exit(1)
        os.chmod("/proc/self/fd/%d" % fd, mode)
    finally:
        os.close(fd)
except (OSError, ValueError):
    sys.exit(1)
PY
}

# db_dir_ensure_nofollow <dir> <owner:group> <octal-mode> — create <dir> if
# absent and set its mode and owner WITHOUT following a symlink at either its
# parent or its own name. The directory half of SC-663 / R4: db_backup_dir_prepare
# used `install -d` + `chown` + `chmod` on a path inside the panel-owned
# /var/lib/shcp, and all three follow a symlink at the final component —
# `install -d` on an existing symlink-to-dir chmods the TARGET (verified with
# coreutils 9.x) — so a `shcp` account that swapped pre-update/ for a symlink
# between the top-down walk and the install got root to chmod/chown any
# directory on the box. Here the parent is opened O_NOFOLLOW|O_DIRECTORY, the
# leaf is created with mkdirat (never through a link) and re-opened O_NOFOLLOW,
# fstat proves it is a directory, and the mode/owner land on THAT inode through
# /proc/self/fd. Exit 0 done; 1 refused (symlink, not a directory, cannot
# create); 2 the directory is right but the chown failed (EPERM — the caller
# decides whether that is fatal, as the old `chown || log` did). Needs python3,
# which every panel box carries (db_chmod_nofollow relies on it already).
db_dir_ensure_nofollow() {
	command -v python3 >/dev/null 2>&1 || return 1
	python3 -I - "$1" "$2" "$3" 2>/dev/null <<'PY'
import os, stat, sys, pwd, grp
try:
    path, owner, mode = sys.argv[1].rstrip("/"), sys.argv[2], int(sys.argv[3], 8)
    user, group = owner.split(":", 1)
    uid = int(user) if user.isdigit() else pwd.getpwnam(user).pw_uid
    gid = int(group) if group.isdigit() else grp.getgrnam(group).gr_gid
    parent, name = os.path.split(path)
    if not parent or not name:
        sys.exit(1)
    flags = os.O_PATH | os.O_NOFOLLOW | os.O_DIRECTORY | os.O_CLOEXEC
    pfd = os.open(parent, flags)
    try:
        try:
            os.mkdir(name, mode, dir_fd=pfd)
        except FileExistsError:
            pass
        fd = os.open(name, flags, dir_fd=pfd)
    finally:
        os.close(pfd)
    try:
        if not stat.S_ISDIR(os.fstat(fd).st_mode):
            sys.exit(1)
        ref = "/proc/self/fd/%d" % fd
        os.chmod(ref, mode)
        try:
            os.chown(ref, uid, gid)
        except PermissionError:
            sys.exit(2)
    finally:
        os.close(fd)
except (OSError, ValueError, KeyError):
    sys.exit(1)
PY
}

# db_target_verify_lstat <path> <owner> <octal-mode> — like db_chown_target_verify
# (lstat: a lone regular file with exactly this owner and mode) but accepts the
# owner in EITHER spelling: `user:group` names, or the `uid:gid` numbers
# db_restore_from_snapshot captures from the live file with `stat %u:%g`.
db_target_verify_lstat() {
	local p="$1" want_owner="$2" want_mode="$3" ftype nlink owner mode
	ftype="$(stat -c %F -- "$p" 2>/dev/null)" || return 1
	[[ "$ftype" == "regular file" || "$ftype" == "regular empty file" ]] || return 1
	nlink="$(stat -c %h -- "$p" 2>/dev/null)" || return 1
	[[ "$nlink" == 1 ]] || return 1
	if [[ "$want_owner" =~ ^[0-9]+:[0-9]+$ ]]; then
		owner="$(stat -c '%u:%g' -- "$p" 2>/dev/null)" || return 1
	else
		owner="$(stat -c '%U:%G' -- "$p" 2>/dev/null)" || return 1
	fi
	mode="$(stat -c '%a' -- "$p" 2>/dev/null)" || return 1
	[[ "$owner" == "$want_owner" && "$mode" == "${want_mode#0}" ]]
}

# db_sidecar_names <db> — the three sidecar files SQLite materializes beside the
# DB. The single source of truth for that set: db_fix_ownership guards+chowns
# each, db_sidecars_remove deletes each, and the db/ relocation (SC-663 Slice B)
# reuses it unchanged since every name derives from the DB path argument.
db_sidecar_names() {
	local db="$1"
	printf '%s\n' "${db}-wal" "${db}-shm" "${db}-journal"
}

# The engine runs as root; the panel DB is DB_OWNER 0640 and its WAL/SHM/journal
# sidecars inherit the uid of whoever creates them. A root sqlite3 open — even a
# read-only one — creates root-owned sidecars, after which the FrankenPHP daemon
# (User=shcp) can no longer write them and the panel is read-only. The
# installer's sqlite_setup runs exactly this loop after its own root console
# calls; the engine must too, after every open of the live DB.
#
# Symlink/hardlink-safe (SC-663 / R4): the DB lives in a panel-writable
# directory, so a `shcp` user can plant a symlink or hardlink at a sidecar name.
# Each target is refused up front unless it is a lone regular file
# (db_chown_target_safe). Both mutating ops are no-follow — `chown -h` and
# db_chmod_nofollow (never `chmod --no-dereference`, which coreutils 9.4 on
# Ubuntu 24.04 rejects, shcp-updater#187) — so a name swapped for a symlink at ANY
# point between the guard and the write affects only the link, never its
# referent; that closes the chmod TOCTOU a plain chmod would leave open. A failed
# no-follow chmod is logged per target, never swallowed; a post-op lstat
# re-verify then detects and logs a target that was swapped under the repair.
# Always returns 0: every caller invokes it bare under `set -euo pipefail`, on
# success and failure paths alike, and an ownership shortfall must not abort a
# stage mid-update — the log line is the failure signal, as for chown.
db_fix_ownership() {
	local db="$1" target
	[[ -e "$db" || -L "$db" ]] || return 0
	local -a targets=("$db")
	while IFS= read -r target; do targets+=("$target"); done \
		< <(db_sidecar_names "$db")
	for target in "${targets[@]}"; do
		[[ -e "$target" || -L "$target" ]] || continue
		if ! db_chown_target_safe "$target"; then
			engine_log "db_fix_ownership: refusing ${target} — not a lone regular file (symlink/hardlink/non-regular), SC-663"
			continue
		fi
		chown -h "$DB_OWNER" "$target" 2>/dev/null \
			|| engine_log "could not chown ${target} to ${DB_OWNER}"
		if ! db_chmod_nofollow 0640 "$target"; then
			engine_log "db_fix_ownership: could not chmod ${target} 0640 no-follow (not a lone regular file, swapped mid-op?, or python3 absent) — SC-663"
			continue
		fi
		db_chown_target_verify "$target" "$DB_OWNER" 640 \
			|| engine_log "db_fix_ownership: ${target} is not the regular file we set after the repair (swapped mid-op?), left untouched — SC-663"
	done
	return 0
}

# A read-only open of a WAL database still materializes -wal/-shm beside it. For
# the live DB that is handled by db_fix_ownership; for a snapshot they are pure
# debris, and leaving them means retention has to reason about them.
db_sidecars_remove() {
	local f="$1" sidecar
	while IFS= read -r sidecar; do rm -f "$sidecar"; done \
		< <(db_sidecar_names "$f")
}

# db_integrity <file> — echoes "ok", or the reason it is not ok.
#
# BOTH halves of the test are needed and neither is sufficient. Damage the b-tree
# walker can still parse is reported as error TEXT with exit 0; damage it cannot
# walk exits 11; a file that is not a database at all exits 26. So exit status
# alone accepts a corrupt database, and output alone accepts a crashed sqlite3.
db_integrity() {
	local f="$1" out
	if ! out="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$f" \
			'PRAGMA integrity_check;' 2>&1)"; then
		printf 'error: %s' "${out//$'\n'/ }"
		return 0
	fi
	if [[ "$out" == "ok" ]]; then printf 'ok'; else printf '%s' "${out//$'\n'/ }"; fi
}

# db_foreign_keys <file> — echoes "ok" when PRAGMA foreign_key_check prints
# nothing. It exits 0 whether or not it finds violations, so EMPTY OUTPUT is the
# only signal. Orthogonal to integrity_check, not redundant with it: that one
# walks pages and never looks at references, this one finds orphaned rows and
# never looks at pages (verified both ways round).
db_foreign_keys() {
	local f="$1" out
	if ! out="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$f" \
			'PRAGMA foreign_key_check;' 2>&1)"; then
		printf 'error: %s' "${out//$'\n'/ }"
		return 0
	fi
	if [[ -z "$out" ]]; then printf 'ok'; else printf '%s' "${out//$'\n'/ }"; fi
}

# db_fk_relations <db_foreign_keys()-shaped string> -- one violation RELATION
# per line (table, parent, fkid -- rowid dropped). "ok", an "error: ..."
# string, or empty input yield nothing: none of those is a set of violations.
#
# rowid is deliberately excluded from the identity. SQLite implements almost
# every ALTER by rebuilding the table and copying rows across, which
# renumbers rowid -- shcp-base's own UpdateDbCommand::newForeignKeyViolations()
# hit exactly this and compares the same way for the same reason: a
# rowid-exact comparison would report every PRE-EXISTING orphan as brand new
# the instant a schema change touched its table.
db_fk_relations() {
	local raw="$1" record table rowid parent fkid
	[[ "$raw" != ok && "$raw" != error:* && -n "$raw" ]] || return 0
	for record in $raw; do
		IFS='|' read -r table rowid parent fkid <<<"$record"
		[[ -n "$table" ]] || continue
		printf '%s|%s|%s\n' "$table" "$parent" "$fkid"
	done
}

# db_fk_new_violations <current> <baseline> -- both db_foreign_keys()-shaped.
# Prints the CURRENT relations absent from BASELINE, one per line (normalized,
# order-independent set difference). Empty output means current introduces
# nothing baseline did not already carry -- a bare row-count or exact-string
# comparison would both miss "5 pre-existing orphans became 5 different
# orphans" and flag "identical 5 orphans after a table rebuild renumbered
# their rowid" as new; comparing by relation catches the former and ignores
# the latter.
db_fk_new_violations() {
	local current="$1" baseline="$2" rel
	local -A known=()
	while IFS= read -r rel; do
		[[ -n "$rel" ]] && known["$rel"]=1
	done < <(db_fk_relations "$baseline")
	while IFS= read -r rel; do
		[[ -n "$rel" ]] || continue
		[[ -n "${known[$rel]:-}" ]] || printf '%s\n' "$rel"
	done < <(db_fk_relations "$current")
}

# verify_worker_db_fk_baseline <run_id> -- this run's own recorded
# pre-migration `PRAGMA foreign_key_check` (stage_snapshot's
# foreign_key_check_before), db_foreign_keys()-shaped. Every 1.0 journal
# records it; there is no fallback to another field. Empty output is the
# honest answer when it is NOT known (no run id, no journal, a run whose
# journal is otherwise unreadable): db_fk_new_violations then treats every
# current violation as new, which is correct -- without ANY baseline to
# compare against this gate cannot tell a pre-existing wart from a fresh one.
# That is a stricter default than the recorded-baseline case, deliberately.
verify_worker_db_fk_baseline() {
	local run_id="$1" jf before
	[[ -n "$run_id" ]] || return 0
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" ]] || return 0
	before="$(jq -r '(.stages[]? | select(.stage == "snapshot")
		| .result.foreign_key_check_before) // empty' "$jf" 2>/dev/null || true)"
	printf '%s' "$before"
}

# db_snapshot_usable <file> — the conjunction that decides whether a file may be
# CALLED a snapshot. Sets DB_SNAPSHOT_INTEGRITY / DB_SNAPSHOT_WHY.
#
# The reason this is a conjunction and not just an integrity check: a failed or
# interrupted `.backup` LEAVES ITS DESTINATION BEHIND, and PRAGMA
# integrity_check answers "ok" on a zero-byte file (verified: a 0-byte file
# passes integrity_check AND foreign_key_check, with zero objects). Gate on
# integrity alone and the engine green-lights an empty snapshot; the first
# rollback then restores an empty database over the panel's.
#
# foreign_key_check is deliberately NOT part of the gate. A snapshot is a
# faithful copy: if the live database already had an orphaned row, so does the
# snapshot, and refusing to protect a host over a pre-existing data wart would
# block its security patching. The verdict is recorded instead, pre and post, so
# a violation that appears during a migration is attributable to the migration.
db_snapshot_usable() {
	local f="$1"
	DB_SNAPSHOT_INTEGRITY="" DB_SNAPSHOT_FK="" DB_SNAPSHOT_WHY=""
	if [[ -L "$f" || ! -f "$f" ]]; then
		DB_SNAPSHOT_WHY="not a regular file: ${f}"
		return 1
	fi
	if [[ ! -s "$f" ]]; then
		DB_SNAPSHOT_WHY="snapshot is 0 bytes (an interrupted .backup leaves one behind)"
		return 1
	fi
	local tables
	if ! tables="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$f" \
			"SELECT count(*) FROM sqlite_master WHERE type = 'table';" 2>&1)"; then
		DB_SNAPSHOT_WHY="not readable as a database: ${tables//$'\n'/ }"
		return 1
	fi
	if [[ ! "$tables" =~ ^[0-9]+$ || "$tables" -eq 0 ]]; then
		DB_SNAPSHOT_WHY="snapshot carries no tables"
		return 1
	fi
	DB_SNAPSHOT_INTEGRITY="$(db_integrity "$f")"
	DB_SNAPSHOT_FK="$(db_foreign_keys "$f")"
	if [[ "$DB_SNAPSHOT_INTEGRITY" != "ok" ]]; then
		DB_SNAPSHOT_WHY="integrity_check: ${DB_SNAPSHOT_INTEGRITY}"
		return 1
	fi
	return 0
}

# db_logical_sha <file> — a CONTENT fingerprint that a structurally valid
# mixture cannot match: the sqlite3 `.dump` text (schema plus every row, in a
# deterministic order for identical content) hashed.
#
# Comparing the FILES would not do. A restored main file with a stale WAL beside
# it is byte-identical to the snapshot and still reads back as the pre-restore
# database — the mixture lives in the sidecar, not in the bytes being compared.
db_logical_sha() {
	local f="$1" out
	out="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$f" '.dump' 2>/dev/null \
		| sha256sum)" || return 1
	printf '%s' "${out%% *}"
}

# Provision the snapshot tree the way SC-272 requires: app-owned, no symlink in
# the path, tight modes. Nothing else on the box creates it — the installer does
# not (no `db-backups` anywhere in shcp-installer) and the updater's postinst
# only makes /var/lib/shcp/update — so the engine owns it. The intermediate
# /var/lib/shcp/db-backups is created 0750 DB_OWNER on purpose: the panel's own
# tenant-backup handler mkdir -p's that base as root and then chowns only its own
# leaf, which leaves the panel user unable to traverse it.
db_backup_dir_prepare() {
	DB_SNAPSHOT_WHY=""
	local dir="$DB_BACKUP_DIR"
	if [[ "$dir" != /* ]]; then
		DB_SNAPSHOT_WHY="snapshot dir must be an absolute path (got '${dir}')"
		return 1
	fi
	# A single quote would break out of the `.backup '<dest>'` dot-command
	# argument. Nothing legitimate contains one; refuse rather than escape.
	if [[ "$dir" == *"'"* ]]; then
		DB_SNAPSHOT_WHY="snapshot dir contains a quote"
		return 1
	fi
	# Walk top-down and refuse a symlink at every level BEFORE creating anything
	# below it — this walk is the whole of SC-272's fail-closed rule here.
	local -a parts=()
	IFS='/' read -r -a parts <<<"${dir#/}"
	local acc="" part
	for part in "${parts[@]+"${parts[@]}"}"; do
		[[ -n "$part" ]] || continue
		acc="${acc}/${part}"
		if [[ -L "$acc" ]]; then
			DB_SNAPSHOT_WHY="refusing a symlinked path component: ${acc}"
			return 1
		fi
		if [[ -e "$acc" && ! -d "$acc" ]]; then
			DB_SNAPSHOT_WHY="path component is not a directory: ${acc}"
			return 1
		fi
	done
	# Create + mode + owner through db_dir_ensure_nofollow, never `install -d` /
	# `chown` / `chmod` by name: every one of those follows a symlink at the
	# final component, and the walk above is only a pre-check — a component
	# swapped for a symlink between the walk and the create would have had root
	# chmod/chown the link's TARGET (SC-663 / R4, the directory analogue of the
	# db_fix_ownership fix). db-backups gets 0750 (the panel user must traverse
	# it), pre-update 0700; neither call touches /var/lib.
	local parent="${dir%/*}" rc
	if [[ -n "$parent" ]]; then
		rc=0; db_dir_ensure_nofollow "$parent" "$DB_OWNER" 0750 || rc=$?
		case "$rc" in
			0) ;;
			2) engine_log "snapshot: could not chown ${parent} to ${DB_OWNER}" ;;
			*) engine_log "snapshot: ${parent} is not a real directory (symlink swapped in?) — refusing to set its mode/owner (SC-663)" ;;
		esac
	fi
	rc=0; db_dir_ensure_nofollow "$dir" "$DB_OWNER" 0700 || rc=$?
	case "$rc" in
		0) ;;
		2) engine_log "snapshot: could not chown ${dir} to ${DB_OWNER}" ;;
		*) DB_SNAPSHOT_WHY="cannot create ${dir} as a real directory (a symlink or non-directory is in its place, or python3 is absent) — SC-663"
		   return 1 ;;
	esac
	# Re-check the leaf: a component could have been swapped for a symlink
	# between the walk above and the mkdir.
	if ! path_no_symlink "$dir"; then
		DB_SNAPSHOT_WHY="snapshot dir path became a symlink"
		return 1
	fi
	return 0
}

# db_snapshot_create <live_db> <dest> — the SQLite online backup.
#
# Taken in a ROOT-HELD scratch directory and moved into <dest> finished, never
# written at <dest> directly (SC-663 / R4). <dest> is `<run-id>.db` inside the
# panel-owned pre-update/ directory, and the run id is readable from status.json
# before this stage runs, so a `shcp`-level attacker can inotify the directory,
# rename the freshly created file away and plant `<id>.db -> /etc/shadow` in the
# window between our create and the chown/.backup that followed it: root's chown
# then landed on the target, and sqlite3's `.backup` — which takes a PATH and
# follows symlinks, with no fd it could be handed instead — wrote a database
# through the link. private_scratch_dir (PrivateTmp, every ancestor root's and
# not writable by others) is the one place nothing unprivileged can rename, so
# the copy is made, mode-set, owner-set and fingerprinted there, moved into
# place as a single operation, and the name is re-verified by lstat and byte
# digest before anything trusts it. The content fingerprint is left in
# DB_SNAPSHOT_SHA for stage_snapshot to journal (root-held), so a snapshot the
# panel account can rewrite at leisure afterwards is refused at restore.
DB_SNAPSHOT_SHA=""
db_snapshot_create() {
	local db="$1"
	local dest="$2"
	DB_SNAPSHOT_WHY=""
	DB_SNAPSHOT_SHA=""
	if [[ "$dest" == *"'"* ]]; then
		DB_SNAPSHOT_WHY="snapshot path contains a quote"
		return 1
	fi
	if ! path_no_symlink "$dest"; then
		DB_SNAPSHOT_WHY="refusing a symlinked snapshot destination: ${dest}"
		return 1
	fi
	local scratch tmp err need have bytes_sha
	if ! scratch="$(private_scratch_dir)"; then
		DB_SNAPSHOT_WHY="no root-held scratch directory to take the snapshot in (see private_scratch_dir)"
		return 1
	fi
	tmp="${scratch}/snapshot.db"
	# The scratch is PrivateTmp, not the /var/lib volume the old in-place copy
	# used, so say so up front when it cannot hold the copy rather than let
	# `.backup` fail half-way with a bare "disk I/O error".
	need=$(( ( $(stat -c %s -- "$db" 2>/dev/null || echo 0) + $(stat -c %s -- "${db}-wal" 2>/dev/null || echo 0) ) / 1024 + 1024 ))
	have="$(df -Pk -- "$scratch" 2>/dev/null | awk 'NR == 2 {print $4}')"
	if [[ "$have" =~ ^[0-9]+$ ]] && (( have < need )); then
		rm -rf -- "$scratch"
		DB_SNAPSHOT_WHY="scratch ${scratch%/*} has ${have} KiB free but the snapshot needs about ${need} KiB"
		return 1
	fi
	# Create the copy ourselves at 0600 before sqlite3 touches it. `.backup`
	# honours the process umask, so under the default 022 it would land 0644 —
	# a world-readable copy of every credential the panel holds. A pre-created
	# destination keeps its mode (verified).
	if ! install -m 0600 /dev/null "$tmp" 2>/dev/null; then
		rm -rf -- "$scratch"
		DB_SNAPSHOT_WHY="cannot create the snapshot in ${scratch}"
		return 1
	fi
	# -readonly: the stage whose job is to protect the database must not be able
	# to modify it. It is also required in practice — without it `.backup` wants
	# a write handle and fails outright on a non-writable source, leaving a
	# 0-byte destination behind that integrity_check would then call "ok".
	if ! err="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$db" \
			".backup '${tmp}'" 2>&1)"; then
		rm -rf -- "$scratch"
		DB_SNAPSHOT_WHY="sqlite3 .backup failed: ${err//$'\n'/ }"
		return 1
	fi
	db_sidecars_remove "$tmp"
	# Owner and mode are set HERE, where no other account can swap the name —
	# no-follow anyway (chown -h, db_chmod_nofollow), as db_fix_ownership does.
	chown -h "$DB_OWNER" "$tmp" 2>/dev/null \
		|| engine_log "snapshot: could not chown the snapshot to ${DB_OWNER}"
	if ! db_chmod_nofollow 0600 "$tmp"; then
		rm -rf -- "$scratch"
		DB_SNAPSHOT_WHY="could not set the snapshot's mode to 0600 (python3 absent?)"
		return 1
	fi
	# The fingerprint db_restore_from_snapshot will demand back, taken from the
	# copy nothing unprivileged can reach; the byte digest proves the placed
	# file is this one.
	DB_SNAPSHOT_SHA="$(db_logical_sha "$tmp" || true)"
	db_sidecars_remove "$tmp"
	bytes_sha="$(sha256sum -- "$tmp" 2>/dev/null | awk '{print $1}')"
	# Idempotent, because a resumed run re-enters this stage from scratch: the
	# previous attempt's file, truncated or not, is simply replaced. The sidecars
	# go too: they belong to the OLD snapshot's lineage. rm -f on a planted
	# symlink removes the LINK; the move then either rename(2)s over whatever
	# is there (same filesystem) or, cross-filesystem, unlinks it and creates
	# with O_EXCL — a link planted in between makes it fail, never redirects it.
	rm -f -- "$dest"
	db_sidecars_remove "$dest"
	if ! mv -f -- "$tmp" "$dest" 2>/dev/null; then
		rm -rf -- "$scratch"
		DB_SNAPSHOT_WHY="cannot place the snapshot at ${dest}"
		return 1
	fi
	rm -rf -- "$scratch"
	# Trust the NAME only after an lstat says it is a lone regular file holding
	# exactly the bytes that were placed, at the mode that was set.
	if ! db_chown_target_safe "$dest"; then
		DB_SNAPSHOT_WHY="snapshot destination ${dest} is not a lone regular file after placement (swapped mid-op?) — SC-663"
		return 1
	fi
	if [[ -z "$bytes_sha" || "$(sha256sum -- "$dest" 2>/dev/null | awk '{print $1}')" != "$bytes_sha" ]]; then
		DB_SNAPSHOT_WHY="snapshot at ${dest} is not the file that was placed there (replaced mid-op?) — SC-663"
		return 1
	fi
	if [[ "$(stat -c %a -- "$dest" 2>/dev/null)" != 600 ]]; then
		DB_SNAPSHOT_WHY="snapshot at ${dest} is not mode 0600 after placement"
		return 1
	fi
	return 0
}

# db_snapshot_pinned_sha <snapshot> — the content fingerprint stage_snapshot
# journaled for this snapshot when it was taken, or nothing. The run id is the
# file's own name (`<run-id>.db`, as stage_snapshot writes it) and the journal
# is root-held under RUNS_DIR, so the pin cannot be rewritten by the account
# that owns the snapshot file. Empty for a snapshot with no journal, a journal
# with no snapshot stage, or a stage that predates the pin — the caller falls
# back to fingerprinting the file and says so.
db_snapshot_pinned_sha() {
	local snap="$1" id jf sha
	id="${snap##*/}"; id="${id%.db}"
	run_id_valid "$id" || return 0
	jf="$(journal_path "$id")"
	[[ -f "$jf" && ! -L "$jf" ]] || return 0
	sha="$(jq -r '([.stages[]? | select(.stage == "snapshot") | .result.db_logical_sha // empty] | last) // empty' \
		"$jf" 2>/dev/null || true)"
	[[ "$sha" =~ ^[0-9a-f]{64}$ ]] && printf '%s' "$sha"
	return 0
}

# The DB path the PANEL actually uses, from the canonical env — empty when it
# cannot be determined. /etc/shcp/panel.env is the real source of truth
# (doctrine.yaml resolves %env(DATABASE_URL)%); the engine keeps its own default
# so it works with the panel absent, and stage_snapshot cross-checks the two.
# Snapshotting one file while the panel writes another means a rollback restores
# the wrong database over the right one, silently.
panel_db_from_env() {
	[[ -r "$PANEL_ENV" ]] || return 0
	local dsn
	dsn="$(sed -n 's/^[[:space:]]*DATABASE_URL[[:space:]]*=[[:space:]]*//p' \
		"$PANEL_ENV" 2>/dev/null | tail -1)"
	dsn="${dsn%\"}"; dsn="${dsn#\"}"
	dsn="${dsn%\'}"; dsn="${dsn#\'}"
	case "$dsn" in
		sqlite:///*)
			local p="${dsn#sqlite:///}"
			# Only an absolute, fully-resolved path is a usable comparison.
			# A %kernel.project_dir% placeholder is not ours to expand.
			[[ "$p" == /* && "$p" != *'%'* ]] && printf '%s' "$p"
			;;
		*) : ;;   # non-sqlite DSN: out of scope for a sqlite3 snapshot
	esac
	return 0
}

# The other half of §4.2-2: the configuration a rollback would need to put back
# — /etc/shcp*, the sources/pins that decide which series the box is on (deb: the
# apt suite pointer + Priority-1001 pins; rpm: the /etc/dnf/vars series pointers),
# and any systemd unit overrides. A rollback aid and a diagnostic, NOT the database
# safety net, so a failure here is reported and does not fail the stage.
config_tar_create() {
	local dest="$1"
	local base="${CONFIG_ROOT%/}"
	local -a members=()
	local p
	for p in "${base}"/etc/shcp*; do
		[[ -e "$p" ]] || continue
		members+=("${p#"${base}/"}")
	done
	# Each guarded by -e: the apt paths are absent on rpm, the dnf series vars are
	# absent on deb, so a box carries only its own family's members. The three dnf
	# vars are named individually (not the whole /etc/dnf/vars dir) — only OUR series
	# pointers belong in the snapshot, not another package's dnf vars.
	for p in etc/apt/sources.list etc/apt/sources.list.d etc/apt/preferences.d \
			etc/systemd/system \
			etc/dnf/vars/shcpseries etc/dnf/vars/shcpbase etc/dnf/vars/shcpel; do
		[[ -e "${base}/${p}" ]] || continue
		members+=("$p")
	done
	[[ ${#members[@]} -gt 0 ]] || return 1
	# 0600 before tar writes: the archive carries /etc/shcp/panel.env, i.e.
	# APP_SECRET and the DB credentials. tar opens O_TRUNC and keeps the mode.
	install -m 0600 /dev/null "$dest" 2>/dev/null || return 1
	# tar exits 1 for "file changed as we read it", which is routine against a
	# live /etc and not a reason to discard the archive; only a fatal error (>1)
	# or an empty result is.
	local rc=0
	tar czf "$dest" -C "${base:-/}" "${members[@]}" 2>/dev/null || rc=$?
	if [[ $rc -gt 1 || ! -s "$dest" ]]; then
		rm -f "$dest"
		return 1
	fi
	chmod 0600 "$dest" 2>/dev/null || true
	return 0
}

# snapshot_run_ids — run-ids owning a snapshot in the backup dir, oldest first.
# The NAME is the sort key: run-ids are YYYYMMDD-HHMMSS-hex, so lexicographic
# order is chronological order. mtime is deliberately not used — a resumed run
# rewrites an older run's snapshot and would then sort as the newest.
snapshot_run_ids() {
	[[ -d "$DB_BACKUP_DIR" ]] || return 0
	local f b
	for f in "$DB_BACKUP_DIR"/*.db; do
		[[ -f "$f" ]] || continue
		b="${f##*/}"
		b="${b%.db}"
		run_id_valid "$b" || continue
		printf '%s\n' "$b"
	done | sort
}

# snapshot_prune_plan <keep_run_id> — the run-ids retention WOULD drop. Unusable
# files (a 0-byte leftover from a killed backup) are dropped first and never
# counted toward the keep depth: retaining one as one of the two would halve the
# real rollback depth while reporting a depth of two.
snapshot_prune_plan() {
	local keep_this="$1"
	local -a usable=() doomed=()
	local id f
	while IFS= read -r id; do
		[[ -n "$id" ]] || continue
		f="${DB_BACKUP_DIR}/${id}.db"
		if [[ "$id" == "$keep_this" ]]; then
			usable+=("$id")
			continue
		fi
		# Subshell: db_snapshot_usable sets globals the caller may already have
		# filled with the verdict on THIS run's snapshot.
		if ( db_snapshot_usable "$f" ) >/dev/null 2>&1; then
			usable+=("$id")
		else
			doomed+=("$id")
		fi
		# Probing a WAL database read-only materializes its sidecars. Leave none
		# behind beside a snapshot this function did not create.
		db_sidecars_remove "$f"
	done < <(snapshot_run_ids)
	local n=${#usable[@]} i
	if (( n > SNAPSHOT_KEEP )); then
		for (( i = 0; i < n - SNAPSHOT_KEEP; i++ )); do
			[[ "${usable[i]}" == "$keep_this" ]] && continue
			doomed+=("${usable[i]}")
		done
	fi
	[[ ${#doomed[@]} -gt 0 ]] || return 0
	printf '%s\n' "${doomed[@]}" | sort -u
}

# snapshot_prune_apply <keep_run_id> — executes the plan, echoes the count.
snapshot_prune_apply() {
	local keep_this="$1" id n=0
	# SC-663 symmetry with db_backup_dir_prepare's create-time walk: refuse to
	# prune through a symlinked backup dir. `rm` never follows the FINAL
	# component, but it DOES traverse directory components, so a `pre-update` →
	# elsewhere symlink swapped in by the (panel-owned) dir owner after the
	# snapshot was created could redirect these deletes into another directory.
	# The create path already guards this; the prune path must too. Fail closed:
	# skip the prune (retention is best-effort) rather than delete blind.
	if ! path_no_symlink "$DB_BACKUP_DIR"; then
		engine_log "finalize: refusing to prune — a component of ${DB_BACKUP_DIR} is a symlink (SC-663)"
		printf '%s' 0
		return 0
	fi
	while IFS= read -r id; do
		[[ -n "$id" ]] || continue
		[[ "$id" == "$keep_this" ]] && continue
		# Never build a path from a name that has not been shape-checked.
		run_id_valid "$id" || continue
		rm -f "${DB_BACKUP_DIR}/${id}.db"
		db_sidecars_remove "${DB_BACKUP_DIR}/${id}.db"
		n=$((n + 1))
		engine_log "finalize: pruned pre-update snapshot ${id}"
	done < <(snapshot_prune_plan "$keep_this")
	printf '%s' "$n"
}

# snapshot_path_of_run <run_id> — where a run's snapshot went, read back out of
# its own journal. One call for rollback_run (§4.5 step 4) and for the panel
# importer, so neither has to know the stage-record shape.
snapshot_path_of_run() {
	local run_id="$1" jf
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" ]] || return 0
	jq -r '(.stages[]? | select(.stage == "snapshot") | .result.db_snapshot) // empty' \
		"$jf" 2>/dev/null || true
}

# config_tar_path_of_run <run_id> — the config tar the snapshot stage wrote for a
# run, read back out of its own journal. The cross-series rollback restores the
# pre-upgrade apt suite + pins from it (SC-476); the
# snapshot stage always runs BEFORE the suite rewrite, so this tar captures the
# PRE-rewrite state regardless of where the run later failed.
config_tar_path_of_run() {
	local run_id="$1" jf
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" ]] || return 0
	jq -r '(.stages[]? | select(.stage == "snapshot") | .result.config_tar) // empty' \
		"$jf" 2>/dev/null || true
}

# db_restore_from_snapshot <snapshot> <live_db> — put the snapshot back.
# Called by rollback_run (§4.5 step 4). UPD-4 owns it because a snapshot whose
# restore has never been exercised is not a backup.
#
# WHY THIS CANNOT PRODUCE A BLEND OF THE SNAPSHOT AND POST-SNAPSHOT STATE
# ----------------------------------------------------------------------
# The panel runs the database in WAL mode (SqlitePragmaDriver sets
# journal_mode=WAL on every connection, unconditionally), so its committed state
# is "main file PLUS whatever frames are in <db>-wal". There is exactly one way
# a restore yields a mixture: the new main file is put in place and the OLD
# <db>-wal survives beside it, at which point the next reader replays the old
# frames on top of the restored pages and sees the pre-restore database again.
#
# That failure is silent, and it was reproduced here deterministically: a
# 200-row main+WAL pair with a 1-row snapshot renamed over the main file reads
# back 200 rows and PRAGMA integrity_check answers "ok". With the two sidecars
# unlinked, the identical sequence reads back 1 row.
#
# So the steps are, in this order and for these reasons:
#   1. Validate the snapshot BEFORE touching anything (§4.5: "integrity-check
#      first"). A refused restore is survivable; writing a 0-byte file over the
#      panel database is not.
#   2. Stage the copy BESIDE the live DB — same directory, therefore same
#      filesystem, therefore rename(2) — and give it the live DB's uid/gid/mode
#      before it becomes the live DB. The engine is root, and a root:root 0600
#      panel database is a panel that cannot write.
#   3. fsync the staged file, then rename it into place: the live path is never
#      a partially written file, at any instant.
#   4. Unlink <db>-wal / -shm / -journal AFTER the rename, never before. Before
#      the rename they could be recreated from the OLD main file and would then
#      be replayed onto the new one — the mixture above.
#   5. fsync the directory, so the rename survives a crash before whatever
#      reboot the rollback goes on to schedule.
#   6. Re-read the restored database and compare a LOGICAL fingerprint against
#      the snapshot's. This is the step that makes a mixture impossible to
#      report as success: a blend is structurally valid, passes integrity_check,
#      and differs only in its rows — exactly what a `.dump` comparison sees. A
#      mismatch fails the restore instead of claiming it.
#
# Processes that had the database open before the swap keep their descriptors on
# the discarded inode: their later writes are LOST (the data-loss window §4.5
# documents) but they cannot pollute the restored file, because SQLite holds its
# -wal/-shm descriptors open for the life of a connection and never re-resolves
# the path. The caller must still restart shcpd afterwards — §4.5 step 5 — or the
# panel keeps reading the old inode; DB_RESTORE_RESULT says so explicitly.
#
# DB_RESTORE_RESULT is a result global its caller
# reads. That caller is rollback_run, which branches on `.ok` (never `.restored`)
# and consumes `.shcpd_restart_required` in its step 5.
# --- SC-236 outbound mail policy socket lifecycle (shcp-updater#199) -------------
# The installer (shcp-installer#937) runs the authenticated-submission policy as a
# systemd socket, shcp-outbound-policy.socket, that starts a panel process
# (shcp:mail:outbound-policy today; the persistent shcp-outbound-policy@N.service
# pool once shcp-base#1495 lands) on every submission. That process opens the panel
# database by path. Two engine operations rename a DIFFERENT inode over that path:
# stage_db's forward migration and db_restore_from_snapshot's rollback swap. A
# policy check that opens the DB during either sees a half-migrated schema, or --
# with a persistent worker still holding the pre-swap inode -- a database that no
# longer has the mailbox the sender named. Either makes the check answer "mailbox
# not found" -> DUNNO, i.e. NO outbound rate limit, for every sender (SC-236). So
# the socket is stopped around each DB swap and started again after.
#
# The unit name is the same on deb and rpm (the installer's POSTFIX_POLICY_SERVICE
# is family-agnostic); the env override exists only for the tests.
OUTBOUND_POLICY_SOCKET_UNIT="${SHCP_UPDATE_OUTBOUND_POLICY_SOCKET:-shcp-outbound-policy.socket}"

# outbound_policy_socket_quiesce -- stop the policy socket and every running instance
# so nothing opens the panel DB during a swap. Sets OUTBOUND_POLICY_SOCKET_QUIESCED
# =true iff this call stopped a socket that was up (so resume knows to bring it
# back). FAIL CLOSED:
#   0  the socket is safely down -- we stopped it, it was already inactive, or the
#      unit is not installed on this box (a box without the units: a clean no-op).
#   1  the socket IS installed and could NOT be confirmed down. The caller MUST NOT
#      proceed into the DB swap: a live socket over a renamed/half-migrated DB is
#      exactly the DUNNO-for-everyone regression above. On this path nothing on the
#      box has changed yet, so the operation is retryable.
OUTBOUND_POLICY_SOCKET_QUIESCED=false
outbound_policy_socket_quiesce() {
	OUTBOUND_POLICY_SOCKET_QUIESCED=false
	local unit="$OUTBOUND_POLICY_SOCKET_UNIT"
	command -v systemctl >/dev/null 2>&1 || return 0
	# Unknown to systemd -> the SC-236 socket was never installed on this box.
	# Nothing to quiesce, and this must raise no spurious failure.
	systemctl list-unit-files "$unit" >/dev/null 2>&1 || return 0
	local state
	state="$(systemctl is-active "$unit" 2>/dev/null || true)"
	state="${state%%$'\n'*}"
	case "$state" in
		active|listening|activating|reloading) OUTBOUND_POLICY_SOCKET_QUIESCED=true ;;
	esac
	# Stop unconditionally: even if the read above raced to inactive, a stop is a
	# harmless no-op and closes the window. Stopping the socket removes the listener;
	# PartOf= stops the pool/instances with it, and the explicit instance stop covers
	# a box whose template predates PartOf= (the shipping Accept=yes per-connection
	# units). A stop of an absent instance glob is a no-op.
	systemctl stop "$unit" >/dev/null 2>&1 || true
	systemctl stop "${unit%.socket}@*.service" >/dev/null 2>&1 || true
	# Prove it down. This is the fail-closed gate the callers depend on.
	state="$(systemctl is-active "$unit" 2>/dev/null || true)"
	state="${state%%$'\n'*}"
	case "$state" in
		inactive|failed|unknown|"") return 0 ;;
		*)
			engine_log "outbound policy: ${unit} is ${state} after stop -- refusing to touch the DB (DUNNO-for-everyone risk, SC-236)"
			return 1 ;;
	esac
}

# outbound_policy_socket_resume -- restart the socket iff quiesce stopped an active
# one. Best-effort, exactly like the shcpd restart: a socket that will not come back
# is a submission outage that fails CLOSED (Postfix gets 451 and defers -- never
# DUNNO) and that base#1600's mail-health probe surfaces, not a reason to fail a DB
# stage that otherwise succeeded. Sets OUTBOUND_POLICY_SOCKET_RESTARTED; returns 1
# only so a caller may record it.
#
# In the rollback swap this is NOT called from db_restore_from_snapshot's success
# path: like the shcpd restart, the resume is deferred to rollback_run, AFTER the
# durable restore proof is published. A policy check opens the DB and can replay a
# stale WAL, so it is a writer, and no writer may start between the restore
# fingerprint and that proof (updater#208). It IS called inline on the pre-swap
# "nothing changed" failure paths and in stage_db, where no such proof is owed.
OUTBOUND_POLICY_SOCKET_RESTARTED=false
outbound_policy_socket_resume() {
	OUTBOUND_POLICY_SOCKET_RESTARTED=false
	[[ "$OUTBOUND_POLICY_SOCKET_QUIESCED" == true ]] || return 0
	local unit="$OUTBOUND_POLICY_SOCKET_UNIT"
	command -v systemctl >/dev/null 2>&1 || return 0
	# restart, not start: the installer uses restart so a socket already listening
	# keeps its owner/mode/SELinux label; after our stop this simply starts it.
	if systemctl restart "$unit" >/dev/null 2>&1; then
		OUTBOUND_POLICY_SOCKET_RESTARTED=true
		return 0
	fi
	engine_log "outbound policy: ${unit} FAILED TO RESTART -- authenticated submission fails closed (451) until it is started by hand (SC-236)"
	return 1
}

DB_RESTORE_RESULT='{}'
db_restore_from_snapshot() {
	local snap="$1"
	local db="$2"
	# The fingerprint the snapshot MUST hash to: the caller's, else the one
	# stage_snapshot journaled for this file (db_snapshot_pinned_sha). Empty
	# only for a snapshot that predates the pin, and then the file's own hash is
	# the reference — reported as fingerprint_pinned:false, never silently.
	local pinned="${3:-}"
	[[ -n "$pinned" ]] || pinned="$(db_snapshot_pinned_sha "$snap")"
	DB_RESTORE_RESULT='{}'

	if ! path_no_symlink "$db"; then
		DB_RESTORE_RESULT="$(jq -nc --arg db "$db" \
			'{ok: false, restored: false, refused: ("live DB path has a symlinked component: " + $db)}')"
		engine_log "db restore REFUSED: symlinked component in ${db}"
		return 1
	fi
	if ! db_snapshot_usable "$snap"; then
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" --arg w "$DB_SNAPSHOT_WHY" \
			'{ok: false, restored: false, snapshot: $s, refused: $w}')"
		engine_log "db restore REFUSED (nothing was changed): ${DB_SNAPSHOT_WHY}"
		return 1
	fi
	local want
	want="$(db_logical_sha "$snap" || true)"
	# Both reads above open the snapshot; neither may leave its sidecars behind.
	db_sidecars_remove "$snap"
	if [[ -z "$want" ]]; then
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" \
			'{ok: false, restored: false, snapshot: $s, refused: "snapshot could not be fingerprinted"}')"
		engine_log "db restore REFUSED (nothing was changed): cannot fingerprint ${snap}"
		return 1
	fi
	# The snapshot file is panel-writable (DB_OWNER's own directory), so a hash
	# taken from it NOW proves only that the restore copies whatever is there —
	# not that it is the database stage 2 copied. The journal's pin is root-held
	# and was computed in a root-held scratch before the file was ever exposed;
	# a snapshot that no longer hashes to it was replaced or rewritten since and
	# must not become the live database (SC-663 / R4).
	local fingerprint_pinned=false
	if [[ -n "$pinned" ]]; then
		fingerprint_pinned=true
		if [[ "$want" != "$pinned" ]]; then
			DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" --arg want "$pinned" --arg got "$want" \
				'{ok: false, restored: false, snapshot: $s, fingerprint_pinned: true,
				  expected_fingerprint: $want, snapshot_fingerprint: $got,
				  refused: "snapshot content does not match the fingerprint journaled when it was taken — the file was replaced or rewritten since stage 2, refusing to restore it (SC-663)"}')"
			engine_log "db restore REFUSED (nothing was changed): ${snap} hashes to ${want}, the journal pinned ${pinned}"
			return 1
		fi
	else
		engine_log "db restore: no journaled fingerprint for ${snap} (pre-pin snapshot) — using the file's own hash as the reference"
	fi

	# The live file's identity, captured BEFORE the swap and reapplied to the
	# staging file — the same thing ServerRestoreService::swapPanelDb() does, for
	# the same reason.
	local owner="" mode=""
	if [[ -f "$db" ]]; then
		owner="$(stat -c '%u:%g' "$db" 2>/dev/null || true)"
		mode="$(stat -c '%a' "$db" 2>/dev/null || true)"
	fi
	[[ -n "$mode" ]] || mode=640

	# The swap is only safe with NO other process able to open the DB by path.
	# The workers are already stopped by the rollback caller, but shcpd is not:
	# it stays up serving safe methods (the maintenance gate does not block GET),
	# and every FrankenPHP request opens a fresh connection by path. A reader that
	# lands between the rename and the sidecar unlink replays the OLD -wal onto the
	# RESTORED main file and checkpoints the blend in permanently — which the
	# fingerprint below then catches, but only after the live DB is already
	# mutated. So remove the racer rather than narrow the window.
	# NOT `is-active --quiet`: that succeeds only for ActiveState=active (and
	# reloading). shcpd ships Restart=always + RestartSec=5, so a crash-looping
	# daemon sits in activating/auto-restart — and a crash-looping panel is
	# exactly the state a rollback gets invoked from. The narrow test would skip
	# the stop in the one case it matters most, and the respawn five seconds later
	# would open the DB mid-swap. `is-enabled`-agnostic and state-agnostic: if the
	# unit is known to systemd at all, stop it and remember that we did.
	local shcpd_was_active=false
	if command -v systemctl >/dev/null 2>&1 \
			&& systemctl list-unit-files shcpd.service >/dev/null 2>&1; then
		local shcpd_state
		shcpd_state="$(systemctl is-active shcpd.service 2>/dev/null || true)"
		case "$shcpd_state" in
			active|reloading|activating|deactivating)
				shcpd_was_active=true
				engine_log "db restore: stopping shcpd (state=${shcpd_state}) for the swap"
				local t0
				t0="$(date +%s)"
				systemctl stop shcpd.service >/dev/null 2>&1 || true
				report_forced_stop shcpd.service "$t0" "db restore"
				;;
			*)
				engine_log "db restore: shcpd is ${shcpd_state:-unknown}; not stopping it"
				;;
		esac
	fi

	# The SC-236 outbound policy socket is the OTHER opener of this DB by path: every
	# authenticated submission starts a policy process that queries it. Stop it for
	# the same window and the same reason as shcpd, and FAIL CLOSED if it will not
	# stop -- BEFORE the rename, so nothing has changed and the operator can retry.
	# Without this a submission landing during the swap answers DUNNO (no rate limit)
	# for every sender, or a persistent worker keeps serving the discarded inode.
	if ! outbound_policy_socket_quiesce; then
		[[ "$shcpd_was_active" == true ]] && systemctl start shcpd.service >/dev/null 2>&1 || true
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" --arg db "$db" \
			'{ok: false, restored: false, snapshot: $s, db: $db,
			  refused: "outbound policy socket could not be stopped for the swap (SC-236)"}')"
		engine_log "db restore REFUSED (nothing was changed): outbound policy socket would not quiesce"
		return 1
	fi
	local policy_socket_stopped="$OUTBOUND_POLICY_SOCKET_QUIESCED"

	# mktemp, NOT "${db}.restore.$$": /var/lib/shcp is shcp:shcp 0750, so the panel
	# account can create entries there, and a $$-derived name is guessable across a
	# small pid space. An attacker with code execution as `shcp` (exactly the threat
	# SC-272/SC-355 exist for) could pre-plant a symlink at the predicted path and
	# have root's cp/chown/mv follow it. mktemp's name is unpredictable and the file
	# is created O_EXCL by root, so a pre-planted entry makes it fail rather than
	# write through. Same directory, so the final mv is still a rename(2).
	local staged
	if ! staged="$(mktemp "${db}.restore.XXXXXX" 2>/dev/null)"; then
		[[ "$shcpd_was_active" == true ]] && systemctl start shcpd.service >/dev/null 2>&1 || true
		outbound_policy_socket_resume >/dev/null 2>&1 || true
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" \
			'{ok: false, restored: false, snapshot: $s, refused: "could not create the staging file"}')"
		engine_log "db restore REFUSED (nothing was changed): mktemp beside the live DB failed"
		return 1
	fi
	# cp -f onto an existing regular file writes THROUGH a symlink; mktemp already
	# created a real file here, and --remove-destination makes the no-follow
	# guarantee explicit rather than incidental.
	if ! cp -f --remove-destination "$snap" "$staged"; then
		rm -f "$staged"
		[[ "$shcpd_was_active" == true ]] && systemctl start shcpd.service >/dev/null 2>&1 || true
		outbound_policy_socket_resume >/dev/null 2>&1 || true
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" \
			'{ok: false, restored: false, snapshot: $s, refused: "could not stage the snapshot beside the live DB"}')"
		engine_log "db restore REFUSED (nothing was changed): staging copy failed"
		return 1
	fi
	# SC-663 / R4: the staged file sits in the panel-owned db/ directory, so
	# between cp and the mode/owner change its NAME can be swapped for a symlink
	# (or a hardlink to a root-owned file). Refuse anything but the lone regular
	# file cp wrote, change mode and owner without following (db_chmod_nofollow,
	# chown -h — the same pair db_fix_ownership uses), and re-verify by lstat
	# before the rename below makes this inode the live database.
	if ! db_chown_target_safe "$staged"; then
		rm -f -- "$staged"
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" '{ok:false,restored:false,snapshot:$s,refused:"staged database is not a lone regular file (swapped for a symlink or hardlink mid-op?) — SC-663",shcpd_restart_required:true,policy_socket_restart_required:true}')"
		engine_log "db restore REFUSED (live DB untouched): ${staged} is not the regular file that was staged — SC-663"
		return 1
	fi
	if ! db_chmod_nofollow "$mode" "$staged"; then
		rm -f -- "$staged"
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" '{ok:false,restored:false,snapshot:$s,refused:"staged database mode could not be set (no-follow)",shcpd_restart_required:true,policy_socket_restart_required:true}')"
		return 1
	fi
	[[ -n "$owner" ]] || owner="$DB_OWNER"
	if ! chown -h "$owner" "$staged" 2>/dev/null; then
		rm -f -- "$staged"
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" '{ok:false,restored:false,snapshot:$s,refused:"staged database owner could not be set",shcpd_restart_required:true,policy_socket_restart_required:true}')"
		return 1
	fi
	if ! db_target_verify_lstat "$staged" "$owner" "$mode"; then
		rm -f -- "$staged"
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" '{ok:false,restored:false,snapshot:$s,refused:"staged database is not the regular file that was set (swapped mid-op?) — SC-663",shcpd_restart_required:true,policy_socket_restart_required:true}')"
		engine_log "db restore REFUSED (live DB untouched): ${staged} changed under the mode/owner set — SC-663"
		return 1
	fi
	if ! sync "$staged" 2>/dev/null; then
		rm -f -- "$staged"
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" '{ok:false,restored:false,snapshot:$s,refused:"staged database fsync failed",shcpd_restart_required:true,policy_socket_restart_required:true}')"
		engine_log "db restore FAILED: staged database durability could not be proved; shcpd stays stopped"
		return 1
	fi

	# --- the point of no return: one rename(2) ---------------------------------
	if ! mv -f "$staged" "$db"; then
		rm -f "$staged"
		[[ "$shcpd_was_active" == true ]] && systemctl start shcpd.service >/dev/null 2>&1 || true
		outbound_policy_socket_resume >/dev/null 2>&1 || true
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" \
			'{ok: false, restored: false, snapshot: $s, refused: "rename over the live DB failed"}')"
		engine_log "db restore FAILED (live DB untouched): rename failed"
		return 1
	fi
	# Step 4. Order matters: see the header. This is the load-bearing line.
	db_sidecars_remove "$db"
	if ! sync "${db%/*}" 2>/dev/null; then
		DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" --arg db "$db" '{ok:false,restored:true,snapshot:$s,db:$db,refused:"database rename or sidecar removal was not durably committed",shcpd_restart_required:true,policy_socket_restart_required:true}')"
		engine_log "db restore FAILED: directory durability barrier failed; shcpd stays stopped"
		return 1
	fi

	# Step 6. Same view a normal reader gets — deliberately NOT an immutable
	# open, which would ignore a stale WAL and hide the very mixture being
	# looked for.
	local got integrity
	got="$(db_logical_sha "$db" || true)"
	integrity="$(db_integrity "$db")"
	db_fix_ownership "$db"

	local verified=false
	[[ -n "$got" && "$got" == "$want" ]] && verified=true
	# `ok` is the ONE field a caller may branch on, and it is the conjunction.
	# `restored` alone means only "the bytes were swapped in" — it is true even
	# when the result is a blend, so a rollback keying on it would report success
	# over a database that is not the snapshot. Kept for diagnostics, never as a
	# verdict.
	# Do NOT restart shcpd OR resume the SC-236 outbound policy socket here.
	# rollback_run must first durably publish the proof that this destructive restore
	# completed, and only then bring writers back. A policy check opens this DB by
	# path and can replay a stale WAL onto it, so the socket is a writer exactly like
	# shcpd: starting either between the fingerprint and that proof reopens the exact
	# kill window the proof closes (updater#208). The socket was stopped for the swap
	# above; policy_socket_restart_required tells rollback_run to resume it, on the
	# same gate as shcpd.
	local restore_ok=false
	[[ "$verified" == true && "$integrity" == "ok" ]] && restore_ok=true
	DB_RESTORE_RESULT="$(jq -nc --arg s "$snap" --arg db "$db" --arg i "$integrity" \
		--arg want "$want" --arg got "$got" \
		--argjson verified "$verified" --argjson ok "$restore_ok" \
		--argjson pinned "$fingerprint_pinned" \
		--argjson stopped "$shcpd_was_active" \
		--argjson pstopped "$policy_socket_stopped" \
		'{ok: $ok, restored: true, snapshot: $s, db: $db, integrity: $i,
		  content_matches_snapshot: $verified, fingerprint_pinned: $pinned,
		  expected_fingerprint: $want, restored_fingerprint: $got,
		  shcpd_stopped_for_swap: $stopped,
		  shcpd_restarted: false,
		  shcpd_restart_required: $stopped,
		  policy_socket_stopped_for_swap: $pstopped,
		  policy_socket_restarted: false,
		  policy_socket_restart_required: $pstopped}')"
	if [[ "$verified" != "true" ]]; then
		engine_log "db restore VERIFICATION FAILED: ${db} is NOT the snapshot ${snap}"
		return 1
	fi
	if [[ "$integrity" != "ok" ]]; then
		engine_log "db restore VERIFICATION FAILED: integrity_check ${integrity}"
		return 1
	fi
	engine_log "db restore: ${snap} -> ${db} (verified)"
	return 0
}

# stage_error <stage> <message> — attach a human-readable reason to the OPEN
# stage record. The runner replaces STAGE_RESULT with {"failed": true} on any
# non-zero stage return, so this is the only way a stage can put its own
# diagnosis in the journal. Purely additive: it never touches done_at or result,
# so stage-completion and resume semantics are unchanged.
#
# It writes the journal itself instead of going through journal_update, and it
# ALWAYS returns 0. journal_update die()s when jq fails, and die is exit — which
# fires the EXIT trap, which calls maintenance_clear_if_mine. From stage_db that
# would drop the maintenance gate while the schema is half-migrated and nothing
# has been rolled back: the exact inversion of SC-318's fail-closed rule, caused
# by nothing worse than a failure to record a diagnostic. Failing to write down
# why a stage failed must never be more destructive than the failure itself.
# The message is clamped to printable ASCII for the same reason: it is built
# from sqlite3/console output, and jq rejects invalid UTF-8 in --arg.
stage_error() {
	local stage="$1" raw="$2"
	[[ -n "$CURRENT_RUN_ID" ]] || return 0
	local jf
	jf="$(journal_path "$CURRENT_RUN_ID")"
	[[ -f "$jf" ]] || return 0
	local msg
	# Deleting non-ASCII leaves the gap behind: the engine's own messages are
	# full of em-dashes, so "is active — retire it" clamped to "is active  retire
	# it", a double space that reads as a typo in an operator notification. Squeeze
	# runs of whitespace after the delete so the sentence closes up. `tr -s` is
	# safe here for the same reason the delete is: the result is ASCII-only.
	msg="$(printf '%s' "$raw" | tr -cd '\11\40-\176' | tr -s '\11\40' ' ' | cut -c1-1024)"
	local out
	if ! out="$(jq --arg s "$stage" --arg e "$msg" \
			'.stages |= map(if .stage == $s and (.done_at // null) == null
				then . + {error: $e} else . end)' "$jf" 2>/dev/null)"; then
		engine_log "could not journal the ${stage} failure reason: ${msg}"
		return 0
	fi
	printf '%s\n' "$out" | atomic_write "$jf" || true
	return 0
}

stage_crash_check() {
	# Crash drill seam: kill -9 ourselves at the named stage, AFTER
	# stage_started is journaled — exactly what a mid-stage power loss looks
	# like. resume must re-enter this stage.
	local stage="$1"
	if [[ "${SHCP_UPDATE_CRASH_STAGE:-}" == "$stage" ]]; then
		engine_log "CRASH DRILL: kill -9 at stage ${stage}"
		kill -9 $$
	fi
}

stage_self_update() {
	# §4.2 stage 0: refresh indexes; if a newer shcp-updater exists, install
	# it and exec the NEW script with --resumed-from-self-update <run-id>.
	# Max 1 hop: the resumed flag short-circuits this stage entirely.
	if [[ "$RESUMED_FROM_SELF_UPDATE" -eq 1 ]]; then
		STAGE_RESULT='{"updated": true, "hop": "resumed"}'
		return 0
	fi
	# Family-dispatched end to end (updater#35): this stage calling apt raw
	# was the one break in the osf_ discipline, and it cost every EL box its
	# every apply — at stage 0, before preflight, invisibly to any
	# fresh-install test.
	if ! osf_refresh_index; then
		# Flag and carry on (SC-319): the self-update decision below and the run
		# after it use what DID refresh.
		engine_log "self-update: package index refresh incomplete; continuing on what refreshed, this run will not stamp last_security_success (SC-319)"
		index_refresh_flag self-update
	fi
	# A pointer on another series than the panel would install the engine (and any
	# new dependency) from that series under this panel, past the preflight blocker.
	if series_pointer_mismatch; then
		engine_log "self-update: skipped — ${SERIES_POINTER_DETAIL}"
		STAGE_RESULT="$(jq -nc '{updated: false, skipped: "series_pointer_mismatch: the package-manager pointer names another series than the panel"}')"
		return 0
	fi
	local installed candidate
	installed="$(osf_selfupdate_installed)"
	candidate="$(osf_selfupdate_candidate)"
	if [[ -n "$installed" ]] && osf_selfupdate_is_newer "$installed" "$candidate"; then
		engine_log "self-update: ${installed} -> ${candidate}, installing + re-exec"
		if ! osf_selfupdate_install; then
			engine_log "self-update: install of newer shcp-updater failed${OSF_TRANSACTION_DETAIL:+: ${OSF_TRANSACTION_DETAIL}}"
			return 1
		fi
		# A held/version-locked package makes `apt-get install`/`dnf upgrade` a
		# NO-OP that still exits 0 — the engine on disk is unchanged. Re-execing $0
		# would then run the STALE engine against a NEW panel (e.g. one that routes
		# a message to a transport only the new engine's redeploy step brings a
		# worker up for), silently, with the health stage blind to it. Confirm the
		# package actually advanced before hopping; if it did not, abort fail-safe
		# (self-update is not in the [R] set and nothing is applied yet, so the box
		# simply stays on its working release). See SC-513.
		local now_installed
		now_installed="$(osf_selfupdate_installed)"
		if ! osf_selfupdate_is_newer "$installed" "$now_installed"; then
			engine_log "self-update: package did not advance (${installed} still installed — held/locked?); refusing to re-exec the stale engine"
			STAGE_RESULT='{"updated": false, "failed": "self-update did not advance (held/locked); aborting rather than run a stale engine against a new panel"}'
			return 1
		fi
		# Journal the hop BEFORE exec so the new process's resume skips us.
		journal_stage_done "$CURRENT_RUN_ID" "self-update" \
			"$(jq -n --arg from "$installed" --arg to "$candidate" \
				'{updated: true, from: $from, to: $to}')"
		status_write "$CURRENT_RUN_ID" "self-update" 1 \
			"updates.stage.self-update" "re-exec into ${candidate}"
		# Release the flock BEFORE exec: bash keeps {var}-allocated fds open
		# across exec, so the re-exec'd engine would find its own inherited
		# lock "held" and refuse to start. The sub-second gap is safe — the
		# journal stays resumable if anything squeezes in and wins the lock.
		shcp_release_lifecycle_release || return 1
		if [[ -n "$LOCK_FD" ]]; then
			exec {LOCK_FD}>&-
			LOCK_FD=""
		fi
		# exec THROUGH bash: $0 now carries the freshly installed script, and
		# invoking via bash keeps this working regardless of how the current
		# process was started (bash /usr/sbin/shcp-update vs direct exec).
		# Under --json the new engine owes the caller the run's document, so it
		# gets the flag and the caller's stdout back, and claims it again itself.
		local -a hop_json=()
		if (( JSON_OUTPUT == 1 )); then
			hop_json=(--json)
			if (( JSON_FD != 1 )); then
				exec 1>&"$JSON_FD" {JSON_FD}>&-
				JSON_FD=1
			fi
		fi
		exec bash "$0" apply --resumed-from-self-update "$CURRENT_RUN_ID" "${hop_json[@]}"
	fi
	STAGE_RESULT='{"updated": false}'
}

# --- series base-OS resolvability preflight (shcp-updater#238) ----------------
# A scope=series run upgrades the WHOLE upgradable set (osf_upgradable /
# osf_upgradable_full), so ONE unrelated base-OS dependency conflict — the one
# measured was AlmaLinux 10.2 aarch64 openssh 9.9p1-23 -> -27, "cannot install
# both" — makes stage_apt exit non-zero mid-run ("apt-get exited non-zero for
# scope=series") and auto-rolls the whole jump back with a generic message. These
# resolve the planned transaction WITHOUT applying it and, on a conflict, let the
# preflight refuse BEFORE the snapshot naming the offending packages — the
# resolvability twin of series_packages_held.
#
# Nothing is fetched or applied: apt-get -s is a pure simulation and dnf
# --assumeno depsolves then answers "no" at the prompt. cmd_check is offline
# (its own contract), so it passes 'cacheonly' and the rpm probe adds -C while
# apt -s reads only the local cache. A resolver that could not run, or produced
# nothing recognisable, degrades to a PASS — never a false refusal, matching the
# rest of series_preflight_gate. The env seam (production: the live package
# manager) lets the tests inject a fixed depsolve output without one.
SERIES_BASEOS_RESOLVE_DETAIL=""
SERIES_BASEOS_RESOLVE_PKGS=""

# series_baseos_resolve_run [cacheonly] — print the family's non-applying resolver
# output over the planned series work set; return its exit code. The single seam
# the unit tests replace with a fixture, so the classifier below is provable
# without a live package manager.
series_baseos_resolve_run() {
	local cacheonly="${1:-}"
	if [[ -n "${SHCP_UPDATE_BASEOS_RESOLVE_FIXTURE:-}" ]]; then
		cat -- "$SHCP_UPDATE_BASEOS_RESOLVE_FIXTURE" 2>/dev/null
		return "${SHCP_UPDATE_BASEOS_RESOLVE_RC:-0}"
	fi
	local -a specs=() ; local p
	case "$OS_FAMILY" in
		deb)
			while IFS= read -r p; do [[ -n "$p" ]] && specs+=("$p"); done < <(apt_target_pkgs series)
			(( ${#specs[@]} > 0 )) || return 0
			# Mirror osf_apply (--only-upgrade install). The apply refuses a protected
			# removal and allows the rest (#321); baseos_removal_refusals applies the
			# same policy to this output. -s never fetches or applies.
			DEBIAN_FRONTEND=noninteractive apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 \
				--only-upgrade install "${specs[@]}" 2>&1
			;;
		rpm)
			while IFS= read -r p; do [[ -n "$p" ]] && specs+=("$p"); done < <(osf_upgradable)
			(( ${#specs[@]} > 0 )) || return 0
			local -a co=()
			[[ "$cacheonly" == "cacheonly" ]] && co=(-C)
			dnf --assumeno "${co[@]}" update "${specs[@]}" "${DNF_SKIP_DEAD_REPOS[@]}" 2>&1
			;;
	esac
}

# series_baseos_output_is_conflict <family> <rc> <output> — 0 IFF the resolver's
# output shows a genuine dependency conflict: not a clean resolvable set, and not
# a can't-check. A PURE function — the unit + mutation surface.
series_baseos_output_is_conflict() {
	local family="$1" rc="$2" out="$3"
	case "$family" in
		deb)
			# apt-get -s exits 0 on a resolvable set and non-zero (100) with an
			# "unmet dependencies" block when it cannot. rc==0 is always clean; a
			# non-zero rc for some OTHER reason (no dependency-conflict marker
			# present) degrades to clean/pass.
			(( rc != 0 )) || return 1
			grep -qiE "unmet dependencies|held broken packages|but it is not (going to be installed|installable)|need to be removed|Conflicts:|Breaks:|^E: " <<<"$out"
			;;
		rpm)
			# dnf --assumeno returns 1 for BOTH a clean aborted transaction and a
			# depsolve failure, so rc cannot decide it. A clean run prints a
			# "Transaction Summary" (or "Nothing to do"); a depsolve failure prints
			# an Error/Problem block naming the conflict and no summary.
			grep -qiE "^Error:|^ *Problem( [0-9]+)?:|cannot install both|conflicts with|nothing provides|but none of the providers|installed package .* (requires|obsoletes)|no match for argument" <<<"$out" \
				&& ! grep -qiE "^(Transaction Summary|Nothing to do)" <<<"$out"
			;;
		*) return 1 ;;
	esac
}

# series_baseos_conflict_detail <family> <output> — the operator-facing lines that
# name the conflict, ASCII-clamped, non-blank and capped. Best-effort: the panel
# renders this under the label.
series_baseos_conflict_detail() {
	local family="$1" out="$2" lines=""
	case "$family" in
		deb) lines="$(grep -E "unmet dependencies|Depends:|Conflicts:|Breaks:|need to be removed|is to be installed|held broken packages|^E: " <<<"$out")" ;;
		rpm) lines="$(grep -E "^Error:|Problem( [0-9]+)?:|cannot install both|conflicts with|nothing provides|but none of the providers|requires|obsoletes" <<<"$out")" ;;
	esac
	printf '%s\n' "$lines" | tr -cd '\11\12\40-\176' | awk 'NF' | head -n 12 | paste -sd'; ' -
}

# series_baseos_conflict_pkgs <family> <output> — best-effort newline list of the
# packages named on the conflict lines, for the structured 'packages' field.
series_baseos_conflict_pkgs() {
	local family="$1" out="$2"
	case "$family" in
		deb)
			# The "have unmet dependencies:" block lists "  <pkg> : Depends: ...".
			grep -oE "^[[:space:]]+[a-z0-9][a-z0-9.+-]* :" <<<"$out" | tr -d ' :' | sort -u
			;;
		rpm)
			# NEVRA tokens named on the Problem block, e.g.
			# openssh-9.9p1-27.el10.aarch64 — reported verbatim (more useful to the
			# operator than a lossy reduction to the bare name).
			grep -oE "[A-Za-z0-9._+-]+-[0-9][A-Za-z0-9._+~]*-[A-Za-z0-9._+~]+\.[a-z0-9_]+" <<<"$out" | sort -u
			;;
	esac
}

# baseos_removal_refusals <family> <resolver output> — the packages the planned
# transaction would remove that the apply would refuse (osf_removal_refusals), one
# "pkg (reason)" per line. Read from the same output the conflict classifier reads.
# Only the series preflight refuses on it: a series run is operator-approved and
# moves the whole set, so it stops before its snapshot rather than half-jump. A
# routine run holds the offending packages back at apply instead (osf_apply).
baseos_removal_refusals() {
	local -a removals=()
	case "$1" in
		deb) mapfile -t removals < <(osf_apt_removals "$2"); OSF_REPLACED="$(osf_apt_replaced "$2")" ;;
		rpm) mapfile -t removals < <(osf_dnf_removals "$2"); OSF_REPLACED="$(osf_dnf_replaced "$2")" ;;
	esac
	osf_removal_refusals "${removals[@]+"${removals[@]}"}"
}

# series_baseos_resolvable [cacheonly] — 0 iff the planned base-OS transaction for
# a series run resolves; 1 on a conflict, with SERIES_BASEOS_RESOLVE_DETAIL /
# _PKGS set. Fail-safe: a can't-run/can't-read resolver returns 0.
series_baseos_resolvable() {
	SERIES_BASEOS_RESOLVE_DETAIL=""
	SERIES_BASEOS_RESOLVE_PKGS=""
	local out rc=0 refused
	out="$(series_baseos_resolve_run "${1:-}")" || rc=$?
	refused="$(baseos_removal_refusals "$OS_FAMILY" "$out")"
	if [[ -n "$refused" ]]; then
		SERIES_BASEOS_RESOLVE_PKGS="$(sed 's/ (.*//' <<<"$refused")"
		SERIES_BASEOS_RESOLVE_DETAIL="the base-OS upgrade would remove $(osf_refused_list "$refused")"
		return 1
	fi
	series_baseos_output_is_conflict "$OS_FAMILY" "$rc" "$out" || return 0
	SERIES_BASEOS_RESOLVE_PKGS="$(series_baseos_conflict_pkgs "$OS_FAMILY" "$out")"
	SERIES_BASEOS_RESOLVE_DETAIL="$(series_baseos_conflict_detail "$OS_FAMILY" "$out")"
	[[ -n "$SERIES_BASEOS_RESOLVE_DETAIL" ]] \
		|| SERIES_BASEOS_RESOLVE_DETAIL="the base-OS package set cannot be upgraded together (dependency conflict); see the OS package manager"
	return 1
}

# --- routine base-OS resolvability preflight (shcp-updater#240) ----------------
# A ROUTINE scope=packages (or scope=all) run applies the WHOLE upgradable set
# (osf_upgradable) via osf_apply, exactly as a series run applies its full set. So
# the SAME one unrelated base-OS dependency conflict that #239/#238 caught before a
# SERIES snapshot (the AlmaLinux openssh EVR the depsolver "cannot install both")
# equally sinks a routine apt/dnf stage mid-run and auto-rolls it back with a
# generic message. This is the routine twin of series_baseos_resolvable: resolve
# the planned routine transaction WITHOUT applying it and, on a conflict, refuse
# BEFORE the snapshot naming the offending packages.
#
# The classifier/detail/pkgs helpers (series_baseos_output_is_conflict /
# series_baseos_conflict_{detail,pkgs}) are pure and work-set agnostic, so they are
# reused verbatim — a second copy would be a second thing to keep in step. Only the
# resolver command differs from the series one in one deb-specific way: the work
# set is apt_target_pkgs's routine set (osf_upgradable), not the series
# dist-upgrade set (osf_upgradable_full). Neither probe is --no-remove: osf_apply
# lets the resolver remove what the #321 policy allows and holds back the rest.
# On rpm the two are identical (both `dnf --assumeno update <osf_upgradable>`), and
# the same fixture seam drives both.
PACKAGES_BASEOS_RESOLVE_DETAIL=""
PACKAGES_BASEOS_RESOLVE_PKGS=""

# packages_baseos_resolve_run [cacheonly] — print the family's non-applying
# resolver output over the routine work set; return its exit code. Shares the
# SHCP_UPDATE_BASEOS_RESOLVE_FIXTURE seam with the series resolver.
packages_baseos_resolve_run() {
	local cacheonly="${1:-}"
	if [[ -n "${SHCP_UPDATE_BASEOS_RESOLVE_FIXTURE:-}" ]]; then
		cat -- "$SHCP_UPDATE_BASEOS_RESOLVE_FIXTURE" 2>/dev/null
		return "${SHCP_UPDATE_BASEOS_RESOLVE_RC:-0}"
	fi
	local -a specs=() ; local p
	case "$OS_FAMILY" in
		deb)
			while IFS= read -r p; do [[ -n "$p" ]] && specs+=("$p"); done < <(apt_target_pkgs packages)
			(( ${#specs[@]} > 0 )) || return 0
			# Mirror routine osf_apply (--only-upgrade install, NO --no-remove: its
			# removals are classified instead). -s never fetches or applies.
			DEBIAN_FRONTEND=noninteractive apt-get -s "${APT_PHASED_OPT[@]}" -o Dpkg::Use-Pty=0 \
				--only-upgrade install "${specs[@]}" 2>&1
			;;
		rpm)
			while IFS= read -r p; do [[ -n "$p" ]] && specs+=("$p"); done < <(osf_upgradable)
			(( ${#specs[@]} > 0 )) || return 0
			local -a co=()
			[[ "$cacheonly" == "cacheonly" ]] && co=(-C)
			dnf --assumeno "${co[@]}" update "${specs[@]}" "${DNF_SKIP_DEAD_REPOS[@]}" 2>&1
			;;
	esac
}

# packages_baseos_resolvable [cacheonly] — 0 iff the planned routine base-OS
# transaction resolves; 1 on a conflict, with PACKAGES_BASEOS_RESOLVE_DETAIL /
# _PKGS set. Fail-safe: a can't-run/can't-read resolver returns 0.
packages_baseos_resolvable() {
	PACKAGES_BASEOS_RESOLVE_DETAIL=""
	PACKAGES_BASEOS_RESOLVE_PKGS=""
	local out rc=0
	out="$(packages_baseos_resolve_run "${1:-}")" || rc=$?
	# A planned removal the #321 policy refuses is NOT a blocker here: osf_apply
	# holds back only the packages whose upgrade needs it and applies the rest, so
	# refusing the whole routine run would stop every other update with it.
	series_baseos_output_is_conflict "$OS_FAMILY" "$rc" "$out" || return 0
	PACKAGES_BASEOS_RESOLVE_PKGS="$(series_baseos_conflict_pkgs "$OS_FAMILY" "$out")"
	PACKAGES_BASEOS_RESOLVE_DETAIL="$(series_baseos_conflict_detail "$OS_FAMILY" "$out")"
	[[ -n "$PACKAGES_BASEOS_RESOLVE_DETAIL" ]] \
		|| PACKAGES_BASEOS_RESOLVE_DETAIL="the base-OS package set cannot be upgraded together (dependency conflict); see the OS package manager"
	return 1
}

# Extended preflight for a SERIES upgrade (§4.7 step 1), factored out of
# stage_preflight so it is unit-testable without the whole apt/manifest machinery.
# Populates SERIES_PF_BLOCKERS and SERIES_PF_WARNINGS; returns non-zero iff a
# blocker was recorded. A series run is manual and operator-approved, so — unlike
# the panel-independent security path (AD-6) — it MAY refuse; but a check that
# cannot READ its input degrades to a WARNING, never a false refusal.
#
# The three inputs each have an env seam (the production source is named beside
# it): the license state and last-backup timestamp the panel records, and the
# disk headroom a series jump needs (a full panel release plus a cross-suite apt
# upgrade). The panel-side settings keys are the contract the panel slice (S5)
# populates; until then the env seams drive it and an unread state is a warning.
SERIES_PF_BLOCKERS=()
SERIES_PF_WARNINGS=()
series_preflight_gate() {
	SERIES_PF_BLOCKERS=()
	SERIES_PF_WARNINGS=()

	# (0) Held SHCP packages — BLOCKER, both families. Both families run a series
	# upgrade: deb re-points the apt suite + Priority-1001 pins, rpm re-points the
	# /etc/dnf/vars/shcpseries var (shcp-updater#159), each proving itself at the
	# point of use in stage_apt (series_repoint). What neither can do is move a
	# package the operator pinned in place: an `apt-mark hold`, a dnf versionlock or
	# a dnf.conf exclude= makes the upgrade silently leave it on the old series
	# ("Nothing to do", exit 0 — measured on EL10) and makes a rollback unable to
	# settle it back. A series jump that half-happens is worse than none, so it is
	# refused until the hold is released (user decision 2026-09-16). The same check
	# is surfaced by `check --blockers` as series_packages_held.
	# rpm evaluates the TARGET leaf (a lock on the installed version hides nothing
	# in the current one), so it proves and primes that leaf first — the same
	# binding/key/probe the flip itself will require; a leaf that does not verify
	# is refused here, before the snapshot, rather than at stage_apt.
	local held_target=""
	if [[ "$OS_FAMILY" == "rpm" ]]; then
		if ! series_rpm_binding; then
			SERIES_PF_BLOCKERS+=("series upgrade blocked: the dnf repo binding or its key is not the managed SHCP binding (see the engine log)")
			return 1
		fi
		held_target="${SERIES_RPM_BASE}/rpm/el${SERIES_RPM_EL}/${REQ_SERIES}/"
		if ! [[ "$REQ_SERIES" =~ ^[0-9]+\.[0-9]+$ ]] || ! series_dnf_prime "$held_target"; then
			SERIES_PF_BLOCKERS+=("series upgrade blocked: the target series leaf ${held_target} did not fetch/verify (${SERIES_DNF_PRIME_DETAIL:-malformed series})")
			return 1
		fi
	else
		# deb: any non-empty value asks series_held_shcp_check to evaluate operator
		# apt pins against the target suite (fetched privately), not the current one.
		held_target="$REQ_SERIES"
	fi
	if ! series_held_shcp_check "$held_target"; then
		SERIES_PF_BLOCKERS+=("$(series_held_detail)")
	fi
	# The panel's own update.blacklist is a hold too: apt_target_pkgs never names a
	# blacklisted package, so a series run would leave it behind and the
	# post-apply convergence check would roll the whole jump back (updater#179).
	local bl_held
	if ! bl_held="$(series_blacklisted_shcp_pkgs "$held_target")"; then
		SERIES_PF_BLOCKERS+=("series upgrade blocked: installed package(s) on the update blacklist cannot be ruled out as SHCP packages (the SHCP package index is unreadable): $(printf '%s' "$bl_held" | paste -sd' ' -) (remove them from the update blacklist first)")
	elif [[ -n "$bl_held" ]]; then
		SERIES_PF_BLOCKERS+=("series upgrade blocked: SHCP package(s) on the update blacklist would stay on the old series: $(printf '%s' "$bl_held" | awk 'NF && NR <= 20' | paste -sd' ' -) (remove them from the update blacklist first)")
	fi

	# (0b) base-OS resolvability — BLOCKER, both families (shcp-updater#238). A
	# series run upgrades the whole upgradable set, so resolve it first: ONE
	# unrelated base-OS conflict then becomes a NAMED refusal here, before the
	# snapshot, instead of a generic mid-run stage_apt failure + auto-rollback.
	# Fed by the index refresh stage_preflight just ran; online here (no cacheonly).
	if ! series_baseos_resolvable; then
		SERIES_PF_BLOCKERS+=("series upgrade blocked: the base-OS package upgrade does not resolve — ${SERIES_BASEOS_RESOLVE_DETAIL} (resolve the conflict with the OS package manager, then retry)")
	fi

	# (a) account-backup freshness — WARNING only. A series jump is the run an
	# operator most wants a recent backup before, but a stale/absent one must not
	# block it.
	local last_backup max_age
	last_backup="${SHCP_UPDATE_LAST_BACKUP_AT:-$(settings_get 'backup.last_success_at' '')}"
	max_age="${SHCP_UPDATE_BACKUP_MAX_AGE:-604800}"
	[[ "$max_age" =~ ^[0-9]+$ ]] || max_age=604800
	if [[ -z "$last_backup" ]]; then
		SERIES_PF_WARNINGS+=("no successful account backup is on record — take one before a series upgrade")
	else
		local bt now_s
		bt="$(date -u -d "$last_backup" +%s 2>/dev/null || true)"
		if [[ "$bt" =~ ^[0-9]+$ ]]; then
			now_s="$(date -u +%s)"
			if (( now_s - bt > max_age )); then
				SERIES_PF_WARNINGS+=("the most recent account backup is $(( (now_s - bt) / 86400 )) day(s) old — take a fresh one before a series upgrade")
			fi
		else
			SERIES_PF_WARNINGS+=("the recorded account-backup timestamp is unparseable — cannot confirm a recent backup before a series upgrade")
		fi
	fi

	# (b) license / entitlement — never a BLOCKER. Panel updates stay available
	# on a lapsed server (owner decision 2026-10-01, shcp-updater#288), so each
	# state only changes what the operator is told. The panel writes exactly
	# NodeLicenseEffective's three values (shcp-base#654 onward; none other ever
	# reached license.state); an unknown or unreadable one warns and proceeds.
	local lic
	lic="${SHCP_UPDATE_LICENSE_STATE:-$(settings_get 'license.state' '')}"
	case "$lic" in
		active) : ;;
		grace)
			SERIES_PF_WARNINGS+=("the panel license is in its grace window: the licensing service has not been reached recently — proceeding with the series upgrade") ;;
		read_only)
			SERIES_PF_WARNINGS+=("the panel license has lapsed (read_only) — proceeding with the series upgrade; panel updates stay available on a lapsed server") ;;
		*)
			SERIES_PF_WARNINGS+=("the panel license state could not be confirmed (${lic:-unread}) — proceeding with the series upgrade unverified") ;;
	esac

	# (c) disk headroom — BLOCKER. Reuse cmd_check's df -Pk pattern, own threshold:
	# a series jump pulls a full panel release plus a cross-suite apt upgrade.
	local min_kb free_kb
	min_kb="${SHCP_UPDATE_SERIES_MIN_FREE_KB:-2097152}"
	[[ "$min_kb" =~ ^[0-9]+$ ]] || min_kb=2097152
	free_kb="$(df -Pk "$SHCP_UPDATE_STATE_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || echo "")"
	if [[ -n "$free_kb" && "$free_kb" =~ ^[0-9]+$ && "$free_kb" -lt "$min_kb" ]]; then
		SERIES_PF_BLOCKERS+=("only ${free_kb} KiB free under the update state dir; a series upgrade needs at least ${min_kb} KiB")
	fi

	(( ${#SERIES_PF_BLOCKERS[@]} == 0 ))
}

stage_preflight() {
	# OS_FAMILY is already set by run_stages (covers the resume entry point too).

	# A second security-update mechanism (unattended-upgrades / dnf-automatic) is
	# a WARNING here, not a refusal, and the distinction is load-bearing rather
	# than cosmetic.
	#
	# Coexisting is safe for THIS run: every lock-taking apt call passes
	# DPkg::Lock::Timeout, so a concurrent unattended-upgrades run makes this one
	# WAIT on the dpkg lock rather than fail. Refusing instead would return from
	# run_stages ABOVE the convergence block that is the only writer of
	# last_security_success, so the box would never be able to record that the
	# engine patched it — a deadlock, not a safety gain (measured in the alpha).
	#
	# What is NOT safe is two appliers over time (AD-6: exactly one), which is why
	# the condition is still reported: check --blockers surfaces it, the panel
	# shows it, and the operator is told to mask the timer and purge the package.
	# A 1.0 install never ships u-u or dnf-automatic and masks their timers
	# itself, so this fires only when an operator (or the base image) adds one.
	local uu_warning="null"
	if second_applier_armed; then
		uu_warning="$(jq -nc --arg d "$(second_applier_detail)" '$d')"
		engine_log "preflight: a distro auto-updater is armed ($(second_applier_id)) — proceeding (the engine applier still runs; deb apt calls wait on the lock); resolve per check --blockers"
	fi

	# An interrupted dpkg makes every later apt call fail. Refuse HERE, where the
	# reason can still be named, rather than at stage_apt where it surfaces as a
	# bare non-zero exit (preflight failure = nothing done).
	if dpkg_interrupted; then
		STAGE_RESULT='{"blockers": ["dpkg was interrupted on this host — run `dpkg --configure -a`; apt cannot proceed until it is repaired"], "workset_empty": false}'
		return 1
	fi
	if rpm_transaction_interrupted; then
		STAGE_RESULT="$(jq -nc --arg d "$(rpm_transaction_interrupted_detail)" '{blockers: [$d], workset_empty: false}')"
		return 1
	fi
	# A pointer on another series than the panel refuses every run that can move
	# SHCP packages outside a series run's own re-point + convergence check. The
	# security scope stays panel-independent (AD-6).
	if [[ "$SERIES_RUN" != "1" && "$REQ_SCOPE" != "security" ]] && series_pointer_mismatch; then
		STAGE_RESULT="$(jq -nc --arg d "series_pointer_mismatch: ${SERIES_POINTER_DETAIL}" '{blockers: [$d], workset_empty: false}')"
		return 1
	fi

	# Index refresh. A transiently unreachable mirror must not fail the run — but
	# only while the CACHED index is still recent. Past that, the work set computed
	# from it is fiction: apt reports nothing upgradable, the run routes straight to
	# finalize as a noop and journals `healthy` having patched nothing. Silent
	# non-patching is the one failure mode SC-319 exists to prevent.
	if ! osf_refresh_index; then
		local idx_age
		idx_age="$(index_age_seconds 2>/dev/null || true)"
		if [[ -n "$idx_age" && "$idx_age" -gt "$SHCP_APT_INDEX_MAX_AGE" ]]; then
			STAGE_RESULT="$(jq -nc --argjson a "$idx_age" '{blockers: ["package index refresh failed and the cached index is stale — a work set computed from it would report nothing to do"], index_age_seconds: $a, workset_empty: false}')"
			return 1
		fi
		engine_log "package index refresh failed; cached index still recent, proceeding (this run will not stamp last_security_success, SC-319)"
		# Applying what the cached index already knows is fine; it is not evidence
		# the host is patched. The convergence gate and `check` read this flag.
		index_refresh_flag preflight
	fi
	if [[ "$REQ_SCOPE" == security ]]; then
		local new_shcp_dep
		new_shcp_dep="$(security_new_shcp_deps_pending)" || {
			STAGE_RESULT='{"blockers": ["security_dependency_unverified: the security transaction could not be resolved"], "workset_empty": false}'
			return 1
		}
		if [[ -n "$new_shcp_dep" ]]; then
			STAGE_RESULT="$(jq -nc --arg p "${new_shcp_dep%%$'\n'*}" '{blockers: ["security_new_shcp_dependency: " + $p + " requires an admin-approved update"], workset_empty: false}')"
			return 1
		fi
	fi

	# Record the package baseline (§4.2-1): packages.before drives the diff — and
	# the ROLLBACK. An empty baseline on a box that has a package manager is a
	# refusal, not a shrug: with before={} and a full after, every installed
	# package diffs as {from:null,to:ver} ("this run installed it") and the
	# rollback's undo for that shape is `apt-get remove` of each — the whole box
	# (SC-791). Preflight is not in the [R] set,
	# so refusing here changes nothing on the host.
	local pkg_baseline
	pkg_baseline="$(baseline_json)" || pkg_baseline=""
	if [[ -z "$pkg_baseline" ]] || { osf_pkg_db_present && baseline_json_empty "$pkg_baseline"; }; then
		STAGE_RESULT='{"blockers": ["package baseline could not be read — the package database listed nothing (dpkg-query/rpm failed or answered empty); a run with no before-state could not be rolled back safely, so nothing was applied"], "workset_empty": false}'
		return 1
	fi
	# Through the slurpfile path, never --argjson: a full-box baseline exceeds
	# MAX_ARG_STRLEN (E2BIG, shcp-updater#188).
	printf '%s\n' "$pkg_baseline" | journal_update_bigjson "$CURRENT_RUN_ID" b '.packages.before = $b[0]'

	# The operator's blacklist is subtracted inside apt_target_pkgs below, through
	# a process substitution that cannot report a failed read. Probe it HERE, in
	# this shell, so the run's own record says whether the exclusions were in
	# force (blacklist_unreadable) — the read is fail-toward-patching by design
	# (AD-6) and this is what keeps that from being silent.
	local blacklist_unreadable=false
	blacklist_read >/dev/null || blacklist_unreadable=true

	# Work set = candidate PACKAGES plus the panel candidate (§4.2-1). Both halves
	# matter: computing it from apt alone means a scope=panel run finds an empty
	# work set, routes straight to finalize, and never reaches stage_panel.
	if [[ "${SHCP_UPDATE_FORCE_WORKSET:-0}" == "1" ]]; then
		WORKSET_EMPTY=0
	elif [[ "$SERIES_RUN" == "1" ]]; then
		# A series run always has work: the package-manager re-point itself. The
		# upgradable set below is computed from the index preflight just refreshed,
		# which is still the OLD series (stage_apt re-points, refreshes and recomputes
		# against the target). A box with nothing upgradable in the old series would
		# otherwise route to finalize as a noop and never move series
		# (shcp-updater#179) — masked today only by a series run also carrying a panel
		# candidate, which this must not depend on.
		WORKSET_EMPTY=0
	elif [[ -n "$(apt_target_pkgs "$REQ_SCOPE")" ]]; then
		WORKSET_EMPTY=0
	else
		WORKSET_EMPTY=1
	fi

	# Manifest fetch + verify happens HERE (§4.2-1) so the SC-064 floor, the
	# SC-207 status gate and the panel candidate are all decided before anything
	# is applied. The verified copy is kept for the run so stage_panel decides
	# against exactly the bytes preflight approved, not a second fetch that could
	# have moved underneath it.
	PANEL_CANDIDATE=""
	local panel_updater_detail=""
	if [[ "$REQ_SCOPE" != "packages" ]]; then
		local manifest="${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json"
		if manifest_fetch "$manifest"; then
			PANEL_CANDIDATE="$(panel_target "$manifest" "$(panel_current_version)" \
				"$REQ_SCOPE" "$REQ_TARGET_VERSION" "$REQ_SERIES" "$REQ_REINSTALL" || true)"
			if [[ -z "$PANEL_CANDIDATE" ]]; then
				panel_updater_detail="$(panel_updater_requirement_detail "$manifest" "$(panel_current_version)" \
					"$REQ_SERIES" "$REQ_TARGET_VERSION" "$REQ_SCOPE" "$REQ_REINSTALL" || true)"
			fi
			[[ -n "$PANEL_CANDIDATE" ]] && WORKSET_EMPTY=0
		elif [[ "$REQ_SCOPE" == "panel" || "$REQ_SCOPE" == "series" ]]; then
			# A panel-only run cannot proceed without knowing the target, and
			# reporting "nothing to do" would be a lie about an outage-free run.
			# Nor can an approved series run (base#1500): without the verified
			# manifest there is no series target, and degrading to a packages-only
			# run would flip the repo pointer with no panel to go with it.
			STAGE_RESULT='{"blockers": ["versions manifest unavailable or unverifiable"], "workset_empty": false}'
			return 1
		else
			# scope=all/security: the apt half is still real work, so a manifest
			# outage degrades to "no panel candidate" instead of failing the run.
			engine_log "preflight: manifest unavailable — continuing without a panel candidate"
		fi
	fi

	# An approved series run whose target the verified manifest no longer offers
	# (the series moved on since the offer, SC-064 floor, min_updater_version,
	# status) must not proceed as a packages-only jump: stage_apt would flip the
	# repo pointer and move packages while stage_panel quietly skipped the panel,
	# ending healthy (break-it finding, updater#178). Refuse; the operator
	# re-approves the current offer.
	if [[ "$REQ_SCOPE" == "series" && -z "$PANEL_CANDIDATE" ]]; then
		STAGE_RESULT="$(jq -nc --arg v "$REQ_TARGET_VERSION" --arg s "$REQ_SERIES" --arg d "$panel_updater_detail" \
			'{blockers: [if $d != "" then $d else "the approved series target " + $v + " (series " + $s + ") is not installable from the verified manifest — re-approve the current series offer" end], workset_empty: false}')"
		return 1
	fi

	# A panel flip installs its target workers before restart. Every other run
	# activates the current release without installing its units (SC-513).
	local live_release
	live_release="$(panel_link_target)"
	if [[ -n "$live_release" && -z "$PANEL_CANDIDATE" ]] \
			&& ! managed_worker_installed_check "$live_release"; then
		STAGE_RESULT="$(jq -nc --arg d "$REQUIRED_UNIT_DETAIL" '{blockers: [$d], workset_empty: false}')"
		return 1
	fi

	# Extended series-upgrade preflight (§4.7 step 1). Blockers here abort the run
	# with nothing applied (preflight is not in the [R] auto-rollback set); the
	# warnings ride the success result below.
	local -a series_warnings=()
	if [[ "$SERIES_RUN" == "1" ]]; then
		if ! series_preflight_gate; then
			STAGE_RESULT="$(printf '%s\n' "${SERIES_PF_BLOCKERS[@]}" \
				| jq -Rsc 'split("\n") | map(select(length > 0)) | {blockers: ., workset_empty: false}')"
			return 1
		fi
		series_warnings=("${SERIES_PF_WARNINGS[@]+"${SERIES_PF_WARNINGS[@]}"}")
	elif [[ "$REQ_SCOPE" == "packages" || "$REQ_SCOPE" == "all" ]]; then
		# Routine base-OS resolvability — BLOCKER, both families (shcp-updater#240).
		# A routine packages/all run applies the whole upgradable set, so resolve it
		# first: one unrelated base-OS conflict becomes a NAMED refusal here, before
		# the snapshot, instead of a generic mid-run apt/dnf failure + auto-rollback
		# (the routine twin of series_preflight_gate's (0b)). Online (no cacheonly):
		# stage_preflight has just consulted the same index. Fail-safe: a resolver
		# that cannot answer degrades to a PASS, never a false refusal. Both scopes
		# apply osf_upgradable and hit the identical mid-run failure, so both are
		# gated; the id stays packages_baseos_conflict for the panel.
		if ! packages_baseos_resolvable; then
			STAGE_RESULT="$(jq -nc --arg d "$PACKAGES_BASEOS_RESOLVE_DETAIL" \
				'{blockers: ["package update blocked: the base-OS package upgrade does not resolve — " + $d + " (resolve the conflict with the OS package manager, then retry)"], workset_empty: false}')"
			return 1
		fi
	fi

	local candidate_version=""
	if [[ -n "$PANEL_CANDIDATE" ]]; then
		candidate_version="$(jq -r '.version // empty' <<<"$PANEL_CANDIDATE")"
		local console_rc=0
		console_identity_target_preflight "$PANEL_CANDIDATE" || console_rc=$?
		if [[ $console_rc != 0 ]]; then
			STAGE_RESULT="$(jq -nc --arg b "$(console_identity_refusal "$console_rc")" '{blockers: [$b], workset_empty: false}')"
			return 1
		fi
		if console_identity_required; then
			journal_update "$CURRENT_RUN_ID" '.console_identity_target = $target' --argjson target "$PANEL_CANDIDATE"
		fi
	fi
	if console_identity_required && ! console_environment_install; then
		STAGE_RESULT='{"blockers":["console identity environment prerequisites are missing or unsafe"],"workset_empty":false}'
		return 1
	fi
	# §4.2-1's remaining baseline: unit states, panel version, panel health
	# probe. Recorded LAST so it is the state immediately before anything is
	# applied, and inside this stage's own result so stage_health can read the
	# genuine pre-update reading back out of the journal on a resumed run.
	# Recorded, never a gate: a host that was already unhealthy must still be
	# patchable (AD-6), and the diff is what makes a REGRESSION attributable.
	local baseline
	baseline="$(preflight_baseline_json)"
	local series_warns_json
	series_warns_json="$(printf '%s\n' "${series_warnings[@]+"${series_warnings[@]}"}" \
		| jq -Rsc 'split("\n") | map(select(length > 0))')"
	STAGE_RESULT="$(jq -nc \
		--argjson empty "$( [[ $WORKSET_EMPTY -eq 1 ]] && echo true || echo false )" \
		--arg panel "$candidate_version" \
		--argjson baseline "$baseline" \
		--argjson uu "$uu_warning" \
		--argjson sw "$series_warns_json" \
		--argjson blu "$blacklist_unreadable" \
		--arg updater_detail "$panel_updater_detail" \
		'{blockers: [], workset_empty: $empty,
		  panel_candidate: (if $panel == "" then null else $panel end),
		  panel_updater_refusal: (if $updater_detail == "" then null else $updater_detail end),
		  warnings: ((if $uu == null then [] else [$uu] end) + $sw),
		  baseline: $baseline}
		 + (if $blu then {blacklist_unreadable: true} else {} end)')"
}

# UPD-4 (§4.2-2): the pre-update safety net, two artifacts wide.
#   - a SQLite online backup of the panel DB into the app-owned backup tree,
#     0600, proven usable before it is called a snapshot (SC-272, SC-055);
#   - a tar of the configuration a rollback would need to put back.
# It runs at stage 2, BEFORE the maintenance flag goes up in the panel stage, so
# it works against a fully live and writable panel. That is deliberate — a
# snapshot must not cost downtime — and it is exactly why the copy is sqlite3's
# online `.backup` with a busy timeout and not `cp`: a copied main file is the
# last CHECKPOINTED state (every WAL-only commit missing), and a copied main+WAL
# pair replays the old frames onto whatever it is later put beside.
#
# NOT `[R]`: nothing has been applied yet when this stage runs, so a failure here
# aborts with the box untouched. It fails rather than continues whenever there IS
# a database and it could not be protected — proceeding would mean entering the
# migration with no rollback target, which is the one thing this stage exists to
# prevent.
stage_snapshot() {
	local db="$SHCP_UPDATE_DB"
	local snap="" integrity="" fk="" integrity_before="" fk_before="" snap_sha=""

	# Cross-check the engine's DB path against the panel's own canonical env
	# before using either. A disagreement means a rollback would restore the
	# wrong database over the right one, and there is no safe way to guess which
	# was meant — so refuse while nothing has been applied.
	local declared
	declared="$(panel_db_from_env)"
	if [[ -n "$declared" && "$declared" != "$db" ]]; then
		engine_log "snapshot: ${PANEL_ENV} declares DATABASE_URL=${declared}, engine has ${db}"
		stage_error snapshot "panel DB path disagreement: ${PANEL_ENV} says ${declared}, engine says ${db}"
		return 1
	fi

	if [[ ! -f "$db" ]]; then
		# No panel database on this host. The security timer is deliberately
		# panel-independent (AD-6), so a box whose panel was never installed —
		# or is broken — must still be patchable. Recorded, never silent.
		engine_log "snapshot: no panel database at ${db} — nothing to snapshot"
	elif [[ -n "$PRODUCT" ]] && ! product_db_op_applies "$PRODUCT" snapshot; then
		# EP-4b: this product declares the snapshot op N/A (e.g. shcp-dns has no
		# DB stage at all). A stray DB file is not this product's to snapshot --
		# skip, do not error. Inert when PRODUCT is unset (legacy/tests).
		engine_log "snapshot: snapshot op N/A for product ${PRODUCT} -- skipped"
	else
		if ! db_backup_dir_prepare; then
			engine_log "snapshot: ${DB_BACKUP_DIR} unusable: ${DB_SNAPSHOT_WHY}"
			stage_error snapshot "$DB_SNAPSHOT_WHY"
			return 1
		fi
		snap="${DB_BACKUP_DIR}/${CURRENT_RUN_ID}.db"

		# AD-7's "before": the LIVE database's own verdict, recorded so a
		# post-migration failure is attributable to the migration instead of to
		# a wart that was already there. Not a gate — refusing to patch a host
		# over a pre-existing orphaned row is a worse outcome than the row.
		integrity_before="$(db_integrity "$db")"
		fk_before="$(db_foreign_keys "$db")"
		db_fix_ownership "$db"

		if ! db_snapshot_create "$db" "$snap"; then
			engine_log "snapshot: ${DB_SNAPSHOT_WHY}"
			stage_error snapshot "$DB_SNAPSHOT_WHY"
			db_fix_ownership "$db"
			return 1
		fi
		db_fix_ownership "$db"
		# The content pin: computed in the root-held scratch before the file
		# reached the panel-writable directory, journaled here (root-held) and
		# demanded back by db_restore_from_snapshot. Without it a rollback
		# restores whatever the panel account left at that path.
		snap_sha="$DB_SNAPSHOT_SHA"

		# A file that fails its own integrity check is not a snapshot, and
		# neither is a 0-byte one — see db_snapshot_usable for why the check is
		# a conjunction.
		if ! db_snapshot_usable "$snap"; then
			engine_log "snapshot: the copy is not a usable snapshot: ${DB_SNAPSHOT_WHY}"
			stage_error snapshot "unusable snapshot: ${DB_SNAPSHOT_WHY}"
			return 1
		fi
		integrity="$DB_SNAPSHOT_INTEGRITY"
		fk="$DB_SNAPSHOT_FK"
		db_sidecars_remove "$snap"
		engine_log "snapshot: ${db} -> ${snap} (integrity ${integrity}, foreign keys ${fk})"
	fi

	# §4.2-2's config half. For a routine run this is the rollback aid beside the
	# DB safety net — reported, never fatal. For a SERIES run it is MANDATORY: the
	# suite/pin revert a cross-series rollback depends on lives in this tar, and a
	# no-DB box (AD-6) has NOTHING else to restore, so a series run with no config
	# snapshot has no safety net at all. Refuse rather than skip silently.
	local ctar="${RUNS_DIR}/${CURRENT_RUN_ID}/config.tar.gz"
	if ! config_tar_create "$ctar"; then
		if [[ "$SERIES_RUN" == "1" ]]; then
			engine_log "snapshot: series upgrade requires a config snapshot but none could be written"
			stage_error snapshot "series upgrade requires a configuration snapshot (tar) but none could be written"
			return 1
		fi
		engine_log "snapshot: no config tar written (no matching paths, or tar failed)"
		ctar=""
	fi

	# §4.2-2 "record retention prune plan" — the PLAN only. Executing it here
	# would delete the snapshot a failed run's rollback depends on; §4.5 puts
	# the pruning in finalize, which a failed run never reaches.
	local plan
	plan="$(snapshot_prune_plan "$CURRENT_RUN_ID" \
		| jq -R -s 'split("\n") | map(select(length > 0))')"

	STAGE_RESULT="$(jq -nc \
		--arg snap "$snap" --arg i "$integrity" --arg fk "$fk" --arg sha "$snap_sha" \
		--arg ib "$integrity_before" --arg fkb "$fk_before" \
		--arg ct "$ctar" --argjson keep "$SNAPSHOT_KEEP" --argjson plan "$plan" \
		'{db_snapshot: (if $snap == "" then null else $snap end),
		  db_logical_sha: (if $sha == "" then null else $sha end),
		  integrity: (if $i == "" then null else $i end),
		  foreign_key_check: (if $fk == "" then null else $fk end),
		  integrity_before: (if $ib == "" then null else $ib end),
		  foreign_key_check_before: (if $fkb == "" then null else $fkb end),
		  config_tar: (if $ct == "" then null else $ct end),
		  retention: {keep: $keep, prune_plan: $plan}}')"
	return 0
}

# Snapshot the post-apt package state and journal the diff; echoes the diff.
# Called on BOTH exits from stage_apt, because §4.5 step 2 reads
# packages.changed and an aborted transaction still moved packages.
apt_record_diff() {
	# The same guard as preflight's before-baseline, mirrored: an after-state the
	# package database could not produce would diff every package as REMOVED
	# (to: null), and the rollback would then try to put the whole box back. Keep
	# the before-state as the after-state (no diff, nothing to undo from it) and
	# say so on the journal, rather than record a fiction.
	local after
	after="$(baseline_json)" || after=""
	if [[ -z "$after" ]] || { osf_pkg_db_present && baseline_json_empty "$after"; }; then
		engine_log "package after-state could not be read (the package database listed nothing) — recording the before-state as the after-state; packages.changed is UNKNOWN for this run, not empty"
		journal_update "$CURRENT_RUN_ID" '.packages.after = .packages.before | .packages.after_unreadable = true'
	else
		printf '%s\n' "$after" | journal_update_bigjson "$CURRENT_RUN_ID" a '.packages.after = $a[0]'
	fi
	local changed
	changed="$(journal_changed_json "$CURRENT_RUN_ID")"
	journal_update "$CURRENT_RUN_ID" '.packages.changed = $c' --argjson c "$changed"
	printf '%s' "$changed"
}

# --- bundled configuration parser (shcp-conf, shcp-base#1788) -------------------
# conf_bin — the always-current configuration parser this engine package ships
# (/usr/lib/shcp-update/bin/<arch>/shcp-conf, built from shcp-base main;
# shcp-base#1788). Deliberately NOT the panel's pinned /usr/sbin/shcp-conf: the
# engine edits system files for the release it is installing, and a new grammar
# or profile tag arrives with the engine, not with the panel series. Profile tags
# are append-only, so the two builds agree on every tag both know.
conf_bin() {
	local bin="${SHCP_UPDATE_CONF_BIN:-/usr/lib/shcp-update/bin/$(uname -m)/shcp-conf}"
	[[ -x "$bin" && ! -L "$bin" ]] || return 1
	printf '%s' "$bin"
}

# conf_dir_private <dir> — succeed only if no one but root (or the user the
# engine runs as) can create, rename or unlink entries in <dir>: owner is root or
# EUID, not world-writable, and group-writable only for the root group. Staging
# a file in a directory someone else can write lets them swap the staged name
# for a symlink between our checks and our chown/chmod/write, so conf_edit
# refuses to work in one.
conf_dir_private() {
	local dir="$1" uid gid mode
	read -r uid gid mode < <(stat -L -c '%u %g %a' -- "$dir" 2>/dev/null) || return 1
	[[ "$uid" =~ ^[0-9]+$ && "$gid" =~ ^[0-9]+$ && "$mode" =~ ^[0-7]+$ ]] || return 1
	[[ "$uid" == 0 || "$uid" == "$EUID" ]] || return 1
	(( (8#$mode & 8#002) == 0 )) || return 1
	(( (8#$mode & 8#020) == 0 || gid == 0 )) || return 1
}

# conf_edit <file> <verb> [shcp-conf flags...] -- <path> [values...] — one
# structured edit of a live configuration file. The `--` is required: flags go
# before it and conf_edit hands shcp-conf an explicit `--` before the path, so no
# path or value (which may come from data) is ever read as a flag.
#
# The edit is bound to the bytes read (--expect-sha256) and staged in the same
# directory. The staged file gets the original owner, mode and SELinux label
# while still EMPTY, before any content is written, and only in a directory no
# one else can write (conf_dir_private), so there is no window in which another
# user can swap it for a symlink. It is then published with durable_replace
# (fsync file, rename, fsync directory).
#
# Compare-and-swap, honestly: the file is re-hashed just before the rename and
# the edit is refused if it changed since it was read. That NARROWS the lost-
# update window to the instant between that re-hash and the rename; it does not
# close it. Callers MUST hold whatever lock the file's other writers take.
#
# The staging runs in a subshell with its own EXIT trap, so a failure at any
# step — or a SIGTERM/SIGINT mid-edit (systemd stop signals the whole cgroup) —
# removes the staged file without touching the engine's global EXIT trap. Only
# SIGKILL can leave one behind. On any failure before the rename the live file
# is untouched.
conf_edit() {
	local file="$1" verb="$2"; shift 2
	local -a flags=() args=()
	while [[ $# -gt 0 && "$1" != "--" ]]; do flags+=("$1"); shift; done
	if [[ $# -eq 0 ]]; then
		engine_log "conf: conf_edit needs '--' between the shcp-conf flags and the path"
		return 1
	fi
	shift
	args=("$@")
	local conf dir
	conf="$(conf_bin)" || { engine_log "conf: the bundled shcp-conf is missing"; return 1; }
	[[ -f "$file" && ! -L "$file" ]] || { engine_log "conf: ${file} is not a regular file (or is a symlink)"; return 1; }
	dir="$(dirname "$file")"
	conf_dir_private "$dir" \
		|| { engine_log "conf: ${dir} is writable by another user or group; refusing to stage ${file} there"; return 1; }
	(
		tmp=""
		trap '[[ -n "$tmp" ]] && rm -f -- "$tmp"' EXIT
		sum="$(sha256sum < "$file")" || exit 1
		sum="${sum%% *}"
		tmp="$(mktemp "${dir}/.$(basename "$file").conf.XXXXXX")" || exit 1
		# Owner, mode and label on the empty file, before any byte is written.
		if ! chown -h --reference="$file" -- "$tmp" || ! chmod --reference="$file" -- "$tmp"; then
			engine_log "conf: could not copy ownership/mode onto the staged ${file}"; exit 1
		fi
		if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then
			chcon -h --reference="$file" -- "$tmp" 2>/dev/null \
				|| { engine_log "conf: could not copy the SELinux label onto the staged ${file}"; exit 1; }
		fi
		if ! "$conf" "$verb" "${flags[@]}" --expect-sha256 "$sum" -- "${args[@]}" < "$file" > "$tmp"; then
			engine_log "conf: shcp-conf ${verb} refused ${file}"; exit 1
		fi
		now="$(sha256sum < "$file")"
		if [[ "${now%% *}" != "$sum" ]]; then
			engine_log "conf: ${file} changed during the edit; refusing to overwrite"; exit 1
		fi
		if ! durable_replace "$tmp" "$file" keep; then
			# Either the rename failed (live file untouched) or it happened and a
			# durability barrier after it failed (new bytes visible, persistence
			# not proved). Either way the caller must not report success.
			engine_log "conf: could not durably publish ${file}"; exit 1
		fi
		tmp=""
	)
}

# --- series apt suite + pin re-render (UPD-9 #370, S3 fills these) -------------
# For a series (cross-minor) upgrade the apt suite pointer
# (/etc/apt/sources.list.d/shcp.list) and the per-daemon Priority-1001 pins must
# be re-rendered for the TARGET series BEFORE stage_apt fetches anything, so apt
# never crosses a series under stale pins (§4.7 step 3, SC-078/029/030/249). The
# pin values come from the signed manifest's daemon_pins field (arch decision A),
# now guarded by SC-472. S2 reserves the hook as a no-op
# so S3 drops straight in; S3 also inserts the post-rewrite `apt-get update` +
# workset recompute against the new suite (F7).
# series_parse_back_suite <staged-list> <deb-re> — SC-029 parse-back: echo the
# suite of the ONE active deb line in <staged-list>, re-tokenized with the same
# regex the rewrite parsed the live line with. Fails (echoing what it found) on
# zero or several deb lines, or one that does not re-tokenize.
series_parse_back_suite() {
	local staged="$1" deb_re="$2" wline written_n=0 written_suite=""
	while IFS= read -r wline || [[ -n "$wline" ]]; do
		[[ "$wline" =~ ^[[:space:]]*# ]] && continue
		[[ "$wline" =~ ^[[:space:]]*deb([[:space:]]|-src) ]] || continue
		written_n=$((written_n + 1))
		if [[ "$wline" =~ $deb_re ]]; then
			written_suite="${BASH_REMATCH[5]}"
		fi
	done < "$staged"
	printf '%s' "$written_suite"
	[[ "$written_n" -eq 1 && -n "$written_suite" ]]
}

# series_rewrite_suite — retarget the single active `deb … <codename>-<old> main`
# line in /etc/apt/sources.list.d/shcp.list from <old> to REQ_SERIES.
#
# SC-249: deb-only. On EL the series pointer is the dnf var shcpseries, re-pointed
# by series_rewrite_dnf_series (series_repoint dispatches); this function refuses
# on rpm rather than writing Debian paths onto an EL box.
#
# SC-030: the new line is rebuilt by FIELD (deb type, options, url, suite,
# components) with only the suite token swapped — never string-concatenated or
# sed-substituted over arbitrary bytes. SC-029: rendered to a tmp in the same
# dir, PARSE-BACK-verified (re-tokenize, assert the written suite == the intended
# <codename>-<REQ_SERIES>), and only then renamed into place — so a corrupt write
# never reaches the live file and the live shcp.list is untouched on any failure.
series_rewrite_suite() {
	if [[ "$OS_FAMILY" != "deb" ]]; then
		engine_log "series apt suite rewrite is deb-only; refusing to rewrite apt paths on an ${OS_FAMILY} host (rpm re-points via series_rewrite_dnf_series)"
		return 1
	fi
	if ! [[ "$REQ_SERIES" =~ ^[0-9]+\.[0-9]+$ ]]; then
		engine_log "series suite rewrite: target series '${REQ_SERIES}' is malformed (want MAJOR.MINOR)"
		return 1
	fi

	local list="${CONFIG_ROOT%/}/etc/apt/sources.list.d/shcp.list"
	local security_list="${CONFIG_ROOT%/}/etc/apt/sources.list.d/shcp-security.list"
	if [[ ! -r "$list" ]]; then
		engine_log "series suite rewrite: ${list} is missing or unreadable"
		return 1
	fi

	# Exactly one active (non-comment, non-blank) deb line — refuse ambiguity.
	local deb_re='^(deb(-src)?)([[:space:]]+\[[^]]*\])?[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+(.+)$'
	local line active="" active_n=0
	while IFS= read -r line || [[ -n "$line" ]]; do
		[[ "$line" =~ ^[[:space:]]*# ]] && continue
		[[ "$line" =~ ^[[:space:]]*$ ]] && continue
		if [[ "$line" =~ ^[[:space:]]*deb([[:space:]]|-src) ]]; then
			active="$line"; active_n=$((active_n + 1))
		fi
	done < "$list"
	if [[ "$active_n" -ne 1 ]]; then
		engine_log "series suite rewrite: expected exactly one deb line in ${list}, found ${active_n}"
		return 1
	fi
	if ! [[ "$active" =~ $deb_re ]]; then
		engine_log "series suite rewrite: could not parse the deb line in ${list}"
		return 1
	fi

	local dtype="${BASH_REMATCH[1]}" opts="${BASH_REMATCH[3]}" url="${BASH_REMATCH[4]}"
	local suite="${BASH_REMATCH[5]}" comps="${BASH_REMATCH[6]}"
	opts="${opts#"${opts%%[![:space:]]*}"}"   # trim leading whitespace off the [..] group

	# suite = <codename>-<old-series>; the series carries a dot, the codename does
	# not, so the final '-' splits them. Validate the old series shape before use.
	local codename="${suite%-*}" old_series="${suite##*-}"
	if [[ -z "$codename" || "$codename" == "$suite" ]] \
		|| ! [[ "$old_series" =~ ^[0-9]+\.[0-9]+$ ]]; then
		engine_log "series suite rewrite: suite '${suite}' is not <codename>-<major.minor>"
		return 1
	fi
	local new_suite="${codename}-${REQ_SERIES}"
	if [[ ! -r "$security_list" ]]; then
		engine_log "series suite rewrite: ${security_list} is missing or unreadable"
		return 1
	fi
	local security_old="${suite}-security" security_new="${new_suite}-security"
	local security_active security_n=0
	while IFS= read -r line || [[ -n "$line" ]]; do
		[[ "$line" =~ ^[[:space:]]*deb([[:space:]]|-src) ]] || continue
		security_active="$line"; security_n=$((security_n + 1))
	done < "$security_list"
	if [[ "$security_n" -ne 1 || ! "$security_active" =~ $deb_re || "${BASH_REMATCH[5]}" != "$security_old" ]]; then
		engine_log "series suite rewrite: ${security_list} does not contain exactly the managed ${security_old} suite"
		return 1
	fi
	local security_dtype="${BASH_REMATCH[1]}" security_opts="${BASH_REMATCH[3]}"

	# Rebuild the file: the one deb line gets its suite field swapped; every other
	# line (comments, blanks) is copied verbatim.
	local tmp="${list}.shcp-series.$$"
	local security_tmp="${security_list}.shcp-series.$$"
	local new_deb
	if [[ -n "$opts" ]]; then
		new_deb="${dtype} ${opts} ${url} ${new_suite} ${comps}"
	else
		new_deb="${dtype} ${url} ${new_suite} ${comps}"
	fi
	local conf
	if conf="$(conf_bin)"; then
		# Structured edit: only the suite field changes; the operator's spacing,
		# option order and every other byte survive (shcp-base#1788). The suite
		# is value 2, or 3 after a [...] options group. Flags first, then `--`,
		# then the path and value: shcp-conf reads nothing after the path as a flag.
		local field=2 security_field=2
		[[ -n "$opts" ]] && field=3
		[[ -n "$security_opts" ]] && security_field=3
		"$conf" set -g apt-list --field "$field" -- "$dtype" "$new_suite" < "$list" > "$tmp" \
			|| { rm -f "$tmp"; engine_log "series suite rewrite: shcp-conf could not stage ${list}"; return 1; }
		"$conf" set -g apt-list --field "$security_field" -- "$security_dtype" "$security_new" < "$security_list" > "$security_tmp" \
			|| { rm -f "$tmp" "$security_tmp"; engine_log "series suite rewrite: shcp-conf could not stage ${security_list}"; return 1; }
	else
		# Engine without the bundled parser (a development tree): the legacy
		# rebuild, which normalizes the deb line's whitespace.
		{
			while IFS= read -r line || [[ -n "$line" ]]; do
				if [[ "$line" == "$active" ]]; then
					printf '%s\n' "$new_deb"
				else
					printf '%s\n' "$line"
				fi
			done < "$list"
		} > "$tmp" || { rm -f "$tmp"; engine_log "series suite rewrite: failed writing ${tmp}"; return 1; }
		awk -v old="$security_old" -v new="$security_new" '
			$1 ~ /^deb(-src)?$/ {for (i=2; i<=NF; i++) if ($i == old) {$i=new; changed++}}
			{print}
			END {if (changed != 1) exit 1}' "$security_list" > "$security_tmp" \
			|| { rm -f "$tmp" "$security_tmp"; engine_log "series suite rewrite: failed staging ${security_list}"; return 1; }
	fi

	# SC-029 parse-back on BOTH tmps (live files still untouched): each written
	# deb line must re-tokenize to exactly the intended suite — <codename>-
	# <REQ_SERIES> for shcp.list, and its -security twin for shcp-security.list.
	# The security binding is published in the same step, so it gets the same
	# proof; a staged file that fails it never reaches the live one.
	local written_suite
	if ! written_suite="$(series_parse_back_suite "$tmp" "$deb_re")" \
		|| [[ "$written_suite" != "$new_suite" ]]; then
		rm -f "$tmp" "$security_tmp"
		engine_log "series suite rewrite: parse-back failed (wrote suite '${written_suite}', wanted '${new_suite}') — leaving ${list} untouched (SC-029)"
		return 1
	fi
	if ! written_suite="$(series_parse_back_suite "$security_tmp" "$deb_re")" \
		|| [[ "$written_suite" != "$security_new" ]]; then
		rm -f "$tmp" "$security_tmp"
		engine_log "series suite rewrite: parse-back failed (wrote suite '${written_suite}', wanted '${security_new}') — leaving ${list} and ${security_list} untouched (SC-029)"
		return 1
	fi

	series_latch_flip_intent
	# The pre-flip copy is staged in the RUN's directory, never beside the live
	# file: a kill between this cp and the rm below used to leave
	# shcp.list.pre-series.<pid> in sources.list.d, which apt then warned about
	# ("invalid filename extension") on every run, forever. Under the run dir a
	# leftover is swept by run retention and apt never sees it. Same filesystem
	# is not required — this copy is restored with mv only on failure, and a
	# cross-device mv is a copy.
	local backup_dir="${RUNS_DIR}/${CURRENT_RUN_ID:-}"
	[[ -n "${CURRENT_RUN_ID:-}" && -d "$backup_dir" ]] || backup_dir="$(mktemp -d)" || { rm -f "$tmp" "$security_tmp"; return 1; }
	local backup="${backup_dir}/shcp.list.pre-series"
	cp -p "$list" "$backup" || { rm -f "$tmp" "$security_tmp"; return 1; }
	if ! mv -f "$tmp" "$list"; then
		rm -f "$tmp" "$security_tmp" "$backup"
		engine_log "series suite rewrite: could not rename ${tmp} into ${list}"
		return 1
	fi
	if ! mv -f "$security_tmp" "$security_list"; then
		mv -f "$backup" "$list" 2>/dev/null
		rm -f "$security_tmp"
		engine_log "series suite rewrite: could not rename ${security_tmp} into ${security_list}; restored ${list}"
		return 1
	fi
	rm -f "$backup"
	engine_log "series suite rewrite: normal and security bindings retargeted ${suite} -> ${new_suite}"
	return 0
}

# series_rerender_pins — re-render the per-daemon Priority-1001 apt pins for the
# TARGET series by sourcing the SHARED installer-shipped writer (apt_render_lib.sh
# = the real apply_template) + renderer (apt_pins.sh), NOT a second copy (F6).
#
# The 5 daemon series come from the signed manifest's daemon_pins map for
# REQ_SERIES (arch decision A). SC-472 already guarantees that manifest is
# gpgv-valid and not a replay before we read a single field from it. F5: a series
# run whose TARGET lacks a well-formed daemon_pins map REFUSES here — this is the
# point of use, so routine (non-series) manifest consumption is unaffected. The
# renderer itself re-validates the series grammar and does the SC-029 parse-back
# + SC-078 add-then-remove pin discipline.
series_rerender_pins() {
	if [[ "$OS_FAMILY" != "deb" ]]; then
		engine_log "series pin re-render: deb-only (apt pins); refusing on an ${OS_FAMILY} host"
		return 1
	fi

	# Read from the run's verified manifest copy (preflight kept it); re-fetch only
	# if a resumed run's copy is gone — a failure there is a real failure.
	local manifest="${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json" own=0
	if [[ ! -s "$manifest" ]]; then
		manifest="$(mktemp)" || return 1
		own=1
		if ! manifest_fetch "$manifest"; then
			rm -f "$manifest"
			engine_log "series pin re-render: manifest unavailable or unverifiable"
			return 1
		fi
	fi

	local dp
	dp="$(jq -c --arg s "$REQ_SERIES" \
		'[.series[]? | select(.series == $s)] | first | .daemon_pins // empty' \
		"$manifest" 2>/dev/null || true)"
	[[ $own -eq 1 ]] && rm -f "$manifest"

	# F5: the series-run TARGET MUST carry a well-formed daemon_pins map. A target
	# whose manifest entry predates the field (S1's transitional generator) cannot
	# be a series-upgrade target — refuse rather than pin daemons to a guessed set.
	if [[ -z "$dp" || "$dp" == "null" ]]; then
		engine_log "series pin re-render: manifest has no daemon_pins for target series ${REQ_SERIES} — refusing (F5, series upgrade needs the signed daemon-series map)"
		return 1
	fi

	local php valkey dovecot rspamd pdns key val
	php="$(jq -r '.php // empty'     <<<"$dp")"
	valkey="$(jq -r '.valkey // empty'  <<<"$dp")"
	dovecot="$(jq -r '.dovecot // empty' <<<"$dp")"
	rspamd="$(jq -r '.rspamd // empty'  <<<"$dp")"
	pdns="$(jq -r '.pdns // empty'    <<<"$dp")"
	for key in php valkey dovecot rspamd pdns; do
		val="${!key}"
		if ! [[ "$val" =~ ^[0-9]+\.[0-9]+$ ]]; then
			engine_log "series pin re-render: daemon_pins.${key}='${val}' is missing or malformed for series ${REQ_SERIES} — refusing (F5)"
			return 1
		fi
	done

	local render_lib="${APT_RENDER_DIR}/apt_render_lib.sh"
	local renderer="${APT_RENDER_DIR}/apt_pins.sh"
	local tpl_dir="${APT_RENDER_DIR}/preferences.d"
	local pin_dir="${CONFIG_ROOT%/}/etc/apt/preferences.d"
	local f
	for f in "$render_lib" "$renderer"; do
		if [[ ! -r "$f" ]]; then
			engine_log "series pin re-render: ${f} is not readable — shcp-installer must ship /usr/share/shcp/apt (F6)"
			return 1
		fi
	done
	if [[ ! -d "$tpl_dir" ]]; then
		engine_log "series pin re-render: template dir ${tpl_dir} is missing"
		return 1
	fi
	mkdir -p "$pin_dir" 2>/dev/null || true

	# Source the shipped writer + renderer in a SUBSHELL so their function
	# definitions (apply_template, log_*) never leak into the engine, and any
	# die/`set -e` abort inside a render fails only the subshell — the parent reads
	# it as a non-zero return and fails the stage cleanly.
	if ! (
		# shellcheck disable=SC1090
		source "$render_lib"
		# shellcheck disable=SC1090
		source "$renderer"
		render_shcp_apt_pins "$pin_dir" "$tpl_dir" \
			"$php" "$valkey" "$dovecot" "$rspamd" "$pdns"
	); then
		engine_log "series pin re-render: render_shcp_apt_pins failed for series ${REQ_SERIES}"
		return 1
	fi
	engine_log "series pin re-render: Priority-1001 pins rendered for ${REQ_SERIES} (php ${php}, valkey ${valkey}, dovecot ${dovecot}, rspamd ${rspamd}, pdns ${pdns})"
	return 0
}

# --- rpm series pointer (shcp-updater#159, SC-704) -
# On EL the series is not a token in a repo file. shcp.repo is shcp-release's
# static %config body (SC-493):
#     baseurl=$shcpbase/rpm/el$shcpel/$shcpseries/
# so the one thing that decides which series leaf a box reads is the dnf var file
# /etc/dnf/vars/shcpseries. Rewriting shcp.repo instead would litter .rpmnew and let
# the next shcp-release upgrade silently rebind the series; a per-series repo id
# would be a second writer of the binding. The var is the only mechanism SC-493
# leaves, and series_rollback_restore_rpm already reverts exactly it.
#
# No pin re-render: shcp.repo's priority=10 (enforced by repo_binding_rpm_is_clean)
# masks every same-named package in EPEL/Remi/AppStream, and a leaf's contents are
# its series' version set. The manifest's daemon_pins map is a deb input.
#
# What a real dnf (4.20, AlmaLinux 10.2) does that shapes every step below:
#   - repo_gpgcheck keys live in a pubring inside the per-repo cache dir, and that
#     dir is named from a hash of the RESOLVED baseurl. Flipping the var therefore
#     lands on a cache dir with no key, and a non-interactive `dnf makecache` fails
#     ("Signing key not found") while `dnf check-update --setopt=skip_if_unavailable=True`
#     returns 0 with nothing listed — a failed flip reads as "already up to date".
#   - `--setopt=shcp.baseurl=<resolved target>` resolves to the SAME cache dir the
#     var will later resolve to, and fails on a missing or badly-signed leaf. So a
#     probe with that override proves the target leaf and primes its key BEFORE the
#     live var moves (DNF_VAR_* env overrides are ignored by dnf 4).
#   - every regular file in /etc/dnf/vars is read as a variable, dotfiles included;
#     directories are skipped. The write stages inside a dot-directory.

# series_rpm_repo_value <repo-file> <key> — the value of <key> inside the [shcp]
# stanza, whitespace-trimmed; empty when absent. Keys outside [shcp] never count.
series_rpm_repo_value() {
	awk -v want="$2" '
		/^[[:space:]]*\[/ { in_shcp = ($0 ~ /^[[:space:]]*\[shcp\][[:space:]]*$/); next }
		in_shcp {
			eq = index($0, "=")
			if (eq == 0) next
			k = substr($0, 1, eq - 1); v = substr($0, eq + 1)
			gsub(/^[[:space:]]+|[[:space:]]+$/, "", k); gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
			if (tolower(k) == want) { print v; exit }
		}' "$1" 2>/dev/null || true
}

# series_rpm_read_var <file> — the first line of a dnf var file, CR-stripped; empty
# when missing, a symlink, or not a regular file.
series_rpm_read_var() {
	[[ -f "$1" && ! -L "$1" ]] || return 0
	head -n1 -- "$1" 2>/dev/null | tr -d '\r' || true
}

# series_dnf_key_pinned <armored-key-file> — 0 iff the file holds exactly ONE
# primary key and its fingerprint is the pinned SHCP repo key. A series flip makes
# dnf import the repo key into a fresh cache dir, and the engine has to answer that
# import non-interactively (-y). That is only a trust decision — not trust on first
# use — when the file dnf imports from has just been proven to be the pinned key
# (the installer's SC-115 check, redone at the point of use). Fails closed without gpg.
series_dnf_key_pinned() {
	local keyfile="$1" gpg_home out npub fpr
	[[ -f "$keyfile" && ! -L "$keyfile" && -s "$keyfile" ]] || return 1
	command -v gpg >/dev/null 2>&1 || return 1
	gpg_home="$(mktemp -d)" || return 1
	out="$(gpg --homedir "$gpg_home" --batch --with-colons --show-keys -- "$keyfile" 2>/dev/null)" || out=""
	rm -rf "$gpg_home"
	npub="$(awk -F: '$1 == "pub" {n++} END {print n + 0}' <<<"$out")"
	fpr="$(awk -F: '$1 == "pub" {p = 1; next} p && $1 == "fpr" {print $10; exit}' <<<"$out")"
	[[ "$npub" == 1 && -n "$fpr" && "$fpr" == "$SHCP_REPO_GPG_FPR" ]]
}

# series_dnf_prime <resolved-baseurl> — make dnf fetch and verify the shcp repo at
# <url> (repomd.xml against the pinned key, repo_gpgcheck=1), importing the key
# into that URL's cache dir. Only the shcp repo is touched. --refresh: a cache dir
# left by an earlier attempt must never answer for a leaf withdrawn since. -y answers the key
# import; callers MUST have passed series_dnf_key_pinned first. Sets
# SERIES_DNF_PRIME_DETAIL to a clamped hint on failure.
SERIES_DNF_PRIME_DETAIL=""
series_dnf_prime() {
	local url="$1" out rc=0
	SERIES_DNF_PRIME_DETAIL=""
	out="$(dnf -y -q --refresh --disablerepo='*' --enablerepo=shcp \
		"--setopt=shcp.baseurl=${url}" --setopt=shcp.skip_if_unavailable=False \
		makecache </dev/null 2>&1)" || rc=$?
	if (( rc != 0 )); then
		SERIES_DNF_PRIME_DETAIL="exit ${rc}: $(printf '%s\n' "$out" | grep -E '^(Error|Curl|Status|repomd)' | head -n 2 | tr -cd '\11\12\40-\176' | tr '\n' ' ' || true)"
		SERIES_DNF_PRIME_DETAIL="${SERIES_DNF_PRIME_DETAIL% }"
		return 1
	fi
	return 0
}

# series_dnf_effective_baseurl — the baseurl dnf ACTUALLY resolves for the shcp
# repo from the live files, read from the cache the prime just filled (-C: no
# network). This is the parse-back of the flip: the var file saying B proves
# nothing if dnf is not reading it.
series_dnf_effective_baseurl() {
	dnf -q -C --disablerepo='*' --enablerepo=shcp repoinfo shcp </dev/null 2>/dev/null \
		| awk '/^Repo-baseurl[[:space:]]*:/ {sub(/^Repo-baseurl[[:space:]]*:[[:space:]]*/, ""); sub(/[[:space:]]+$/, ""); print; exit}' \
		|| true
}

# series_dnf_write_var <vars-dir> <series> — atomically replace <vars-dir>/shcpseries
# with one line. Staged in a dot-DIRECTORY inside <vars-dir> (same filesystem, so
# the mv is a rename(2), and the new file inherits the directory's SELinux label);
# a sibling tmp FILE would itself be read by dnf as a variable. Content is parsed
# back before the rename (SC-029); the live file is untouched on any failure.
series_dnf_write_var() {
	local vars="$1" val="$2" stage n back
	[[ -d "$vars" && ! -L "$vars" ]] || return 1
	stage="$(mktemp -d "${vars}/.shcp-series.XXXXXX" 2>/dev/null)" || return 1
	if ! printf '%s\n' "$val" > "${stage}/shcpseries" || ! chmod 0644 "${stage}/shcpseries"; then
		rm -rf "$stage"; return 1
	fi
	n="$(wc -l < "${stage}/shcpseries")"; n="${n//[[:space:]]/}"
	back="$(head -n1 "${stage}/shcpseries")"
	if [[ "$n" != 1 || "$back" != "$val" ]]; then
		rm -rf "$stage"; return 1
	fi
	if ! mv -f "${stage}/shcpseries" "${vars}/shcpseries"; then
		rm -rf "$stage"; return 1
	fi
	rm -rf "$stage"
	return 0
}

# repo_binding_rpm_is_clean <repo-file> -- the operator-safety GATE in front of
# the rpm series flip (series_rpm_binding). Returns 0 only when shcp.repo is
# cleanly the installer-written single-writer binding (SC-493): exactly one
# stanza [shcp]; every KEY is a managed SHCP key (no operator additions like
# proxy=/sslverify=, no extra [shcp-source]/[shcp-debuginfo] stanza); every
# fixed-value key that is PRESENT still carries its canonical VALUE; and gpgkey
# is exactly ONE entry, the file:// SHCP key. An operator-customised .repo is
# never flipped underneath the operator -- silently re-pointing a repo whose
# enabled=0 or proxy= says the operator is managing it is worse than refusing --
# and a foreign .repo merely sitting at this path is never touched.
#
# NAME/VALUE both matter, and only format-invariant values are pinned. A MISSING
# fixed-value key is tolerated (dnf defaults it); a PRESENT one whose value
# diverges from canonical is operator INTENT (fail closed). name and baseurl are
# unchecked here: series_rpm_binding separately demands the $shcpseries-routed
# baseurl. gpgkey must be exactly ONE entry: a same-line second key path
# (`gpgkey=file://...RPM-GPG-KEY-shcp https://evil/x.gpg`) would be accepted by
# dnf on first use, so >1 whitespace-split entry => not clean, and only the
# file:///etc/pki/rpm-gpg/RPM-GPG-KEY-shcp entry the installer writes is
# accepted (an http(s) gpgkey would be fetched and trusted on first use).
#
# The value-aware contract is shared with the installer's repo-binding writer
# (SC-493) and pinned by tests/fixtures/repo-binding/rpm-guard-cases. LC_ALL=C
# is part of that contract: only ASCII case folding and character classes are
# accepted, independent of the host locale.
repo_binding_rpm_is_clean() {
	[[ -f "$1" && -r "$1" ]] || return 1
	# AWK implementations disagree on embedded NUL handling (mawk can discard it
	# before a record-level check sees it), so reject it from the byte stream.
	# Capture both statuses: a failed od/grep must not look like "no NUL found".
	local -a nul_status
	if LC_ALL=C od -An -v -t x1 -- "$1" | LC_ALL=C grep -Eq '(^|[[:space:]])00([[:space:]]|$)'; then
		return 1
	else
		nul_status=("${PIPESTATUS[@]}")
		[[ "${nul_status[0]}" -eq 0 && "${nul_status[1]}" -eq 1 ]] || return 1
	fi
	LC_ALL=C awk '
		BEGIN { nstanza = 0; stanza = ""; ok = 1; gpgkeys = 0;
				allowed = " name baseurl enabled priority gpgcheck repo_gpgcheck module_hotfixes gpgkey " }
		/^[[:space:]]*(#|;)/ { next }
		/^[[:space:]]*$/     { next }
		/^[[:space:]]*\[/ {
			nstanza++
			s = $0; sub(/^[[:space:]]*\[/, "", s); sub(/\][[:space:]]*$/, "", s)
			stanza = s
			if (s != "shcp") ok = 0
			next
		}
		{
			if (stanza != "shcp") { ok = 0; next }
			line = $0
			eq = index(line, "=")
			if (eq == 0) { ok = 0; next }   # a key line with no = is malformed -> not clean
			key = substr(line, 1, eq - 1)
			val = substr(line, eq + 1)
			gsub(/^[[:space:]]+|[[:space:]]+$/, "", key); key = tolower(key)
			gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
			if (key == "" || index(allowed, " " key " ") == 0) ok = 0
			else if (++seen[key] != 1)                         ok = 0
			else if (key == "enabled"         && val != "1")  ok = 0
			else if (key == "priority"        && val != "10") ok = 0
			else if (key == "gpgcheck"        && val != "1")  ok = 0
			else if (key == "repo_gpgcheck"   && val != "1")  ok = 0
			else if (key == "module_hotfixes" && val != "1")  ok = 0
			else if (key == "gpgkey") {
				gpgkeys++
				n = split(val, parts, /[[:space:]]+/)
				if (n != 1) ok = 0
				else if (parts[1] != "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-shcp") ok = 0
			}
		}
		# Do not `exit 0` from END: that would mask an AWK input/tool error.
		END { if (nstanza != 1 || gpgkeys != 1) ok = 0; if (!ok) exit 1 }
	' "$1"
}

# series_rpm_binding <out-prefix> — validate the live rpm binding and resolve the
# series leaf URL for REQ_SERIES-shaped input. Refuses (returns 1, logged) when the
# flip could not take effect or could not be verified:
#   - shcp.repo is not the clean single-stanza binding, its baseurl does not route
#     through $shcpseries (a literal baseurl makes the var flip a silent no-op), or
#     its gpgkey is not the file:// SHCP key (an http(s) gpgkey would be fetched and
#     accepted on first use);
#   - a var is missing or malformed;
#   - the key file is not the pinned SHCP repo key.
# On success sets SERIES_RPM_BASE, SERIES_RPM_EL, SERIES_RPM_CUR.
SERIES_RPM_BASE="" SERIES_RPM_EL="" SERIES_RPM_CUR=""
series_rpm_binding() {
	SERIES_RPM_BASE="" SERIES_RPM_EL="" SERIES_RPM_CUR=""
	local root="${CONFIG_ROOT%/}"
	local repo="${root}/etc/yum.repos.d/shcp.repo" vars="${root}/etc/dnf/vars"
	local key="${root}/etc/pki/rpm-gpg/RPM-GPG-KEY-shcp"
	if ! repo_binding_rpm_is_clean "$repo"; then
		engine_log "series dnf re-point: ${repo} is missing or not the clean managed binding — refusing (SC-493)"
		return 1
	fi
	# Single-quoted: these are dnf variables, compared literally.
	# shellcheck disable=SC2016
	if [[ "$(series_rpm_repo_value "$repo" baseurl)" != '$shcpbase/rpm/el$shcpel/$shcpseries/' ]]; then
		engine_log "series dnf re-point: ${repo} baseurl does not route through \$shcpseries — flipping the var would change nothing; refusing (repair the binding first)"
		return 1
	fi
	if [[ "$(series_rpm_repo_value "$repo" gpgkey)" != 'file:///etc/pki/rpm-gpg/RPM-GPG-KEY-shcp' ]]; then
		engine_log "series dnf re-point: ${repo} gpgkey is not file:///etc/pki/rpm-gpg/RPM-GPG-KEY-shcp — refusing to accept a repo key from anywhere else"
		return 1
	fi
	local b e c
	b="$(series_rpm_read_var "${vars}/shcpbase")"
	e="$(series_rpm_read_var "${vars}/shcpel")"
	c="$(series_rpm_read_var "${vars}/shcpseries")"
	if ! [[ "$b" =~ ^https?://[^[:space:]$]+$ && "$b" != */ ]] \
		|| ! [[ "$e" =~ ^[0-9]+$ ]] || ! [[ "$c" =~ ^[0-9]+\.[0-9]+$ ]]; then
		engine_log "series dnf re-point: ${vars}/{shcpbase,shcpel,shcpseries} missing or malformed — refusing"
		return 1
	fi
	if ! series_dnf_key_pinned "$key"; then
		engine_log "series dnf re-point: ${key} is not exactly the pinned SHCP repo key ${SHCP_REPO_GPG_FPR} — refusing to import a repo key non-interactively"
		return 1
	fi
	SERIES_RPM_BASE="$b" SERIES_RPM_EL="$e" SERIES_RPM_CUR="$c"
	return 0
}

# series_rewrite_dnf_series — the rpm twin of series_rewrite_suite: re-point the box
# at the REQ_SERIES leaf. Prove, then flip, then parse back:
#   1. binding + key validated (series_rpm_binding);
#   2. the target leaf is fetched and verified through a baseurl override, which
#      also primes the key in the exact cache dir the var will resolve to — a
#      missing/unsigned leaf refuses here with the live var untouched;
#   3. shcpseries rewritten atomically (series_dnf_write_var);
#   4. dnf's own resolved baseurl must be the probed URL, or the old value is put
#      back and the stage refuses.
# Idempotent: a resumed run whose var already says REQ_SERIES re-proves and returns
# 0 without rewriting. Sets SERIES_DNF_FROM / SERIES_DNF_URL for the journal latch.
SERIES_DNF_FROM="" SERIES_DNF_URL=""
series_rewrite_dnf_series() {
	SERIES_DNF_FROM="" SERIES_DNF_URL=""
	if [[ "$OS_FAMILY" != "rpm" ]]; then
		engine_log "series dnf re-point: rpm-only; refusing on an ${OS_FAMILY} host"
		return 1
	fi
	if ! [[ "$REQ_SERIES" =~ ^[0-9]+\.[0-9]+$ ]]; then
		engine_log "series dnf re-point: target series '${REQ_SERIES}' is malformed (want MAJOR.MINOR)"
		return 1
	fi
	series_rpm_binding || return 1
	local vars="${CONFIG_ROOT%/}/etc/dnf/vars"
	local url="${SERIES_RPM_BASE}/rpm/el${SERIES_RPM_EL}/${REQ_SERIES}/"
	local cur="$SERIES_RPM_CUR"

	if ! series_dnf_prime "$url"; then
		engine_log "series dnf re-point: target leaf ${url} did not fetch/verify (${SERIES_DNF_PRIME_DETAIL:-no detail}) — shcpseries left at ${cur}"
		return 1
	fi

	if [[ "$cur" != "$REQ_SERIES" ]]; then
		series_latch_flip_intent
		if ! series_dnf_write_var "$vars" "$REQ_SERIES"; then
			engine_log "series dnf re-point: could not write ${vars}/shcpseries — left at ${cur}"
			return 1
		fi
	fi

	local eff
	eff="$(series_dnf_effective_baseurl)"
	if [[ "$eff" != "$url" ]]; then
		if [[ "$cur" != "$REQ_SERIES" ]]; then
			series_dnf_write_var "$vars" "$cur" \
				|| engine_log "series dnf re-point: could NOT restore ${vars}/shcpseries to ${cur}"
		fi
		engine_log "series dnf re-point: dnf resolves the shcp repo to '${eff}', not ${url} — refusing (SC-029 parse-back)"
		return 1
	fi

	SERIES_DNF_FROM="$cur" SERIES_DNF_URL="$url"
	engine_log "series dnf re-point: shcpseries ${cur} -> ${REQ_SERIES} (${url}, repo metadata verified before the flip)"
	return 0
}

# series_rpm_snapshot_url <config_tar> — the shcp leaf URL the run's pre-upgrade
# config snapshot points at, echoed. The live binding and key must still pass
# series_rpm_binding (same trust preconditions as the forward flip); the series
# comes from the snapshot, and base/major from the snapshot when it carries them,
# else from the live vars. Non-zero when any part is missing or malformed.
series_rpm_snapshot_url() {
	local ctar="$1" member v base el series
	[[ -n "$ctar" && -f "$ctar" ]] || return 1
	series_rpm_binding || return 1
	base="$SERIES_RPM_BASE" el="$SERIES_RPM_EL" series=""
	for member in etc/dnf/vars/shcpseries ./etc/dnf/vars/shcpseries; do
		v="$(tar xzf "$ctar" -O -- "$member" 2>/dev/null | head -n1 | tr -d '\r' || true)"
		[[ -n "$v" ]] && { series="$v"; break; }
	done
	for member in etc/dnf/vars/shcpbase ./etc/dnf/vars/shcpbase; do
		v="$(tar xzf "$ctar" -O -- "$member" 2>/dev/null | head -n1 | tr -d '\r' || true)"
		[[ -n "$v" ]] && { base="$v"; break; }
	done
	for member in etc/dnf/vars/shcpel ./etc/dnf/vars/shcpel; do
		v="$(tar xzf "$ctar" -O -- "$member" 2>/dev/null | head -n1 | tr -d '\r' || true)"
		[[ -n "$v" ]] && { el="$v"; break; }
	done
	[[ "$series" =~ ^[0-9]+\.[0-9]+$ && "$el" =~ ^[0-9]+$ ]] || return 1
	[[ "$base" =~ ^https?://[^[:space:]$]+$ && "$base" != */ ]] || return 1
	printf '%s' "${base}/rpm/el${el}/${series}/"
}

# series_latch_flip_intent — journal .series.repoint_started IMMEDIATELY before the
# live pointer write (apt shcp.list rename / dnf shcpseries rename). The completion
# latch (.series.suite_rewritten) is written by stage_apt only after the whole
# re-point returns, and between the two sit a dnf/apt call that can block on the
# package-manager lock and a kill (unit timeout, OOM, power) — a crash there left a
# box on the new series' repo with no latch, and `rollback` would not revert it.
# No-op outside a run (sourced tests drive the rewrite functions without a journal).
series_latch_flip_intent() {
	[[ -n "${CURRENT_RUN_ID:-}" && -f "$(journal_path "$CURRENT_RUN_ID")" ]] || return 0
	journal_update "$CURRENT_RUN_ID" \
		'.series = ((.series // {}) + {repoint_started: true, target: $s})' --arg s "$REQ_SERIES"
}

# series_repoint_latched <journal> — 0 iff the run re-pointed the package manager
# at a new series, or had begun to: the one signal both auto_rollback's work test
# and the rollback's series branch read.
series_repoint_latched() {
	jq -e '(.series.suite_rewritten // false) == true or (.series.repoint_started // false) == true' \
		"$1" >/dev/null 2>&1
}

# --- held SHCP packages (updater#159 user decision 2026-09-16) ------------------
# A series run is refused while the operator has pinned an installed SHCP package
# in place: it would silently stay on the old series ("Nothing to do", exit 0) and
# a rollback could not settle it back.
#
# rpm: dnf's OWN semantics, never a re-implementation. Two rounds of break-it on
# EL10 showed every hand-written matcher both missing holds dnf honours (globbed
# and `!` versionlock entries, empty-prefix exclude globs, includepkgs globs, a
# plugin conf with no enabled= key) and false-blocking ones it ignores
# (`exclude=shcp*-debuginfo`, an uppercase EXCLUDE= key). So:
#   - excludes/includepkgs: load the shcp repo twice through the dnf API, with
#     dnf.conf's excludes applied and with them disabled, and compare what each
#     offers per SHCP name — a difference is a hold;
#   - versionlock: dnf repoquery does not run the plugin's filter (measured), so
#     replay the plugin's own algorithm — hawkey Subject NEVRA forms, locked names
#     as globs, every other available version of a locked name excluded, `!`
#     entries excluded — and only when dnf itself reports the plugin loaded.
# The sack is the TARGET series leaf in a series preflight (its metadata primed
# first) — a lock on the installed version hides nothing in the current leaf but
# everything in the next — and the current leaf in `check --blockers`, which is
# offline and does not know the target (a hold that only hides a future version
# is then caught at preflight). Cache-only, and it never waits on dnf's lock.

# series_rpm_versionlock_loaded — dnf itself loads the versionlock plugin (its
# conf, its enabled= default, dnf.conf plugins=, pluginconfpath — all dnf's call).
series_rpm_versionlock_loaded() {
	timeout 30 dnf -v -C --disablerepo='*' --setopt=exit_on_lock=True repolist </dev/null 2>/dev/null \
		| awk '/^Loaded plugins:/ { if ($0 ~ /[:,[:space:]]versionlock([,[:space:]]|$)/) f = 1 } END { exit !f }'
}

# series_rpm_held_names <target-baseurl-or-empty> <versionlock-loaded 0|1> —
# installed SHCP package names a hold keeps from moving, one per line. Exit 0 =
# evaluated (possibly nothing held); 4 = the shcp metadata could not be loaded
# while dnf.conf or the versionlock list carries hold-capable entries (the caller
# fails closed); 3 = the shcp repo is not configured.
series_rpm_held_names() {
	local url="$1" vl="$2"
	command -v python3 >/dev/null 2>&1 || return 4
	python3 -I - "$url" "$vl" 2>/dev/null <<'PY'
import sys
import dnf
import dnf.subject
import hawkey

url, vl_loaded = sys.argv[1], sys.argv[2] == "1"


class Versionlock(dnf.Plugin):
    name = "versionlock"


def base(disable_excludes):
    b = dnf.Base()
    b.conf.read()
    b.conf.substitutions.update_from_etc(b.conf.installroot)
    b.conf.cacheonly = True
    try:
        b.conf.exit_on_lock = True
    except Exception:
        pass
    if disable_excludes:
        b.conf.disable_excludes = ["all"]
    b.read_all_repos()
    shcp = b.repos.get("shcp")
    if shcp is None or not shcp.enabled:
        sys.exit(3)
    for r in list(b.repos.iter_enabled()):
        if r.id != "shcp":
            r.disable()
    if url:
        shcp.baseurl = [url]
    shcp.skip_if_unavailable = False
    b.fill_sack(load_system_repo=True, load_available_repos=True)
    return b


def lock_lines(conf):
    try:
        cp = Versionlock.read_config(conf)
        path = cp.get("main", "locklist")
    except Exception:
        return []
    out = []
    try:
        with open(path) as fh:
            for line in fh:
                line = line.strip()
                if line and not line.startswith("#"):
                    out.append(line)
    except OSError:
        pass
    return out


try:
    allb = base(True)
    visb = base(False)
except SystemExit:
    raise
except Exception as e:
    conf = dnf.Base().conf
    conf.read()
    risky = bool(conf.excludepkgs) or bool(conf.includepkgs) or (vl_loaded and lock_lines(conf))
    if not risky:
        sys.exit(0)
    # 5: the shcp metadata is simply not cached yet (`dnf clean all`, a fresh
    # box before dnf-makecache.timer). Distinct from 4 so the offline `check`
    # does not report a hold it has no evidence of (shcp-updater#179).
    sys.exit(5 if (not url and "no cache" in str(e).lower()) else 4)

installed = {p.name for p in allb.sack.query().installed()}
names = {p.name for p in allb.sack.query().available()} & installed
held = set()


def offered(b, n):
    return {(p.evr, p.arch) for p in b.sack.query().available().filter(name=n)}


for n in names:
    if offered(allb, n) != offered(visb, n):
        held.add(n)

if vl_loaded and names:
    sack = allb.sack
    empty = sack.query().filter(empty=True)
    locked_q, excl_q, locked_names = empty, empty, set()
    # A line-for-line replay of dnf-plugins-core versionlock.py sack() (EL10
    # python3-dnf-plugin-versionlock 4.7): same Subject forms, same first-match
    # break, same obsoleter exclusion. Keep it in step with that file.
    for pat in lock_lines(allb.conf):
        excl = 0
        if pat and pat[0] == "!":
            pat = pat[1:]
            excl = 1
        poss = dnf.subject.Subject(pat).get_nevra_possibilities(
            forms=[hawkey.FORM_NEVRA, hawkey.FORM_NEVR, hawkey.FORM_NEV,
                   hawkey.FORM_NA, hawkey.FORM_NAME])
        for nevra in poss:
            q = nevra.to_query(sack)
            if excl:
                excl_q = excl_q.union(q)
            else:
                locked_names.add(nevra.name)
                locked_q = locked_q.union(q)
            if q:
                break
    if locked_names:
        other = sack.query().filter(name__glob=list(locked_names)).difference(locked_q)
        excl_q = excl_q.union(other)
        obsoleters = sack.query().filterm(obsoletes=locked_q)
        excl_q = excl_q.union(obsoleters.difference(locked_q))
    excl_q.filterm(reponame__neq=hawkey.SYSTEM_REPO_NAME)
    for pkg in excl_q:
        if pkg.name in names:
            held.add(pkg.name)

for n in sorted(held):
    print(n)
PY
}

# series_blacklisted_shcp_pkgs [target] — installed SHCP packages on the panel's
# update.blacklist, one per line. Non-zero (with the installed blacklisted names on
# stdout) when whether they are SHCP packages cannot be decided — fail closed.
# rpm asks the (primed) target leaf, deb the SHCP list files.
series_blacklisted_shcp_pkgs() {
	local target="${1:-}" p names=""
	local -a inst=()
	while IFS= read -r p; do
		[[ -n "$p" ]] && osf_pkg_installed "$p" && inst+=("$p")
	done < <(blacklist_pkgs)
	(( ${#inst[@]} > 0 )) || return 0
	case "$OS_FAMILY" in
		deb)
			if ! names="$(series_shcp_pkg_names)" || [[ -z "$names" ]]; then
				printf '%s\n' "${inst[@]}"; return 1
			fi ;;
		rpm)
			local -a o=(-q -C --disablerepo='*' --enablerepo=shcp --setopt=exit_on_lock=True)
			[[ -n "$target" ]] && o+=("--setopt=shcp.baseurl=${target}")
			if ! names="$(timeout 60 dnf "${o[@]}" repoquery --qf '%{name}\n' -- "${inst[@]}" </dev/null 2>/dev/null)"; then
				printf '%s\n' "${inst[@]}"; return 1
			fi ;;
		*) return 0 ;;
	esac
	LC_ALL=C comm -12 <(printf '%s\n' "${inst[@]}" | LC_ALL=C sort -u) <(printf '%s\n' "$names" | awk 'NF' | LC_ALL=C sort -u) || true
	return 0
}

# series_deb_held_pkgs — installed packages dpkg has on hold (`apt-mark hold`).
# Non-zero when apt-mark could not answer (a locked or broken dpkg database, a
# hung apt-mark past the 30 s bound). It used to `|| true` that into "no holds",
# so a series jump proceeded past the one check whose whole point is that a
# held SHCP package makes the jump half-happen and un-rollback-able; the gate
# fails CLOSED on it instead (series_held_shcp_check).
series_deb_held_pkgs() {
	local out rc=0
	out="$(timeout 30 apt-mark showhold 2>/dev/null)" || rc=$?
	if (( rc != 0 )); then
		engine_log "series holds: apt-mark showhold failed (exit ${rc}) — the hold state of installed packages could not be evaluated"
		return 1
	fi
	printf '%s\n' "$out" | awk 'NF {print $1}' | LC_ALL=C sort -u
}

# --- deb: operator apt pins (shcp-updater#179) ----------------------------------
# `apt-mark hold` is not the only way to keep apt from moving a package: a
# preferences stanza (`Pin: version 0.0.*` + a high Pin-Priority, a negative
# priority, a release pin) does it too, and a series jump then leaves the package
# behind exactly as a hold does. Same lesson as the rpm side: never re-implement
# apt's pin semantics. Ask apt for each installed SHCP package's Candidate twice —
# under the box's real preferences, and under the SHCP-managed pin files ALONE —
# and call a package pinned when the two answers differ. The managed files (the
# installer's Priority-1001 daemon pins, re-rendered per series by the engine) sit
# on both sides, so only an operator's preference can make a difference.
#
# The index the question is asked of matters exactly as on rpm: a pin to the
# installed version hides nothing in the current suite and everything in the next.
# `check` (offline) asks the current index; the series preflight fetches the TARGET
# suite of shcp.list into a private lists dir and asks that
# (series_deb_target_index). Nothing here touches the live apt state.

# series_deb_managed_pin_names — basenames of the preferences.d files SHCP renders
# (from the installer-shipped templates; `shcp-*` when those are not installed).
series_deb_managed_pin_names() {
	local tpl_dir="${APT_RENDER_DIR}/preferences.d" t
	if compgen -G "${tpl_dir}/*.template" >/dev/null 2>&1; then
		for t in "${tpl_dir}"/*.template; do
			t="${t##*/}"; printf '%s\n' "${t%.template}"
		done
	else
		printf '%s\n' 'shcp-*'
	fi
}

# series_deb_operator_prefs_present — 0 when apt reads any preference the SHCP
# pin set does not account for: a non-empty /etc/apt/preferences, or a file in
# preferences.d apt would parse (no extension or .pref, not a managed name).
series_deb_operator_prefs_present() {
	local root="${CONFIG_ROOT%/}" f b m managed
	[[ -s "${root}/etc/apt/preferences" ]] && return 0
	managed="$(series_deb_managed_pin_names)"
	for f in "${root}/etc/apt/preferences.d"/*; do
		[[ -f "$f" ]] || continue
		b="${f##*/}"
		# apt's own filter for preferences.d parts: [A-Za-z0-9_.-] with no
		# extension or .pref (apt-pkg/policy.cc).
		[[ "$b" =~ ^[A-Za-z0-9_.-]+$ && "$b" != .* ]] || continue
		[[ "$b" != *.* || "$b" == *.pref ]] || continue
		local is_managed=0
		while IFS= read -r m; do
			# shellcheck disable=SC2053  # the managed name may be the shcp-* glob
			[[ -n "$m" && "$b" == $m ]] && { is_managed=1; break; }
		done <<<"$managed"
		[[ $is_managed -eq 1 ]] || return 0
	done
	return 1
}

# series_deb_policy_candidates <managed-only 0|1> <index-opts-file-or-empty> <pkg>... —
# "name<TAB>installed<TAB>candidate" per queried package, from apt-cache policy.
series_deb_policy_candidates() {
	local managed_only="$1" idx="$2"; shift 2
	local -a opts=()
	if [[ -n "$idx" ]]; then
		local o
		while IFS= read -r o; do [[ -n "$o" ]] && opts+=(-o "$o"); done < "$idx"
	fi
	local tmp="" rc=0 out
	if [[ "$managed_only" == 1 ]]; then
		tmp="$(mktemp -d)" || return 1
		: > "${tmp}/preferences"
		mkdir -p "${tmp}/preferences.d"
		local root="${CONFIG_ROOT%/}" m f
		while IFS= read -r m; do
			[[ -n "$m" ]] || continue
			for f in "${root}/etc/apt/preferences.d"/$m; do
				[[ -f "$f" ]] && cp -- "$f" "${tmp}/preferences.d/"
			done
		done < <(series_deb_managed_pin_names)
		opts+=(-o "Dir::Etc::Preferences=${tmp}/preferences" -o "Dir::Etc::PreferencesParts=${tmp}/preferences.d")
	fi
	out="$(timeout 60 apt-cache "${opts[@]}" policy "$@" 2>/dev/null)" || rc=$?
	[[ -n "$tmp" ]] && rm -rf "$tmp"
	(( rc == 0 )) || return 1
	awk '
		/^[^[:space:]].*:$/ { name = substr($0, 1, length($0) - 1); inst = ""; next }
		/^[[:space:]]+Installed:/ { inst = $2; next }
		/^[[:space:]]+Candidate:/ { if (name != "") print name "\t" inst "\t" $2; name = "" }
	' <<<"$out"
}

# series_deb_pin_held_pkgs <index-opts-file-or-empty> — installed SHCP packages an
# operator preference keeps apt from moving, one per line. Exit 0 = evaluated; 1 =
# could not be evaluated (unreadable SHCP index or apt-cache failure) while operator
# preferences exist — the caller fails closed.
series_deb_pin_held_pkgs() {
	local idx="${1:-}" names real managed
	series_deb_operator_prefs_present || return 0
	names="$(series_shcp_pkg_names)" && [[ -n "$names" ]] || return 1
	# shellcheck disable=SC2086  # names are apt package names, one per line
	real="$(series_deb_policy_candidates 0 "$idx" $names)" || return 1
	# shellcheck disable=SC2086
	managed="$(series_deb_policy_candidates 1 "$idx" $names)" || return 1
	awk -F'\t' '
		NR == FNR { cand[$1] = $3; next }
		$2 != "" && $2 != "(none)" && ($1 in cand) && cand[$1] != $3 { print $1 }
	' <(printf '%s\n' "$managed") <(printf '%s\n' "$real") | LC_ALL=C sort -u
}

# series_deb_target_index <dir> — fetch ONLY the target series suite of shcp.list
# into <dir> (a private sources list and lists dir) and write the apt -o options
# that read it to <dir>/opts. The live sources, lists and preferences are never
# written. Non-zero when the line cannot be parsed or the suite does not fetch.
series_deb_target_index() {
	local dir="$1" list="${CONFIG_ROOT%/}/etc/apt/sources.list.d/shcp.list"
	[[ "$REQ_SERIES" =~ ^[0-9]+\.[0-9]+$ && -r "$list" ]] || return 1
	local deb_re='^[[:space:]]*(deb)([[:space:]]+\[[^]]*\])?[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+(.+)$'
	local line tline="" n=0 lopts lurl lsuite lcomps codename old
	while IFS= read -r line || [[ -n "$line" ]]; do
		[[ "$line" =~ ^[[:space:]]*# ]] && continue
		if [[ "$line" =~ $deb_re ]]; then
			lopts="${BASH_REMATCH[2]}" lurl="${BASH_REMATCH[3]}" lsuite="${BASH_REMATCH[4]}" lcomps="${BASH_REMATCH[5]}"
			codename="${lsuite%-*}" old="${lsuite##*-}"
			[[ -n "$codename" && "$codename" != "$lsuite" && "$old" =~ ^[0-9]+\.[0-9]+$ ]] || return 1
			lopts="${lopts#"${lopts%%[![:space:]]*}"}"
			tline="deb ${lopts:+${lopts} }${lurl} ${codename}-${REQ_SERIES} ${lcomps}"
			n=$((n + 1))
		fi
	done < "$list"
	[[ "$n" -eq 1 ]] || return 1
	mkdir -p "${dir}/lists/partial" "${dir}/sourceparts" || return 1
	# apt downloads as the sandbox user _apt: it must reach lists/partial through
	# the mktemp dir (0700). Without this apt falls back to an unsandboxed download
	# and warns, which Error-Mode=any turns into a failed fetch (measured, trixie).
	chmod 0711 "$dir" || return 1
	if id _apt >/dev/null 2>&1; then
		chown -R _apt:root "${dir}/lists" 2>/dev/null || true
	fi
	printf '%s\n' "$tline" > "${dir}/sources.list" || return 1
	printf '%s\n' "Dir::Etc::SourceList=${dir}/sources.list" "Dir::Etc::SourceParts=${dir}/sourceparts" \
		"Dir::State::Lists=${dir}/lists" "Dir::Cache::pkgcache=" "Dir::Cache::srcpkgcache=" > "${dir}/opts" || return 1
	local -a o=()
	while IFS= read -r line; do o+=(-o "$line"); done < "${dir}/opts"
	# This private index only asks apt's policy engine a question. Noble still
	# runs the host's update hooks when SourceList and Lists point at scratch;
	# command-not-found, Ubuntu Pro and operator hooks must not mistake the probe
	# for a real system refresh. Keep the real preferences, but CLEAR the hook
	# lists. That takes a config file: `-o APT::Update::Pre-Invoke::=` does not
	# clear anything — apt.conf's `::` APPENDS an element (an empty one, which
	# apt then ignores), so the host's hooks all still ran. `#clear` is the
	# only form that empties a list, and it exists only in a config FILE, read
	# after the defaults with -c (which reads the default config first, then this).
	printf '%s\n' '#clear APT::Update::Pre-Invoke;' '#clear APT::Update::Post-Invoke;' \
		'#clear APT::Update::Post-Invoke-Success;' > "${dir}/apt.conf" || return 1
	timeout 180 apt-get -c "${dir}/apt.conf" "${APT_LOCK_OPT[@]}" -o Acquire::Retries=0 \
		-o APT::Update::Error-Mode=any \
		"${o[@]}" update -qq </dev/null >/dev/null 2>&1
}

# series_shcp_pkg_names — (deb) the package names the SHCP repo publishes, read
# from the LOCAL apt list files apt itself maps to the shcp.list source
# (`apt-get indextargets`, so apt's own URI-to-filename quoting applies). Non-zero
# when unreadable. rpm resolves names inside series_rpm_held_names.
series_shcp_pkg_names() {
	case "$OS_FAMILY" in
		deb)
			local list="${CONFIG_ROOT%/}/etc/apt/sources.list.d/shcp.list"
			local deb_re='^[[:space:]]*deb([[:space:]]+\[[^]]*\])?[[:space:]]+([^[:space:]]+)[[:space:]]'
			local line url=""
			[[ -r "$list" ]] || return 1
			while IFS= read -r line || [[ -n "$line" ]]; do
				if [[ "$line" =~ $deb_re ]]; then url="${BASH_REMATCH[2]}"; break; fi
			done < "$list"
			[[ -n "$url" ]] || return 1
			local -a apt_lists=()
			local uri f g
			while IFS='|' read -r uri f; do
				[[ -n "$f" && "${uri%/}" == "${url%/}" ]] && apt_lists+=("$f")
			# indextargets reads local state only (no lock, no fetch); the options keep
			# it inside the engine-wide apt call contract the hygiene suites enforce.
			done < <(timeout 30 apt-get "${APT_LOCK_OPT[@]}" -o Acquire::Retries=0 				indextargets --format '$(REPO_URI)|$(FILENAME)' 'Created-By: Packages' 2>/dev/null || true)
			(( ${#apt_lists[@]} > 0 )) || return 1
			local names
			names="$(for f in "${apt_lists[@]}"; do
				if [[ -f "$f" ]]; then
					cat -- "$f"
				else
					for g in "$f".*; do
						[[ -f "$g" ]] && { /usr/lib/apt/apt-helper cat-file "$g" 2>/dev/null || true; }
					done
				fi
			done | awk '/^Package:[[:space:]]/ {print $2}' | LC_ALL=C sort -u || true)"
			[[ -n "$names" ]] || return 1
			printf '%s\n' "$names" ;;
		*) return 1 ;;
	esac
}

# series_held_shcp_check [target-baseurl] — 0 when no hold pins an installed SHCP
# package; 1 when one does (or might), with SERIES_HELD_PKGS the names and
# SERIES_HELD_ORIGIN "shcp-repo" (evaluated) or "unconfirmed" (the SHCP index
# could not be evaluated while holds exist — fail closed: refusing a series jump
# costs a retry, a half-jumped box a recovery).
SERIES_HELD_PKGS="" SERIES_HELD_ORIGIN=""
series_held_shcp_check() {  # [target-baseurl] (rpm: evaluate against that leaf)
	SERIES_HELD_PKGS="" SERIES_HELD_ORIGIN=""
	local target_url="${1:-}" names="" origin="shcp-repo" held=""
	case "$OS_FAMILY" in
		deb)
			if ! held="$(series_deb_held_pkgs)"; then
				# Fail CLOSED: an unevaluable hold state is a refusal with its own
				# origin, so series_held_detail can say "could not evaluate" rather
				# than name packages it never saw.
				SERIES_HELD_ORIGIN="unevaluable" SERIES_HELD_PKGS="(apt-mark showhold failed)"
				return 1
			fi
			if [[ -n "$held" ]]; then
				if names="$(series_shcp_pkg_names)" && [[ -n "$names" ]]; then
					held="$(LC_ALL=C comm -12 <(printf '%s\n' "$held") <(printf '%s\n' "$names") || true)"
				else
					origin="unconfirmed"
				fi
			fi
			# Operator apt preferences (shcp-updater#179). A non-empty argument is
			# the series preflight: evaluate against the TARGET suite, fetched
			# privately; `check` passes none and asks the current index.
			if series_deb_operator_prefs_present; then
				local tdir="" idx="" pinned="" prc=0
				if [[ -n "$target_url" ]]; then
					tdir="$(mktemp -d)" || prc=1
					if (( prc == 0 )) && series_deb_target_index "$tdir"; then
						idx="${tdir}/opts"
					else
						prc=1
					fi
				fi
				if (( prc == 0 )); then
					pinned="$(series_deb_pin_held_pkgs "$idx")" || prc=1
				fi
				[[ -n "$tdir" ]] && rm -rf "$tdir"
				if (( prc != 0 )); then
					origin="unconfirmed"
					pinned="(apt could not evaluate the operator preferences against the SHCP index${target_url:+ of the target series})"
				fi
				if [[ -n "$pinned" ]]; then
					held="$(printf '%s\n%s\n' "$held" "$pinned" | awk 'NF' | LC_ALL=C sort -u)"
				fi
			fi ;;
		rpm)
			local vl=0 rc=0
			series_rpm_versionlock_loaded && vl=1
			held="$(series_rpm_held_names "$target_url" "$vl")" || rc=$?
			case "$rc" in
				0) ;;
				3) return 0 ;;   # no shcp repo on this box: nothing SHCP to hold
				5)
					# Offline evaluation only (no target leaf): hold-capable entries
					# exist but the shcp metadata is not cached, so there is nothing to
					# evaluate them against. Not a blocker — the series preflight primes
					# the target leaf and evaluates for real, failing closed there.
					# Reported so `check` can say it did not look.
					if [[ -z "$target_url" ]]; then
						SERIES_HELD_ORIGIN="uncached"
						return 0
					fi
					origin="unconfirmed"
					held="(dnf could not evaluate the shcp repo offline; dnf.conf or the versionlock list carries hold entries)" ;;
				*)
					origin="unconfirmed"
					held="(dnf could not evaluate the shcp repo offline; dnf.conf or the versionlock list carries hold entries)" ;;
			esac ;;
		*) return 0 ;;
	esac
	[[ -n "$held" ]] || return 0
	SERIES_HELD_ORIGIN="$origin" SERIES_HELD_PKGS="$held"
	return 1
}

# series_held_detail — the operator-facing refusal for series_held_shcp_check,
# naming the packages (first 20) and the family's release command.
series_held_detail() {
	local names how fix
	names="$(printf '%s\n' "$SERIES_HELD_PKGS" | awk 'NF && NR <= 20' | paste -sd' ' - || true)"
	case "$OS_FAMILY" in
		deb) how="an apt hold or an apt preferences pin"; fix="apt-mark unhold <package>, or remove the pin from /etc/apt/preferences(.d)" ;;
		*)   how="a dnf versionlock or dnf.conf exclude="; fix="dnf versionlock delete <package>, or remove it from exclude=" ;;
	esac
	if [[ "$SERIES_HELD_ORIGIN" == "unevaluable" ]]; then
		printf 'series upgrade blocked: could not evaluate holds — %s, so whether an installed SHCP package is pinned by %s is unknown (repair the package database, then retry)' "$names" "$how"
	elif [[ "$SERIES_HELD_ORIGIN" == "unconfirmed" ]]; then
		printf 'series upgrade blocked: installed package(s) pinned by %s cannot be ruled out as SHCP packages (the SHCP package index is unreadable): %s (release with: %s)' "$how" "$names" "$fix"
	else
		printf 'series upgrade blocked: SHCP package(s) pinned by %s would stay on the old series and could not settle back on a rollback: %s (release with: %s)' "$how" "$names" "$fix"
	fi
}

# --- post-apply series convergence (shcp-updater#179) ---------------------------
# series_convergence_check — after a series run's package apply: is every
# installed SHCP package at the version the (now re-pointed, refreshed) SHCP repo
# offers for the target series? Both families ask their package manager's own
# index, SHCP repo only:
#   rpm: dnf's API, cache only, every repo but shcp disabled; installed packages
#        are compared per arch (noarch counts for any) with hawkey's EVR order;
#   deb: `apt-cache madison`, rows from the shcp.list URL and the target suite
#        only, the highest by `dpkg --compare-versions`.
# BEHIND (installed older than the target offers) fails the check. A package the
# target no longer publishes, or one already newer than it offers, is a note: the
# jump cannot move either, and neither is a package left on the old series by a
# hold. Sets SERIES_CONVERGENCE_JSON {converged, behind[], notes[], reason}.
# Returns 0 converged, 1 behind, 2 could not evaluate (fail closed: a series jump
# the engine cannot verify is not one it bumps the floor over).
SERIES_CONVERGENCE_JSON='{}'
series_convergence_check() {
	local out="" rc=0 behind notes
	SERIES_CONVERGENCE_JSON='{}'
	case "$OS_FAMILY" in
		rpm) out="$(series_rpm_convergence_rows)" || rc=$? ;;
		deb) out="$(series_deb_convergence_rows)" || rc=$? ;;
		*)   rc=2 ;;
	esac
	if (( rc != 0 )); then
		SERIES_CONVERGENCE_JSON="$(jq -nc --arg s "$REQ_SERIES" --arg f "$OS_FAMILY" \
			'{converged: false, behind: [], notes: [],
			  reason: ("series " + $s + ": could not verify that the SHCP packages reached the target series (the " + $f + " SHCP index could not be evaluated) — refusing to call the series upgrade done")}')"
		return 2
	fi
	behind="$(awk -F'\t' '$1 == "B" {printf "%s %s (target %s)\n", $2, $3, $4}' <<<"$out")"
	notes="$(awk -F'\t' '$1 == "G" {printf "%s %s: not published by the target series\n", $2, $3} $1 == "N" {printf "%s %s: newer than the target series offers (%s)\n", $2, $3, $4}' <<<"$out")"
	SERIES_CONVERGENCE_JSON="$(jq -nc --arg s "$REQ_SERIES" --arg b "$behind" --arg n "$notes" '
		($b | split("\n") | map(select(length > 0))) as $behind
		| {converged: ($behind | length == 0), behind: $behind,
		   notes: ($n | split("\n") | map(select(length > 0)) | .[:50]),
		   reason: (if ($behind | length) == 0 then "" else
			"series " + $s + ": SHCP package(s) did not reach the target series: " + ($behind[:20] | join(", "))
			+ " — a hold, exclude or missing build left them behind; rolling the series upgrade back" end)}')"
	[[ -z "$behind" ]] || return 1
	return 0
}

# series_rpm_convergence_rows — B|G|N rows (tab-separated) for series_convergence_check.
series_rpm_convergence_rows() {
	command -v python3 >/dev/null 2>&1 || return 2
	timeout 120 python3 -I - 2>/dev/null <<'PY'
import sys
import dnf

b = dnf.Base()
b.conf.read()
b.conf.substitutions.update_from_etc(b.conf.installroot)
b.conf.cacheonly = True
# An exclude hides a published package; it must read as behind, not unpublished.
b.conf.disable_excludes = ["all"]
try:
    b.conf.exit_on_lock = True
except Exception:
    pass
try:
    b.read_all_repos()
    shcp = b.repos.get("shcp")
    if shcp is None or not shcp.enabled:
        sys.exit(2)
    for r in list(b.repos.iter_enabled()):
        if r.id != "shcp":
            r.disable()
    b.fill_sack(load_system_repo=True, load_available_repos=True)
except SystemExit:
    raise
except Exception:
    sys.exit(2)

avail = b.sack.query().available()
offered = {p.name for p in avail}
rows = []
for p in b.sack.query().installed():
    from_shcp = False
    try:
        from_shcp = p.from_repo == "shcp"
    except Exception:
        pass
    if p.name not in offered:
        if from_shcp:
            rows.append(("G", p.name, str(p.evr), ""))
        continue
    cands = [a for a in avail.filter(name=p.name) if a.arch == p.arch or a.arch == "noarch" or p.arch == "noarch"]
    if not cands:
        continue
    best = cands[0]
    for a in cands[1:]:
        if a.evr_gt(best):
            best = a
    if best.evr_gt(p):
        rows.append(("B", p.name, str(p.evr), str(best.evr)))
    elif p.evr_gt(best):
        rows.append(("N", p.name, str(p.evr), str(best.evr)))
for r in sorted(set(rows)):
    print("\t".join(r))
PY
}

# series_deb_convergence_rows — B|N rows (tab-separated) for series_convergence_check.
series_deb_convergence_rows() {
	local list="${CONFIG_ROOT%/}/etc/apt/sources.list.d/shcp.list"
	local deb_re='^[[:space:]]*deb([[:space:]]+\[[^]]*\])?[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]'
	local line url="" suite=""
	[[ -r "$list" ]] || return 2
	while IFS= read -r line || [[ -n "$line" ]]; do
		if [[ "$line" =~ $deb_re ]]; then url="${BASH_REMATCH[2]}" suite="${BASH_REMATCH[3]}"; break; fi
	done < "$list"
	[[ -n "$url" && "$suite" == *"-${REQ_SERIES}" ]] || return 2
	local names name inst best v
	names="$(series_shcp_pkg_names)" && [[ -n "$names" ]] || return 2
	while IFS= read -r name; do
		[[ -n "$name" ]] || continue
		[[ "$(dpkg-query -W -f='${Status}' "$name" 2>/dev/null)" == *"install ok installed"* ]] || continue
		inst="$(dpkg-query -W -f='${db:Status-Abbrev}\t${Architecture}\t${Version}\n' "$name" 2>/dev/null \
			| awk -F'\t' -v na="$(dpkg --print-architecture 2>/dev/null)" '$1 !~ /^ii/ {next} first == "" {first = $3} ($2 == na || $2 == "all") && pick == "" {pick = $3} END {print (pick != "" ? pick : first)}')" || continue
		[[ -n "$inst" ]] || continue
		best=""
		# madison: "name | version | <uri> <suite>/<component> <arch> Packages"
		while IFS= read -r v; do
			[[ -n "$v" ]] || continue
			if [[ -z "$best" ]] || dpkg --compare-versions "$v" gt "$best"; then best="$v"; fi
		done < <(timeout 60 apt-cache madison "$name" 2>/dev/null \
			| awk -F'|' -v u="${url%/}" -v s="$suite" '{
				gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); src = $3; gsub(/^[[:space:]]+/, "", src)
				split(src, f, /[[:space:]]+/); fu = f[1]; sub(/\/$/, "", fu)
				if (fu == u && index(f[2], s "/") == 1) print $2 }')
		[[ -n "$best" ]] || continue
		if dpkg --compare-versions "$inst" lt "$best"; then
			printf 'B\t%s\t%s\t%s\n' "$name" "$inst" "$best"
		elif dpkg --compare-versions "$inst" gt "$best"; then
			printf 'N\t%s\t%s\t%s\n' "$name" "$inst" "$best"
		fi
	done <<<"$names"
	return 0
}

# rollback_replaces_unrestored <pkg> <settle_allowed> [<name<TAB>from>...] — 0 when
# <pkg> Obsoletes or Provides the name of a prior package whose restore failed AND
# that cannot come back once <pkg> is gone (neither the exact prior nor, when a
# settle is allowed, any current version is in the enabled repos); sets
# ROLLBACK_REPLACED. A prior that CAN come back does not keep <pkg>: removing it is
# what lets the retry pass reinstall the prior. rpm only: deb has no
# rename-by-Obsoletes to strand.
ROLLBACK_REPLACED=""
rollback_replaces_unrestored() {
	local pkg="$1" settle="$2" fr identity name from caps
	shift 2
	ROLLBACK_REPLACED=""
	[[ "$OS_FAMILY" == "rpm" && $# -gt 0 ]] || return 1
	osf_valid_pkg "$pkg" || return 1
	caps="$( { rpm -q --obsoletes "$pkg" 2>/dev/null; rpm -q --provides "$pkg" 2>/dev/null; } | awk '{print $1}' | LC_ALL=C sort -u || true)"
	[[ -n "$caps" ]] || return 1
	for fr in "$@"; do
		identity="${fr%%$'\t'*}"; name="${identity%%|*}"; from="${fr#*$'\t'}"
		grep -qxF -- "$name" <<<"$caps" || continue
		osf_version_available "$name" "$from" && continue
		[[ "$settle" == true ]] && osf_repo_current_version "$name" >/dev/null 2>&1 && continue
		ROLLBACK_REPLACED="$name"
		return 0
	done
	return 1
}

# series_repoint — the family dispatcher stage_apt calls for a series run. Every
# family has an arm; the deb arm is exactly the UPD-9 calls.
series_repoint() {
	case "$OS_FAMILY" in
		deb) series_rewrite_suite && series_rerender_pins ;;
		rpm) series_rewrite_dnf_series ;;
		*)
			engine_log "series re-point: unsupported OS family '${OS_FAMILY}'"
			return 1 ;;
	esac
}

# series_rollback_restore_apt <config_tar> — the REVERSE of series_rewrite_suite +
# series_rerender_pins, and the load-bearing half of a cross-series rollback's
# safety net (SC-476). Restore the PRE-upgrade apt
# suite pointer + daemon pins from the run's config-tar so the package downgrades
# that follow resolve against the reverted series' repo instead of the new suite
# (where the prior debs are unfetchable).
#
# SELECTIVE — this is the whole point. config_tar_create archives four things:
# /etc/shcp*, /etc/apt/{sources.list,sources.list.d,preferences.d} and
# /etc/systemd/system. A naive full extract would clobber live installer config
# and unit files with a days-old snapshot. So ONLY these members are pulled:
#     etc/apt/sources.list.d/shcp.list            (the ordinary suite pointer)
#     etc/apt/sources.list.d/shcp-security.list   (the marked-security suite pointer)
#     etc/apt/preferences.d/shcp-*       (the Priority-1001 daemon pins)
# The set of shcp-* pin files is read from the tar's OWN listing, never guessed.
#
# SC-029 posture: extract into a private staging dir on the SAME filesystem as the
# targets, verify (non-empty; shcp.list re-tokenizes to exactly one deb line), and
# only then rename each file into place — so the live files are untouched on any
# failure. deb-only (SC-249: the rpm pointer is reverted by
# series_rollback_restore_rpm). Sets SERIES_APT_REVERT_RESULT; 0 = restored, 1 = failed.
series_rollback_restore_apt() {
	local ctar="$1"
	SERIES_APT_REVERT_RESULT='{"restored": false, "reason": "not attempted"}'
	if [[ "$OS_FAMILY" != "deb" ]]; then
		SERIES_APT_REVERT_RESULT='{"restored": false, "reason": "series rollback is deb-only"}'
		return 1
	fi
	if [[ -z "$ctar" || ! -f "$ctar" ]]; then
		SERIES_APT_REVERT_RESULT="$(jq -nc --arg c "$ctar" \
			'{restored: false, reason: ("config snapshot (tar) is missing: " + (if $c == "" then "(none recorded)" else $c end))}')"
		return 1
	fi

	local base="${CONFIG_ROOT%/}"

	# The exact members to restore — and no others. shcp.list is a fixed path; the
	# shcp-* pins are enumerated from the archive's own listing so a member the tar
	# does not contain is never handed to `tar x` (which would error the whole run).
	local -a wanted=()
	local member
	while IFS= read -r member; do
		# A leading ./ is how some tar builds record members; normalise it off
		# before matching so the filter never silently misses a pin.
		member="${member#./}"
		case "$member" in
			*..*) continue ;;   # never trust a traversal shape
			etc/apt/sources.list.d/shcp.list|etc/apt/sources.list.d/shcp-security.list) wanted+=("$member") ;;
			etc/apt/preferences.d/shcp-*)
				# directories list as .../ — a pin file never ends in /
				[[ "$member" == */ ]] && continue
				wanted+=("$member") ;;
		esac
	done < <(tar tzf "$ctar" 2>/dev/null || true)

	if [[ ${#wanted[@]} -eq 0 ]]; then
		SERIES_APT_REVERT_RESULT='{"restored": false, "reason": "config snapshot carries no shcp apt suite/pin paths"}'
		return 1
	fi

	# Stage on the same filesystem as /etc/apt so the moves below are atomic
	# renames; fall back to a scratch dir only if /etc/apt is not writable.
	local stage
	stage="$(mktemp -d "${base}/etc/apt/.shcp-rollback.XXXXXX" 2>/dev/null)" \
		|| stage="$(mktemp -d 2>/dev/null)" \
		|| { SERIES_APT_REVERT_RESULT='{"restored": false, "reason": "cannot create a staging directory"}'; return 1; }

	if ! tar xzf "$ctar" -C "$stage" -- "${wanted[@]}" 2>/dev/null; then
		rm -rf "$stage"
		SERIES_APT_REVERT_RESULT='{"restored": false, "reason": "extracting the apt paths from the config snapshot failed"}'
		return 1
	fi

	# Verify every staged file BEFORE moving anything — SC-029's "corrupt write
	# never reaches the live file". shcp.list additionally must parse back to one
	# deb line, or a mangled snapshot would leave apt with no source at all.
	local w staged
	for w in "${wanted[@]}"; do
		staged="${stage}/${w}"
		if [[ ! -s "$staged" ]]; then
			rm -rf "$stage"
			SERIES_APT_REVERT_RESULT="$(jq -nc --arg f "$w" '{restored: false, reason: ("archived " + $f + " is empty or missing")}')"
			return 1
		fi
		if [[ "$w" == "etc/apt/sources.list.d/shcp.list" || "$w" == "etc/apt/sources.list.d/shcp-security.list" ]]; then
			local ln n=0
			while IFS= read -r ln || [[ -n "$ln" ]]; do
				[[ "$ln" =~ ^[[:space:]]*deb([[:space:]]|-src) ]] && n=$((n + 1))
			done < "$staged"
			if [[ "$n" -ne 1 ]]; then
				rm -rf "$stage"
				SERIES_APT_REVERT_RESULT="$(jq -nc --argjson n "$n" '{restored: false, reason: ("archived shcp.list has " + ($n|tostring) + " deb lines, expected 1")}')"
				return 1
			fi
		fi
	done

	# Move the pins first, the suite pointer last: same ordering discipline as the
	# forward rewrite (F2) — the suite line is the last thing to flip either way.
	local restored_pins=0 restored_suite=false live
	for w in "${wanted[@]}"; do
		[[ "$w" == "etc/apt/sources.list.d/shcp.list" || "$w" == "etc/apt/sources.list.d/shcp-security.list" ]] && continue
		live="${base}/${w}"
		mkdir -p "$(dirname "$live")" 2>/dev/null || true
		if ! mv -f "${stage}/${w}" "$live"; then
			rm -rf "$stage"
			SERIES_APT_REVERT_RESULT="$(jq -nc --arg f "$w" '{restored: false, reason: ("could not restore " + $f)}')"
			return 1
		fi
		restored_pins=$((restored_pins + 1))
	done
	for w in "${wanted[@]}"; do
		[[ "$w" == "etc/apt/sources.list.d/shcp.list" || "$w" == "etc/apt/sources.list.d/shcp-security.list" ]] || continue
		live="${base}/${w}"
		mkdir -p "$(dirname "$live")" 2>/dev/null || true
		if ! mv -f "${stage}/${w}" "$live"; then
			rm -rf "$stage"
			SERIES_APT_REVERT_RESULT="$(jq -nc --arg f "$w" '{restored: false, reason: ("could not restore " + $f)}')"
			return 1
		fi
		restored_suite=true
	done

	rm -rf "$stage"
	SERIES_APT_REVERT_RESULT="$(jq -nc --argjson s "$restored_suite" --argjson p "$restored_pins" \
		'{restored: true, suite: $s, pins: $p}')"
	engine_log "rollback: restored the pre-upgrade apt suite$( [[ "$restored_suite" == true ]] && printf ' + %s daemon pin(s)' "$restored_pins" ) from the config snapshot (selective)"
	return 0
}

# series_rollback_restore_rpm <config_tar> — the rpm twin of
# series_rollback_restore_apt (SC-476). On EL the series is NOT a suite token in a
# repo file: /etc/yum.repos.d/shcp.repo is a static body whose baseurl is
# $shcpbase/rpm/el$shcpel/$shcpseries/, so the thing that decides which series a box
# is on is the dnf var /etc/dnf/vars/shcpseries. The load-bearing revert is therefore
# restoring the three dnf series vars from the run's config snapshot, so package
# downgrades after a cross-series rollback resolve against the reverted series' rpm
# tree instead of the new series'.
#
# SELECTIVE — same discipline as the apt half. config_tar_create also archives
# /etc/shcp* and /etc/systemd/system; a full extract would clobber live config, so
# ONLY these members are pulled:
#     etc/dnf/vars/shcpseries   (the series pointer — the load-bearing one)
#     etc/dnf/vars/shcpbase     (repo base URL var)
#     etc/dnf/vars/shcpel       (EL major var)
# The set is read from the tar's OWN listing, never guessed — a member the tar does
# not carry is never handed to `tar x` (which would error the whole run).
#
# SC-029 posture: extract into a private staging dir on the SAME filesystem as the
# targets, verify (non-empty; shcpseries re-parses to MAJOR.MINOR), and only then
# rename each file into place — the live files are untouched on any failure.
# rpm-only: the reverse of series_rewrite_dnf_series (shcp-updater#159). rollback_run
# proves the pre-upgrade leaf (series_rpm_snapshot_url + series_dnf_prime) BEFORE calling this.
# Sets SERIES_RPM_REVERT_RESULT; 0 = restored, 1 = failed.
series_rollback_restore_rpm() {
	local ctar="$1"
	SERIES_RPM_REVERT_RESULT='{"restored": false, "reason": "not attempted"}'
	if [[ "$OS_FAMILY" != "rpm" ]]; then
		SERIES_RPM_REVERT_RESULT='{"restored": false, "reason": "series rpm rollback is rpm-only"}'
		return 1
	fi
	if [[ -z "$ctar" || ! -f "$ctar" ]]; then
		SERIES_RPM_REVERT_RESULT="$(jq -nc --arg c "$ctar" \
			'{restored: false, reason: ("config snapshot (tar) is missing: " + (if $c == "" then "(none recorded)" else $c end))}')"
		return 1
	fi

	local base="${CONFIG_ROOT%/}"

	# The exact members to restore — and no others. The three dnf vars are fixed
	# paths, but each is taken from the archive's own listing so a member the tar
	# does not contain is never handed to `tar x` (which would error the whole run).
	local -a wanted=()
	local member
	while IFS= read -r member; do
		# A leading ./ is how some tar builds record members; normalise it off
		# before matching so the filter never silently misses a var.
		member="${member#./}"
		case "$member" in
			*..*) continue ;;   # never trust a traversal shape
			etc/dnf/vars/shcpseries|etc/dnf/vars/shcpbase|etc/dnf/vars/shcpel)
				wanted+=("$member") ;;
		esac
	done < <(tar tzf "$ctar" 2>/dev/null || true)

	if [[ ${#wanted[@]} -eq 0 ]]; then
		SERIES_RPM_REVERT_RESULT='{"restored": false, "reason": "config snapshot carries no shcp dnf series-var paths"}'
		return 1
	fi

	# shcpseries is the series pointer itself. Restoring shcpbase/shcpel WITHOUT it
	# would not revert the series at all, so a snapshot missing it is a fail-closed
	# refusal, not a partial "restored: true".
	local have_series=false w
	for w in "${wanted[@]}"; do
		[[ "$w" == "etc/dnf/vars/shcpseries" ]] && have_series=true
	done
	if [[ "$have_series" != true ]]; then
		SERIES_RPM_REVERT_RESULT='{"restored": false, "reason": "config snapshot has no etc/dnf/vars/shcpseries (the series pointer)"}'
		return 1
	fi

	# Stage on the same filesystem as /etc/dnf/vars so the moves below are atomic
	# renames; fall back to a scratch dir only if it is not writable.
	local stage
	stage="$(mktemp -d "${base}/etc/dnf/vars/.shcp-rollback.XXXXXX" 2>/dev/null)" \
		|| stage="$(mktemp -d 2>/dev/null)" \
		|| { SERIES_RPM_REVERT_RESULT='{"restored": false, "reason": "cannot create a staging directory"}'; return 1; }

	if ! tar xzf "$ctar" -C "$stage" -- "${wanted[@]}" 2>/dev/null; then
		rm -rf "$stage"
		SERIES_RPM_REVERT_RESULT='{"restored": false, "reason": "extracting the dnf series vars from the config snapshot failed"}'
		return 1
	fi

	# Verify every staged file BEFORE moving anything — SC-029's "corrupt write never
	# reaches the live file". shcpseries additionally must re-parse to MAJOR.MINOR, or
	# a mangled snapshot would point every dnf baseurl at a non-existent series tree.
	local staged
	for w in "${wanted[@]}"; do
		staged="${stage}/${w}"
		if [[ ! -s "$staged" ]]; then
			rm -rf "$stage"
			SERIES_RPM_REVERT_RESULT="$(jq -nc --arg f "$w" '{restored: false, reason: ("archived " + $f + " is empty or missing")}')"
			return 1
		fi
		if [[ "$w" == "etc/dnf/vars/shcpseries" ]]; then
			local sv
			sv="$(head -n1 "$staged" | tr -d '\r')"
			if ! [[ "$sv" =~ ^[0-9]+\.[0-9]+$ ]]; then
				rm -rf "$stage"
				SERIES_RPM_REVERT_RESULT="$(jq -nc --arg s "$sv" '{restored: false, reason: ("archived shcpseries is malformed (want MAJOR.MINOR): " + $s)}')"
				return 1
			fi
		fi
	done

	# Move the base/major vars first, the series pointer LAST: same ordering
	# discipline as the apt half (F2) — the series token is the last thing to flip
	# either way, so an interrupted revert never leaves a baseurl already pointing at
	# the old series while base/major still say the new one.
	local restored_vars=0 restored_series=false live
	for w in "${wanted[@]}"; do
		[[ "$w" == "etc/dnf/vars/shcpseries" ]] && continue
		live="${base}/${w}"
		mkdir -p "$(dirname "$live")" 2>/dev/null || true
		if ! mv -f "${stage}/${w}" "$live"; then
			rm -rf "$stage"
			SERIES_RPM_REVERT_RESULT="$(jq -nc --arg f "$w" '{restored: false, reason: ("could not restore " + $f)}')"
			return 1
		fi
		restored_vars=$((restored_vars + 1))
	done
	for w in "${wanted[@]}"; do
		[[ "$w" == "etc/dnf/vars/shcpseries" ]] || continue
		live="${base}/${w}"
		mkdir -p "$(dirname "$live")" 2>/dev/null || true
		if ! mv -f "${stage}/${w}" "$live"; then
			rm -rf "$stage"
			SERIES_RPM_REVERT_RESULT='{"restored": false, "reason": "could not restore etc/dnf/vars/shcpseries"}'
			return 1
		fi
		restored_series=true
	done

	rm -rf "$stage"
	SERIES_RPM_REVERT_RESULT="$(jq -nc --argjson s "$restored_series" --argjson v "$restored_vars" \
		'{restored: true, series: $s, vars: $v}')"
	engine_log "rollback: restored the pre-upgrade dnf series pointer$( [[ "$restored_series" == true ]] && printf ' (shcpseries + %s base/major var(s))' "$restored_vars" ) from the config snapshot (selective)"
	return 0
}

# --- panel runtime-dep delivery (shcp-build#183, SC-543)
# The fresh installer lays down the panel's runtime OS packages (e.g. `dig` for the
# FCrDNS worker: deb bind9-dnsutils, rpm bind-utils — SC-356). An already-installed
# host never reruns the installer and shcp-reconcile installs no packages, so the
# signed update is the only vehicle that can repair it. The signed manifest declares
# them per family (installer.<family>.runtime_packages); this engine installs the
# declared-missing ones ADDITIVELY during a normal apply.
#
# THREE trust bounds, defence-in-depth (SC-543):
#   1. signed-payload-only — names are read ONLY from the run's verified manifest
#      copy (gpgv + pinned VALIDSIG + SC-472 anti-replay), never an unverified source;
#   2. allow-list — even a signed manifest can install only a name that ALSO appears
#      on this compiled-in per-family list, so a signed-but-wrong or key-compromised
#      manifest cannot turn the root update path into an arbitrary-package installer;
#   3. additive + family-dispatched — install-if-absent only (osf_pkg_install), a deb
#      name never consulted on rpm (the case arms below) nor vice versa.

# runtime_dep_allowed <pkg> — 0 iff <pkg> is a permitted runtime dep for the RUNNING
# family. A bare case per family: auditable, and structurally impossible for a deb
# name to match on rpm (SC-077 class). Widen only alongside the manifest declaration.
# unbound (+ the deb split unbound-anchor/dns-root-data) is the SC-691 DNSBL recursor,
# delivered to pre-#974 boxes here so installer#1051's converge action can bring it up.
# EL has no dns-root-data, and unbound-anchor is a separate subpackage of the unbound
# SRPM that unbound depends on (verified on AlmaLinux 10.2: dnf install unbound pulls
# unbound-anchor in as a dependency) — so the rpm arm lists unbound alone and the
# deb-only names are rejected on rpm.
# named-checkconf is the panel's SC-029 named.conf parse-back, which fails closed:
# without it no named.conf is published and no new zone is served. deb ships it in
# bind9-utils; EL10 ships it only in bind (bind-utils has named-checkzone, not
# named-checkconf).
# bubblewrap builds the jail a tenant shell runs in (SC-901); a box without it has no
# tenant shell, only SFTP (SC-906). git is what the jailed shell and git deploy run.
# Both carry the same name on deb and rpm, and neither ships a service unit.
# dbus-send is needed by app-hosting; its client package differs by family.
# rsync is what the panel runs AS a tenant to copy mail inside the tenant's own tree
# (IMAP-migration import; subdomain mail rename staging, shcp-master#3184). Same name
# on both families; deb's rsync.service only starts with an /etc/rsyncd.conf, and EL
# ships the daemon in a separate rsync-daemon package.
runtime_dep_allowed() {
	local pkg="$1"
	case "$OS_FAMILY" in
		deb) case "$pkg" in bind9-dnsutils|bind9-utils|unbound|unbound-anchor|dns-root-data|bubblewrap|git|dbus-bin|rsync) return 0 ;; esac ;;
		rpm) case "$pkg" in bind-utils|bind|unbound|bubblewrap|git|dbus-tools|rsync) return 0 ;; esac ;;
	esac
	return 1
}

# runtime_deps_declared — the package names the SIGNED manifest declares as panel
# runtime deps for this box's OS family, one per line. Read ONLY from the run's
# verified manifest copy (preflight's manifest_fetch kept it after gpgv + the pinned
# VALIDSIG check + SC-472). Union across every listed series (the allow-list is the
# hard bound, so this cannot install anything unexpected and is robust to which
# series entry the box matches). Empty — the loop no-ops — when the manifest is
# absent (scope=packages, or a manifest outage the run degraded past) or carries no
# such field.
runtime_deps_declared() {
	local manifest="${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json"
	[[ -r "$manifest" ]] || return 0
	jq -r --arg f "$OS_FAMILY" '
		[ .series[]? | .installer[$f]?.runtime_packages // [] | .[]? ]
		| unique | .[]' "$manifest" 2>/dev/null || true
}

# runtime_deps_ensure — install every declared-missing, allow-listed, family-matching
# runtime dep additively. Journals installed names to .packages.deps_added —
# deliberately NOT .packages.changed — so a later release rollback LEAVES them in
# place (an additive hardening dep is not part of the release's package transaction;
# rollback only undoes .packages.changed). BEST-EFFORT: a failure is logged loud and
# journaled as a shortfall but never fails the stage / never arms auto_rollback — a
# transient repo miss on additive hardening must not roll back an otherwise-good
# update. Confirms the package actually landed after install (SC-513: a held/absent
# package can leave the manager exit-0 without installing).
runtime_deps_ensure() {
	local pkg refreshed=0
	while IFS= read -r pkg; do
		[[ -n "$pkg" ]] || continue
		if ! osf_valid_pkg "$pkg"; then
			engine_log "runtime-dep: refusing malformed declared package name (SC-002)"
			continue
		fi
		if ! runtime_dep_allowed "$pkg"; then
			engine_log "runtime-dep: '${pkg}' declared but not on the ${OS_FAMILY} allow-list — ignoring (SC-543)"
			continue
		fi
		osf_pkg_installed "$pkg" && continue
		if [[ "$refreshed" == 0 ]]; then osf_refresh_index || true; refreshed=1; fi
		engine_log "runtime-dep: installing declared-missing package ${pkg} (additive)"
		if osf_pkg_install "$pkg" >/dev/null 2>&1 && osf_pkg_installed "$pkg"; then
			engine_log "runtime-dep: ${pkg} installed"
			journal_update "$CURRENT_RUN_ID" \
				'.packages.deps_added = ((.packages.deps_added // []) + [$p])' --arg p "$pkg"
		else
			engine_log "runtime-dep: FAILED to install ${pkg} — panel functionality needing it stays degraded until the next successful update (non-fatal)"
			journal_update "$CURRENT_RUN_ID" \
				'.packages.deps_shortfall = ((.packages.deps_shortfall // []) + [$p])' --arg p "$pkg"
		fi
	done < <(runtime_deps_declared)
	return 0
}

# --- wp-cli version currency (see the WPCLI_* constants). The same reasoning as
# runtime_deps_ensure (SC-543) — an already-installed box never reruns the installer,
# so the signed update is the only vehicle that can carry a bumped pin to it —
# applied to the pinned, GPG-verified wp-cli phar instead of an apt package.
# BEST-EFFORT + fail-closed: a bumped pin is re-applied, a stale one is left exactly
# as-is on ANY download/verify failure (never a half-installed or unverified phar),
# and the failure is loud + journaled but never fails the stage / never arms
# auto_rollback — a transient GitHub outage must not roll back an otherwise-good
# update, exactly as an additive runtime dep must not.

# wpcli_pin_declared — the wp-cli version the SIGNED manifest declares for THIS box's
# panel series, or empty. Read ONLY from the run's verified manifest copy (preflight's
# manifest_fetch kept it after gpgv + the pinned VALIDSIG check + SC-472 anti-replay),
# the same signed-payload-only bound as runtime_deps_declared — the pin is never read
# from an unverified source, and never from a stale on-box installer copy (there is
# none during a run). Empty — the caller no-ops — when the manifest is absent
# (scope-degraded run), carries no wp_cli.version for the series, or the value is
# not a well-formed version. The CURRENT series is read,
# not a series-run target: the phar installs before the panel flip, and a
# target-series wp-cli bump lands on the first routine run after the flip.
wpcli_pin_declared() {
	local manifest="${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json" current series v
	[[ -r "$manifest" ]] || return 0
	current="$(panel_current_version)" || return 0
	ver_valid "$current" || return 0
	series="$(ver_series "$current")"
	v="$(jq -r --arg s "$series" \
		'[.series[]? | select(.series == $s)] | first | .wp_cli.version // empty' \
		"$manifest" 2>/dev/null || true)"
	ver_valid "$v" || return 0
	printf '%s' "$v"
}

# wpcli_installed_pin_matches <pin> — 0 iff the installed wp reports exactly <pin>.
# Mirrors the installer's wpcli_installed_version_matches_pin. It is BOTH the
# idempotency gate (skip the whole download when already current) AND the
# post-install parse-back (prove the phar just written runs and IS the pin — a phar
# that verifies but cannot execute under this box's PHP would otherwise only surface
# later as a confusing WordPress failure).
wpcli_installed_pin_matches() {
	wpcli_binary_reports_pin "$WPCLI_BIN" "$1"
}

# wpcli_binary_reports_pin <path> <pin> — 0 iff the phar at <path> RUNS under this
# box's PHP and reports exactly <pin>. The parse-back primitive, usable against a
# staged copy before it is renamed into place.
wpcli_binary_reports_pin() {
	local bin="$1" pin="$2" installed
	[[ -x "$bin" ]] || return 1
	installed="$("$bin" --allow-root --version 2>/dev/null | awk '{ print $2; exit }')"
	[[ "$installed" == "$pin" ]]
}

# wpcli_download_verify_install <pin> — download the pinned phar, its detached
# signature and its sha512 into a private scratch dir; verify sha512 (integrity, a
# clear message on truncation) THEN the GPG signature against the compiled-in release
# key (authenticity — the SHA ships from the same origin as the phar, so the detached
# signature is the gate that actually binds the bytes to WP-CLI); import the key into
# an ephemeral keyring and assert its fingerprint equals WPCLI_RELEASE_FPR before
# trusting it; only then atomically install to $WPCLI_BIN and parse back. Fail-closed:
# ANY failure removes the whole scratch dir (phar, sig, ephemeral keyring) and leaves
# the live binary untouched. 0 on a verified install, 1 otherwise. Mirrors the
# installer's wpcli_download_phar + wpcli_install verify discipline — re-implemented
# here rather than sourced because run_stages has no installer tree on-box.
wpcli_download_verify_install() {
	local pin="$1"
	if ! command -v gpg >/dev/null 2>&1; then
		engine_log "wpcli: gpg unavailable — cannot verify a pinned phar; leaving wp-cli as-is"
		return 1
	fi
	local base="${WPCLI_RELEASE_BASE_URL}/v${pin}"
	local dir phar sig sha keyfile keyring
	# Private, never under the panel-owned state dir: the keyring and the phar are
	# read and installed BY PATH after verification.
	dir="$(private_scratch_dir)" || return 1
	phar="${dir}/wp-cli-${pin}.phar"; sig="${phar}.asc"; sha="${phar}.sha512"
	keyfile="${dir}/release-key.asc"; keyring="${dir}/keyring"

	wpcli_download_verify_install_inner "$pin" "$base" "$dir" "$phar" "$sig" "$sha" "$keyfile" "$keyring"
	local rc=$?
	# ALWAYS shred the scratch dir — a poisoned or unverified phar dropped here must
	# never survive into the next run (SC-089), and the ephemeral keyring is per-run.
	rm -rf "$dir"
	return $rc
}

# The body of wpcli_download_verify_install, split out so its single caller can
# unconditionally remove the scratch dir on every exit path.
wpcli_download_verify_install_inner() {
	local pin="$1" base="$2" dir="$3" phar="$4" sig="$5" sha="$6" keyfile="$7" keyring="$8"

	local f remote local_path
	for f in "wp-cli-${pin}.phar:${phar}" \
	         "wp-cli-${pin}.phar.asc:${sig}" \
	         "wp-cli-${pin}.phar.sha512:${sha}"; do
		remote="${f%%:*}"; local_path="${f#*:}"
		if ! curl -fsSL --connect-timeout 15 --max-time 180 \
				--max-filesize "$MAX_WPCLI_PHAR_BYTES" -H 'Accept-Encoding: identity' \
				"${base}/${remote}" -o "${local_path}"; then
			engine_log "wpcli: download of ${remote} failed — refusing to install an unverified wp-cli (SC-078 fail-closed)"
			return 1
		fi
	done

	# SHA-512 first (cheap, catches truncation with a clear message). NOT the
	# authenticity gate: it ships from the same origin as the phar.
	local expected_sha actual_sha
	expected_sha="$(awk '{ print $1; exit }' "$sha" 2>/dev/null | tr -d '[:space:]')"
	if [[ ! "$expected_sha" =~ ^[a-f0-9]{128}$ ]]; then
		engine_log "wpcli: sha512 file is not a 128-hex digest — refusing (SC-078)"
		return 1
	fi
	actual_sha="$(sha512sum "$phar" 2>/dev/null | awk '{ print $1 }')"
	if [[ "$expected_sha" != "$actual_sha" ]]; then
		engine_log "wpcli: sha512 mismatch for v${pin} — refusing (SC-078)"
		return 1
	fi

	# Import the compiled-in release key into an ephemeral keyring and prove it is the
	# key we intend to trust (fingerprint == WPCLI_RELEASE_FPR) before verifying with it.
	install -d -m 700 "$keyring" || return 1
	wpcli_release_key "$keyfile" || { engine_log "wpcli: could not materialise the release key"; return 1; }
	if ! GNUPGHOME="$keyring" gpg --batch --quiet --import "$keyfile" 2>/dev/null; then
		engine_log "wpcli: importing the release key failed — refusing (SC-078)"
		return 1
	fi
	local imported_fpr
	imported_fpr="$(GNUPGHOME="$keyring" gpg --batch --list-keys --with-colons 2>/dev/null \
		| awk -F: '$1 == "fpr" { print $10; exit }')"
	if [[ "$imported_fpr" != "$WPCLI_RELEASE_FPR" ]]; then
		engine_log "wpcli: release key fingerprint mismatch (expected ${WPCLI_RELEASE_FPR}, got ${imported_fpr:-<none>}) — refusing (SC-078)"
		return 1
	fi

	# --trust-model always: the pin IS the trust decision; without it gpg exits
	# non-zero purely for want of web-of-trust certification (mirrors the installer).
	if ! GNUPGHOME="$keyring" gpg --batch --quiet --trust-model always \
			--verify "$sig" "$phar" 2>/dev/null; then
		engine_log "wpcli: GPG signature verification FAILED for v${pin} — the phar is not signed by ${WPCLI_RELEASE_FPR}; refusing (SC-078 fail-closed)"
		return 1
	fi

	# Copy beside the final path and set mode/owner THERE, so the binary is never
	# briefly present half-permissioned and mv -f is a same-filesystem rename: the
	# scratch dir is on another filesystem, where mv would write the live binary
	# in place instead of swinging it in.
	local staged="${WPCLI_BIN}.shcp-new"
	install -d -m 755 "$(dirname "$WPCLI_BIN")" || return 1
	rm -f -- "$staged"
	if ! cp -- "$phar" "$staged" || ! chmod 0755 "$staged" || ! chown root:root "$staged"; then
		rm -f -- "$staged"
		engine_log "wpcli: could not stage the verified phar beside ${WPCLI_BIN} — refusing"
		return 1
	fi

	# Parse-back BEFORE the rename, against the staged copy. A phar can verify
	# (sha512 + signature) and still not run under this box's PHP; it used to be
	# renamed into place first and checked second, so that failure left `wp`
	# broken for every tenant with the old binary already gone — and every later
	# run retried the same pin into the same hole. The staged copy sits beside
	# $WPCLI_BIN on the same exec-capable filesystem (the scratch dir is
	# PrivateTmp, which an SHCP box mounts noexec), and it is the exact inode
	# the rename below publishes.
	if ! wpcli_binary_reports_pin "$staged" "$pin"; then
		rm -f -- "$staged"
		engine_log "wpcli: the verified phar does not run as v${pin} under this PHP — NOT installing it, ${WPCLI_BIN} is untouched"
		return 1
	fi
	# Keep the live binary reachable as a hardlink (same inode, same filesystem)
	# so a post-rename check that still fails can put it straight back.
	local prev="${WPCLI_BIN}.shcp-prev"
	rm -f -- "$prev"
	if [[ -f "$WPCLI_BIN" && ! -L "$WPCLI_BIN" ]]; then
		if ! ln -- "$WPCLI_BIN" "$prev" 2>/dev/null && ! cp -p -- "$WPCLI_BIN" "$prev" 2>/dev/null; then
			rm -f -- "$staged" "$prev"
			engine_log "wpcli: could not keep a copy of the live ${WPCLI_BIN} for rollback — refusing to replace it"
			return 1
		fi
	fi
	if ! mv -f -- "$staged" "$WPCLI_BIN"; then
		rm -f -- "$staged" "$prev"
		engine_log "wpcli: could not install the verified phar to ${WPCLI_BIN}"
		return 1
	fi

	# Parse-back again at the live path: the thing just installed must run AND
	# be the pinned version. Should this fail after the staged check passed
	# (an open-file/handler race, a PATH-dependent shim), the previous binary
	# goes back so no tenant is left without `wp`.
	if ! wpcli_installed_pin_matches "$pin"; then
		if [[ -e "$prev" ]] && mv -f -- "$prev" "$WPCLI_BIN"; then
			engine_log "wpcli: post-install verification failed — ${WPCLI_BIN} does not report v${pin}; the previous binary was put back"
		else
			engine_log "wpcli: post-install verification failed — ${WPCLI_BIN} does not report v${pin}, and no previous binary could be restored"
		fi
		rm -f -- "$prev"
		return 1
	fi
	rm -f -- "$prev"
	return 0
}

# wpcli_ensure_pin — re-apply the manifest-declared wp-cli pin if the on-box binary
# is not already at it. Called from stage_apt beside runtime_deps_ensure. Always
# returns 0 (non-fatal, like runtime_deps_ensure): a wp-cli shortfall is journaled
# loud but never fails the stage. Journals the applied version to .packages.wpcli and
# a failure to .packages.wpcli_shortfall — deliberately NOT .packages.changed, so a
# later release rollback leaves the current wp-cli in place (a forward-only tool bump
# is not part of the release's package transaction).
wpcli_ensure_pin() {
	local pin
	pin="$(wpcli_pin_declared)"
	if [[ -z "$pin" ]]; then
		return 0
	fi
	if wpcli_installed_pin_matches "$pin"; then
		return 0
	fi
	engine_log "wpcli: on-box wp-cli is not at the pinned v${pin} — re-applying (pinned + GPG-verified)"
	if wpcli_download_verify_install "$pin"; then
		engine_log "wpcli: wp-cli re-pinned to v${pin}"
		journal_update "$CURRENT_RUN_ID" '.packages.wpcli = $v' --arg v "$pin"
	else
		engine_log "wpcli: FAILED to re-apply the pinned wp-cli v${pin} — wp-cli stays at its current version until the next successful update (non-fatal)"
		journal_update "$CURRENT_RUN_ID" '.packages.wpcli_shortfall = $v' --arg v "$pin"
	fi
	return 0
}

# crs_pin_declared — the OWASP CRS version+digest the SIGNED manifest declares for
# THIS box's panel series, as "<version> <sha256>", or empty. Read ONLY from the
# run's verified manifest copy (preflight's manifest_fetch kept it after gpgv + the
# pinned VALIDSIG check + SC-472 anti-replay), the same signed-payload-only bound as
# wpcli_pin_declared and runtime_deps_declared. Empty — the caller no-ops — when the
# manifest is absent (scope-degraded run), the series carries no crs pin, or either
# half is malformed. The CURRENT series is read,
# exactly as wpcli_pin_declared: the ruleset is refreshed before the panel flip, and
# a target-series pin lands on the first routine run after the flip.
crs_pin_declared() {
	local manifest="${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json" current series ver sha
	[[ -r "$manifest" ]] || return 0
	current="$(panel_current_version)" || return 0
	ver_valid "$current" || return 0
	series="$(ver_series "$current")"
	ver="$(jq -r --arg s "$series" \
		'[.series[]? | select(.series == $s)] | first | .crs.version // empty' \
		"$manifest" 2>/dev/null || true)"
	sha="$(jq -r --arg s "$series" \
		'[.series[]? | select(.series == $s)] | first | .crs.sha256 // empty' \
		"$manifest" 2>/dev/null || true)"
	ver_valid "$ver" || return 0
	[[ "$sha" =~ ^[a-f0-9]{64}$ ]] || return 0
	printf '%s %s' "$ver" "$sha"
}

# crs_installed_matches <version> <sha256> — 0 iff the on-box stamp records EXACTLY
# this version+digest. The idempotency gate (skip the whole download when the ruleset
# already matches the pin). An absent stamp is a non-match ⇒ re-apply once; the
# stamp it then writes makes every later run a no-op.
crs_installed_matches() {
	local want_ver="$1" want_sha="$2" line
	[[ -r "$CRS_VERSION_STAMP" ]] || return 1
	line="$(head -n1 -- "$CRS_VERSION_STAMP" 2>/dev/null)"
	[[ "$line" == "${want_ver} ${want_sha}" ]]
}

# crs_restore_previous <backup-dir> — put the saved ruleset back after a failed swap
# or a post-swap configtest, so the live box keeps the rules it had. Clears whatever
# the failed attempt left behind first, and re-applies the owner and mode
# crs_download_verify_install_inner recorded (CRS_PREV_RULES_META /
# CRS_PREV_CONF_META, "uid:gid mode"): the backup lives in a scratch on another
# filesystem, where the move aside was a copy, and nothing else remembers what
# httpd could read before. Applied without following: a symlink where a rules
# dir or conf file should be is left alone.
CRS_PREV_RULES_META=""
CRS_PREV_CONF_META=""
crs_restore_previous() {
	local backup="$1"
	rm -rf "${MODSEC_CRS_DIR}/rules" "${MODSEC_CRS_DIR}/crs-setup.conf"
	[[ -e "${backup}/rules" ]] && mv -- "${backup}/rules" "${MODSEC_CRS_DIR}/rules"
	[[ -e "${backup}/crs-setup.conf" ]] && mv -- "${backup}/crs-setup.conf" "${MODSEC_CRS_DIR}/crs-setup.conf"
	crs_meta_restore "${MODSEC_CRS_DIR}/rules" "$CRS_PREV_RULES_META"
	crs_meta_restore "${MODSEC_CRS_DIR}/crs-setup.conf" "$CRS_PREV_CONF_META"
	if command -v restorecon >/dev/null 2>&1; then
		restorecon -R "$MODSEC_CRS_DIR" >/dev/null 2>&1 || true
	fi
}

# crs_meta_record <path> — "uid:gid mode" of <path> (lstat), or empty.
crs_meta_record() {
	[[ -e "$1" && ! -L "$1" ]] || return 0
	stat -c '%u:%g %a' -- "$1" 2>/dev/null || true
}

# crs_meta_restore <path> <"uid:gid mode"> — re-apply a recorded owner and mode
# to a real (non-symlink) file or directory; no-op when nothing was recorded.
crs_meta_restore() {
	local p="$1" meta="$2"
	[[ -n "$meta" && -e "$p" && ! -L "$p" ]] || return 0
	[[ "$meta" =~ ^([0-9]+:[0-9]+)\ ([0-7]{3,4})$ ]] || return 0
	chown -h "${BASH_REMATCH[1]}" "$p" 2>/dev/null \
		|| engine_log "crs: could not restore the owner of ${p} to ${BASH_REMATCH[1]}"
	chmod "${BASH_REMATCH[2]}" "$p" 2>/dev/null \
		|| engine_log "crs: could not restore the mode of ${p} to ${BASH_REMATCH[2]}"
}

# crs_download_verify_install <version> <sha256> — download the pinned CRS tarball
# into a private scratch dir, verify its sha256 against the pinned digest
# (fail-closed — the manifest digest is the sole authenticity anchor, there being no
# upstream GPG signature for the CRS), extract, sanity-check the layout, then swing
# the new rules/ + crs-setup.conf into MODSEC_CRS_DIR keeping the current ones aside.
# Run an apachectl configtest and, only if it passes, gracefully reload Apache so the
# refreshed rules go live, then write the applied-pin stamp. On ANY failure —
# download, digest, extract, layout, a failed swap, or a post-swap configtest — the
# previous ruleset is restored and Apache is never reloaded into a broken config
# (SC-478). The scratch dir (tarball, extracted tree, backup) is ALWAYS shredded, so
# an unverified artifact never survives into the next run (SC-089). 0 on a verified,
# live install; 1 otherwise. Mirrors the installer's install_owasp_crs verify
# discipline, re-implemented here rather than sourced because run_stages has no
# installer tree on-box.
crs_download_verify_install() {
	local ver="$1" sha="$2" dir rc
	# private_scratch_dir, exactly as wp-cli: the tarball is verified and then
	# extracted BY PATH as root and installed from there, so the scratch must be
	# somewhere no unprivileged account can rename or replace. It used to be
	# `mktemp -d` under SHCP_UPDATE_STATE_DIR — inside the panel-owned
	# /var/lib/shcp, whose owner can swap the tree between the digest check and
	# the tar -xzf / mv that follow it.
	dir="$(private_scratch_dir)" || return 1
	crs_download_verify_install_inner "$ver" "$sha" "$dir"
	rc=$?
	rm -rf "$dir"
	return $rc
}

# The body of crs_download_verify_install, split out so its single caller can
# unconditionally shred the scratch dir on every exit path.
crs_download_verify_install_inner() {
	local ver="$1" sha="$2" dir="$3"
	local tarball="${dir}/coreruleset-${ver}-minimal.tar.gz"
	local url="${CRS_RELEASE_BASE_URL}/v${ver}/coreruleset-${ver}-minimal.tar.gz"

	if ! curl -fsSL --connect-timeout 15 --max-time 180 \
			--max-filesize "$MAX_CRS_TARBALL_BYTES" -H 'Accept-Encoding: identity' \
			"$url" -o "$tarball"; then
		engine_log "crs: download of ${url} failed — refusing to install an unverified ruleset (SC-485 fail-closed)"
		return 1
	fi

	local actual
	actual="$(sha256sum "$tarball" 2>/dev/null | awk '{print $1}')"
	if [[ "$actual" != "$sha" ]]; then
		engine_log "crs: sha256 mismatch for v${ver} (expected ${sha}, got ${actual:-<none>}) — refusing (SC-485)"
		return 1
	fi

	local staging="${dir}/x"
	install -d -m 755 "$staging" || return 1
	if ! tar -xzf "$tarball" -C "$staging"; then
		engine_log "crs: extraction of the verified tarball failed — refusing"
		return 1
	fi
	local tree="${staging}/coreruleset-${ver}"
	if [[ ! -f "${tree}/crs-setup.conf.example" || ! -d "${tree}/rules" ]]; then
		engine_log "crs: tarball layout unexpected (no crs-setup.conf.example / rules/) — refusing"
		return 1
	fi

	# Swap in place, keeping the current ruleset aside so a post-swap configtest
	# failure can restore it — the engine updates a LIVE box and must never leave
	# Apache unable to parse its config (SC-478). install_owasp_crs can abort the
	# whole install; here restore-on-failure is that safety.
	install -d -m 755 "$MODSEC_CRS_DIR" || return 1
	local backup="${dir}/prev"
	install -d -m 755 "$backup" || return 1
	# Record what the live ruleset's owner and mode ARE before it is moved into
	# a scratch on another filesystem, so crs_restore_previous can put them back.
	CRS_PREV_RULES_META="$(crs_meta_record "${MODSEC_CRS_DIR}/rules")"
	CRS_PREV_CONF_META="$(crs_meta_record "${MODSEC_CRS_DIR}/crs-setup.conf")"
	[[ -e "${MODSEC_CRS_DIR}/rules" ]] && mv -- "${MODSEC_CRS_DIR}/rules" "${backup}/rules"
	[[ -e "${MODSEC_CRS_DIR}/crs-setup.conf" ]] && mv -- "${MODSEC_CRS_DIR}/crs-setup.conf" "${backup}/crs-setup.conf"

	# The example ships the upstream defaults; SHCP's engine mode and audit
	# overrides live in modsecurity-shcp.conf, not here — same as the installer.
	if ! mv -- "${tree}/rules" "${MODSEC_CRS_DIR}/rules" \
			|| ! mv -f -- "${tree}/crs-setup.conf.example" "${MODSEC_CRS_DIR}/crs-setup.conf"; then
		engine_log "crs: could not move the new ruleset into ${MODSEC_CRS_DIR} — restoring the previous ruleset"
		crs_restore_previous "$backup"
		return 1
	fi
	chown -R root:root "$MODSEC_CRS_DIR" 2>/dev/null || true
	chmod 755 "${MODSEC_CRS_DIR}/rules" 2>/dev/null || true
	chmod 644 "${MODSEC_CRS_DIR}/crs-setup.conf" 2>/dev/null || true
	find "${MODSEC_CRS_DIR}/rules" -type f -exec chmod 644 {} + 2>/dev/null || true
	# SELinux: mv preserves the scratch (tmp_t) label, which httpd_t cannot read —
	# the SC-484 class install_owasp_crs documents. Relabel to the /var/lib/shcp
	# default so httpd can read the tree (no-op where restorecon is absent).
	if command -v restorecon >/dev/null 2>&1; then
		restorecon -R "$MODSEC_CRS_DIR" >/dev/null 2>&1 || true
	fi

	# Configtest-gate the reload. The new rules are a digest-verified upstream
	# release (they parse where the same artifact installs), so this is
	# belt-and-suspenders — but if the whole config no longer parses, restore the
	# previous ruleset rather than let Apache die at its next (re)start. A box with
	# no apachectl (a DNS-only edition never reaches here — MODSEC_CRS_DIR is absent)
	# treats the parse as "not disproven" and proceeds.
	if command -v "$APACHE_CTL" >/dev/null 2>&1; then
		if ! run_bounded 60 "$APACHE_CTL" configtest >/dev/null 2>&1; then
			engine_log "crs: apachectl configtest FAILED after staging v${ver} — restoring the previous ruleset, not reloading (SC-478)"
			crs_restore_previous "$backup"
			return 1
		fi
		# Graceful reload so the refreshed rules take effect now, not at the next
		# unrelated reload. Best-effort: a reload hiccup leaves good rules on disk
		# for the next reload and is never a reason to fail the update.
		run_bounded 60 "$APACHE_CTL" graceful >/dev/null 2>&1 \
			|| engine_log "crs: apachectl graceful reload did not complete — new rules are staged and load on the next Apache reload (non-fatal)"
	fi

	# Record the applied pin so subsequent runs are no-ops. Written LAST, only on a
	# fully successful live install.
	printf '%s %s\n' "$ver" "$sha" > "$CRS_VERSION_STAMP" || return 1
	chmod 644 "$CRS_VERSION_STAMP" 2>/dev/null || true
	return 0
}

# crs_ensure_pin — re-apply the manifest-declared OWASP CRS pin if the on-box
# ruleset is not already at it. Called from stage_apt beside wpcli_ensure_pin.
# Gates, in order: (1) this box manages its own upstream-fetched CRS — MODSEC_CRS_DIR
# exists (el10); on deb the CRS is a distro package apt refreshes in this
# same stage, so this is a clean no-op there; (2) the signed manifest declares a
# well-formed pin for the box's series; (3) the on-box ruleset does not already match
# it. Always returns 0 (non-fatal, like wpcli_ensure_pin): a shortfall is journaled
# loud but never fails the stage / arms auto_rollback — a transient upstream outage
# must not roll back an otherwise-good update. Journals the applied version to
# .packages.crs and a failure to .packages.crs_shortfall — deliberately NOT
# .packages.changed, so a later release rollback leaves the refreshed ruleset in
# place (a forward-only ruleset refresh is not part of the release's package
# transaction).
crs_ensure_pin() {
	[[ -d "$MODSEC_CRS_DIR" ]] || return 0
	local pin ver sha
	pin="$(crs_pin_declared)" || return 0
	[[ -n "$pin" ]] || return 0
	ver="${pin%% *}"; sha="${pin##* }"
	if crs_installed_matches "$ver" "$sha"; then
		return 0
	fi
	engine_log "crs: on-box OWASP CRS is not at the pinned v${ver} — re-applying (pinned + digest-verified)"
	if crs_download_verify_install "$ver" "$sha"; then
		engine_log "crs: OWASP CRS re-pinned to v${ver}"
		journal_update "$CURRENT_RUN_ID" '.packages.crs = $v' --arg v "$ver"
	else
		engine_log "crs: FAILED to re-apply the pinned OWASP CRS v${ver} — the ruleset stays as-is until the next successful update (non-fatal)"
		journal_update "$CURRENT_RUN_ID" '.packages.crs_shortfall = $v' --arg v "$ver"
	fi
	return 0
}

# additive_currency_ensure — the manifest-driven, ADDITIVE host-repair steps: install
# a declared-missing runtime dep, re-pin wp-cli, re-apply the OWASP CRS pin.
# Every one reads ONLY the run's verified manifest, is
# idempotent, is journaled OUTSIDE .packages.changed (a release rollback leaves them),
# and is non-fatal. They belong together because they share the ONE premise the whole
# class rests on (SC-543): an already-installed host never reruns the installer, so the
# signed update is the only vehicle that can carry a newly-required dep/pin to it.
#
# Called from BOTH stage_apt (the transaction paths) AND the noop path in run_stages.
# The noop call is the load-bearing one: a fully-patched box with no package or panel
# work skips stage_apt entirely (WORKSET_EMPTY -> noop), and that box — quiet, current,
# missing only the dep — is EXACTLY the host this must repair. Tying the delivery to
# stage_apt meant it fired only when some unrelated upgrade happened to ride along, so a
# steady-state host missing `dig` never self-healed (proved live on the dev VM: a
# scope=security noop left bind9-dnsutils uninstalled). Idempotent + non-fatal, so
# running it on the snapshot-less noop path carries no rollback obligation.
additive_currency_ensure() {
	runtime_deps_ensure
	wpcli_ensure_pin
	crs_ensure_pin
}

# UPD-2: apply the OS package upgrade for the request scope. Idempotent and
# resume-safe — it recomputes its own target set (apt state may have moved since
# preflight). `[R]`: a non-zero apt run marks the stage failed, which is the
# rollback trigger — run_stages calls auto_rollback for the `[R]` stage set.
stage_apt() {
	if ! osf_rollback_source_ensure; then
		stage_error apt "signed rollback source or pin could not be installed"
		return 1
	fi
	if [[ "$REQ_SCOPE" == security ]]; then
		local new_shcp_dep
		new_shcp_dep="$(security_new_shcp_deps_pending)" || {
			stage_error apt "security_dependency_unverified: the security transaction could not be resolved"
			return 1
		}
		if [[ -n "$new_shcp_dep" ]]; then
			stage_error apt "security_new_shcp_dependency: ${new_shcp_dep%%$'\n'*} requires an admin-approved update"
			return 1
		fi
	fi
	# Series-run hook: re-point the package manager at the target series before any
	# package work — deb rewrites the apt suite + re-renders the daemon pins, rpm
	# flips the dnf series var after proving the target leaf (series_repoint). No-op
	# for a routine run.
	if [[ "$SERIES_RUN" == "1" ]]; then
		if ! series_repoint; then
			case "$OS_FAMILY" in
				rpm) stage_error apt "series dnf series-var re-point failed" ;;
				*)   stage_error apt "series apt suite/pin rewrite failed" ;;
			esac
			return 1
		fi
		osf_rollback_source_ensure || { stage_error apt "rollback source could not follow the series pointer"; return 1; }
		# Latch that THIS run re-pointed the repo. A rollback reads this one boolean
		# (named for the deb original, family-neutral in meaning) to decide it must
		# revert the pointer from the config snapshot before the package downgrade
		# (SC-476) — a precise signal beats re-deriving the series relationship at
		# rollback time, where REQ_SERIES is not populated. Written after the re-point
		# succeeds, so it is never set for a run whose re-point failed. `from` is
		# informational and first-write-wins (a resumed run re-proves an already
		# flipped var and would otherwise record the target as its own origin).
		local series_mech=apt-suite
		[[ "$OS_FAMILY" == "rpm" ]] && series_mech=dnf-var
		journal_update "$CURRENT_RUN_ID" '.series = ((.series // {}) + {suite_rewritten: true, target: $s, mechanism: $m}
			+ (if $f == "" then {} else {from: (.series.from // $f)} end)
			+ (if $u == "" then {} else {probed_baseurl: $u} end))' \
			--arg s "$REQ_SERIES" --arg m "$series_mech" \
			--arg f "${SERIES_DNF_FROM:-}" --arg u "${SERIES_DNF_URL:-}"
		# F7: the suite pointer + pins now target the NEW series. stage_preflight's
		# osf_refresh_index ran against the OLD suite, so its work set and cache are
		# stale — apt would upgrade WITHIN the old suite and the cross-series jump
		# would silently not happen. Refresh here so apt_target_pkgs below recomputes
		# the work set against the new suite (§4.7: "rewrite -> apt-get update -> apt
		# stage in the new suite"). Unlike preflight, a failure here is fatal: there
		# is no recent-cache fallback for an index that must reflect a suite we only
		# just switched to.
		if ! osf_refresh_index; then
			stage_error apt "package index refresh after the series suite rewrite failed — cannot apply against the new suite"
			return 1
		fi
	fi

	local scope="$REQ_SCOPE"
	if [[ "$scope" == "panel" ]]; then
		# A panel-only run does no apt work, but a new panel release is exactly when a
		# newly-required runtime dep / bumped pin appears — deliver it before the panel
		# restarts. No apt_record_diff runs on this path, so nothing enters
		# .packages.changed and a later rollback leaves the additive repairs (SC-543).
		additive_currency_ensure
		STAGE_RESULT='{"skipped": "scope=panel — no OS package work"}'
		return 0
	fi

	local -a targets=()
	local p
	while IFS= read -r p; do [[ -n "$p" ]] && targets+=("$p"); done < <(apt_target_pkgs "$scope")

	if [[ ${#targets[@]} -gt 0 ]]; then
		# Per-package changelogs, best-effort (display-only; network may be flaky).
		#
		# SC-672: stop FETCHING once the cap is already
		# hit, not just before the final truncate. `${var//pat/}` below (now
		# `[[ $var == *pat* ]]`) is a red herring on its own — the real cost
		# was accumulating every package's changelog BEFORE ever looking at
		# the cap, so a wide upgrade set (90 packages, 22MB of concatenated
		# changelog on a real trixie node) ran bash's O(n^2) substitution over
		# the full blob, pegging a core for ~23min with no child process to
		# show for it (shcp-base#1346). Breaking here means the accumulated
		# text never grows past ~one package's worth beyond the cap.
		local changelog=""
		for p in "${targets[@]}"; do
			(( ${#changelog} > SHCP_CHANGELOG_MAX_BYTES )) && break
			changelog+="$(osf_changelog "$p")"$'\n'
		done
		# Linear scan for non-whitespace on the now-capped text — the
		# `${var//pat/}` form used here used to run BEFORE truncation, over
		# the full unbounded blob; see SC-672 above.
		if [[ $changelog == *[![:space:]]* ]]; then
			# SC-changelog-argv-e2big: truncate BEFORE it ever reaches jq's argv —
			# cut on the last newline inside the cap so the kept text stays
			# whole-line (and, since changelog text is ASCII, never splits a
			# multi-byte sequence either).
			if (( ${#changelog} > SHCP_CHANGELOG_MAX_BYTES )); then
				changelog="${changelog:0:SHCP_CHANGELOG_MAX_BYTES}"
				changelog="${changelog%$'\n'*}"$'\n... (truncated; see the OS package manager for the full changelog)'
			fi
			journal_update "$CURRENT_RUN_ID" '.changelog = $c' --arg c "$changelog"
		fi

		local -a essential_before=()
		mapfile -t essential_before < <(essential_installed)
		local apply_rc=0
		osf_apply "${targets[@]}" || apply_rc=$?
		if (( ${#OSF_HELD_BACK[@]} > 0 )); then
			journal_update "$CURRENT_RUN_ID" '.packages.held_back = $h' \
				--argjson h "$(printf '%s\n' "${OSF_HELD_BACK[@]}" | jq -Rsc 'split("\n") | map(select(length > 0) | split("\t") | {pkg: .[0], reason: .[1]})')"
		fi
		# #321: whatever the transaction did, the box must still carry every
		# essential package it had. Checked after a failure too: a partial apt run
		# can remove one before it aborts.
		local epkg
		for epkg in "${essential_before[@]+"${essential_before[@]}"}"; do
			osf_pkg_installed "$epkg" && continue
			engine_log "CRITICAL: essential package ${epkg} is not installed after the package transaction"
			apt_record_diff >/dev/null
			stage_error apt "essential package ${epkg} is not installed after the package transaction"
			return 1
		done
		if (( apply_rc != 0 )); then
			engine_log "apt apply failed for scope=${scope}"
			# Record the diff BEFORE returning. An aborted apt transaction can
			# leave PART of the set upgraded, and packages.changed is the only
			# thing the rollback has to learn which packages actually moved —
			# computing it only on the success path handed auto_rollback an empty
			# work list, from which it correctly concluded there was nothing to
			# undo while the box carried half a new package set.
			#
			# >/dev/null because the echo exists ONLY for the success path's
			# capture. Stage bodies run in the main shell with stdout untouched,
			# and stdout is reserved for the verbs' JSON emitters — an uncaptured
			# call here printed the packages-changed array immediately before
			# emit_run_result's object, producing two concatenated JSON documents
			# that no single-document reader can parse.
			apt_record_diff >/dev/null
			# OSF_TRANSACTION_DETAIL names a removal the #321 preview refused (both
			# families); APT_APPLY_DETAIL a refusal apt explained (e.g. a refused
			# downgrade set); otherwise the generic exit code. It survives
			# apt_record_diff above, which takes no apt_get_fetch path. Kept on the
			# stage_error line so the apt call-site linters exempt it.
			stage_error apt "${OSF_TRANSACTION_DETAIL:-${APT_APPLY_DETAIL:-apt-get exited non-zero for scope=${scope}}}"
			return 1
		fi
	else
		# An empty set here — despite a non-empty preflight work set that routed
		# us into this stage — means the packages were ALREADY applied: a prior
		# attempt that upgraded them, then crashed before journaling its result,
		# now resumed. Fall through and still record the diff (the pre-upgrade
		# baseline is preserved in packages.before from the first run's
		# preflight), so a crashed-but-applied run is not silently lost.
		engine_log "apt: no pending packages in scope=${scope} (already applied / converged)"
	fi

	# ALWAYS record the after-state diff, applied-this-attempt or not.
	local changed
	changed="$(apt_record_diff)"

	# shcp-updater#179: a series run is only a series run if the SHCP packages
	# actually moved. apt/dnf exiting 0 is not that proof — a hold the preflight
	# could not see, a missing arch build, an Obsoletes it declined all leave a
	# package on the old series with a clean exit, and health-green would then bump
	# the version floor over a half-jumped box. Failing here, with the diff already
	# recorded, hands the run to the auto-rollback.
	if [[ "$SERIES_RUN" == "1" ]]; then
		local conv_rc=0
		series_convergence_check || conv_rc=$?
		journal_update "$CURRENT_RUN_ID" '.series.convergence = $c' --argjson c "$SERIES_CONVERGENCE_JSON"
		if (( conv_rc != 0 )); then
			stage_error apt "$(jq -r '.reason' <<<"$SERIES_CONVERGENCE_JSON")"
			return 1
		fi
	fi

	STAGE_RESULT="$(jq -nc --argjson changed "$changed" \
		'{changed: ($changed | length), packages: [$changed[].pkg]}')"
	# last_security_success is stamped at the run's success-convergence point
	# (run_stages), not here — so a fully-patched NOOP run stamps it too (AD-6,
	# SC-319: a healthy idle host must not false-alarm "patching stalled").

	# Additive host-repair runs LAST — after apt_record_diff has snapshotted
	# .packages.changed — so an installed dep / re-pinned artifact is journaled to its
	# own key (deps_added / wpcli / crs) and never joins the rollback ledger
	# (SC-543/SC-485). Best-effort; never fails the stage.
	additive_currency_ensure
}
# UPD-3: install a new panel release blue/green (§4.2-4). Ordering is the whole
# design — everything that can fail cheaply happens BEFORE the maintenance flag
# goes up, so a bad release costs zero downtime:
#   resolve target → download+verify → extract → link → smoke
#   → [maintenance flag, stop workers] → promote → flip → helpers → restart
# Only the last four steps are inside the outage window, and the flip itself is
# a rename(2). `[R]`: a failure here triggers auto_rollback, which flips the
# symlink back to `.panel.previous_release_dir` (§4.5 step 3).
stage_panel() {
	# Recheck a known target on resume too, before anything touches the live
	# tree. Preflight normally proves this before the package stage.
	local console_target="$PANEL_CANDIDATE"
	if console_identity_required && [[ -z "$console_target" ]]; then
		console_target="$(jq -c '.console_identity_target // empty' "$(journal_path "$CURRENT_RUN_ID")" 2>/dev/null || true)"
		# Every run this engine starts persists the target at preflight, so a
		# journal that names a candidate yet carries no target is damaged or
		# foreign. Fail closed: require a fresh authenticated preflight rather
		# than deploy a transition nothing has proven.
		if [[ -z "$console_target" ]] && jq -e '.stages[]? | select(.stage == "preflight") | .result.panel_candidate | strings | length > 0' \
				"$(journal_path "$CURRENT_RUN_ID")" >/dev/null 2>&1; then
			STAGE_RESULT='{"failed":"resume lacks console compatibility evidence; start a fresh update to rerun authenticated preflight"}'
			return 1
		fi
	fi
	if [[ -n "$console_target" ]]; then
		local console_rc=0
		console_identity_target_preflight "$console_target" || console_rc=$?
		if [[ $console_rc != 0 ]]; then
			STAGE_RESULT="$(jq -nc --arg f "$(console_identity_refusal "$console_rc")" '{failed: $f}')"
			return 1
		fi
	fi

	if [[ "$REQ_SCOPE" == "packages" ]]; then
		STAGE_RESULT='{"skipped": "scope=packages — no panel work"}'
		return 0
	fi

	local current; current="$(panel_current_version)"

	# PREFLIGHT OWNS THE DECISION; this stage executes it. That split is what
	# keeps a manifest outage from turning an apt-only run into a failed (and
	# therefore rolled-back) one: preflight already degraded to "no panel
	# candidate" and recorded it, so re-deciding here — and failing — would
	# contradict a judgement that was made with the same information.
	local target="$PANEL_CANDIDATE"
	if [[ -z "$target" ]]; then
		# A resumed run is a different process with no PANEL_CANDIDATE, so recover
		# it from what preflight journaled rather than guessing.
		local recorded
		recorded="$(jq -r '(.stages[]? | select(.stage == "preflight") | .result.panel_candidate) // empty' \
			"$(journal_path "$CURRENT_RUN_ID")" 2>/dev/null || true)"
		if [[ -z "$recorded" ]]; then
			local refusal
			refusal="$(jq -r '[.stages[]? | select(.stage == "preflight") | .result.panel_updater_refusal] | last // empty' \
				"$(journal_path "$CURRENT_RUN_ID")" 2>/dev/null || true)"
			STAGE_RESULT="$(jq -nc --arg c "$current" --arg d "$refusal" \
				'{skipped: (if $d == "" then "preflight found no eligible panel release" else $d end), current: $c}')"
			return 0
		fi
		# Preflight DID find a candidate, so this run has panel work. Re-resolve
		# from the manifest it verified; only a resumed run whose copy is gone
		# refetches, and a failure there is a real failure.
		local manifest="${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json" own_manifest=0
		if [[ ! -s "$manifest" ]]; then
			manifest="$(mktemp)" || return 1
			own_manifest=1
			if ! manifest_fetch "$manifest"; then
				rm -f "$manifest"
				STAGE_RESULT='{"failed": "manifest unavailable or unverifiable"}'
				return 1
			fi
		fi
		target="$(panel_target "$manifest" "$current" "$REQ_SCOPE" "$REQ_TARGET_VERSION" "$REQ_SERIES" "$REQ_REINSTALL" || true)"
		[[ $own_manifest -eq 1 ]] && rm -f "$manifest"
		if [[ -z "$target" ]]; then
			# Two very different resumes land here, and they need opposite
			# handling. The panel symlink is the ground truth that separates them
			# (the same test stage_db / stage_republish_vhosts gate on):
			#
			#  1. The flip ALREADY completed. A kill between panel_flip and the
			#     `.panel.flipped=true` write — or before helpers_redeploy /
			#     verify_worker_redeploy finished — leaves the symlink pointing at
			#     the new release, so panel_current_version now equals the
			#     candidate and panel_target correctly finds nothing newer. But
			#     the post-flip redeploys may never have run: on a FIRST
			#     verify->verify transition that leaves the box routing
			#     VerifySmarthostMessage to `verify` with NO consumer, and health's
			#     regression-diff is blind to a same-release unit (the #563
			#     sequencing hazard). So re-assert them idempotently before
			#     returning — exactly what the forward path does at the flip site
			#     below. See SC-513.
			#  2. The candidate genuinely moved out from under us (a manifest that
			#     changed, or a request pin lost across the resume): the symlink
			#     still points at the OLD release, nothing flipped, and skipping
			#     is right.
			local jf intent_dir intent_flipped
			jf="$(journal_path "$CURRENT_RUN_ID")"
			intent_dir="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
			intent_flipped="$(jq -r '.panel.flipped // false' "$jf" 2>/dev/null || true)"
			if [[ -n "$intent_dir" && "$intent_flipped" != "true" ]] \
					&& panel_link_points_at "$intent_dir"; then
				engine_log "panel: resume found the flip to ${intent_dir} applied but not finalized — re-asserting post-flip redeploys"
				# A reboot after the flip can auto-start enabled consumers. Restore the
				# forward path's quiesced state before overwriting their units.
				panel_timers_hold
				if ! panel_workers_stop; then
					STAGE_RESULT='{"failed": "could not quiesce every shared Messenger consumer before resume reassertion"}'
					return 1
				fi
				# Best-effort exactly as at the flip site: a helper that will not
				# restart is health's problem, not a rollback trigger — but it is
				# carried in the result, never silently healthy.
				local reassert_helpers_ok=true
				helpers_redeploy "$intent_dir" \
					|| { reassert_helpers_ok=false; engine_log "panel: helper redeploy reported a failure"; }
				# FATAL exactly as at the flip site: a box that cannot bring the
				# verify consumer up rolls back. Both redeploys are idempotent —
				# install(1) overwrite, systemctl enable, restart — so re-running
				# them when they were already done is a clean no-op.
				verify_worker_redeploy "$intent_dir" || return 1
				verify_worker_checkpoint forward-redeploy || return 1
				license_recover_install "$intent_dir" || return 1
				managed_workers_install "$intent_dir" || return 1
				# Record the completion the crash prevented, so the journal matches
				# the filesystem and any further resume skips this stage.
				journal_update "$CURRENT_RUN_ID" '.panel.flipped = true'
				STAGE_RESULT="$(jq -nc --arg d "$intent_dir" --arg c "$current" \
					'{reasserted: "flip already applied on a prior attempt — re-ran post-flip redeploys", release_dir: $d, current: $c}')"
				[[ "$reassert_helpers_ok" == true ]] || STAGE_RESULT="$(panel_helpers_degrade "$STAGE_RESULT")"
				return 0
			fi
			# Preflight said there was a candidate and now there is not: the
			# manifest moved under us. Skipping is right (nothing is eligible),
			# but say WHY rather than reporting the quiet no-op above.
			STAGE_RESULT="$(jq -nc --arg c "$current" --arg r "$recorded" \
				'{skipped: "candidate no longer eligible", expected: $r, current: $c}')"
			return 0
		fi

		# The re-resolve must land on the SAME version preflight approved (SC-420:
		# emptiness is not a sufficient check — a re-resolve returning a
		# DIFFERENT installable release is the dangerous case).
		# Emptiness was the only thing checked here before, so a re-resolve that
		# returned a DIFFERENT installable release was downloaded, verified and
		# flipped with no discrepancy recorded anywhere — `recorded` was used
		# only in the error message of the branch not taken. Losing a request
		# pin across a resume is one way to get here; a manifest that moved
		# under us is another. Either way, installing a release this run never
		# approved is worse than failing.
		local resolved_version
		resolved_version="$(jq -r '.version // empty' <<<"$target")"
		if [[ "$resolved_version" != "$recorded" ]]; then
			engine_log "panel: re-resolved ${resolved_version} but preflight approved ${recorded} — refusing"
			STAGE_RESULT="$(jq -nc --arg got "$resolved_version" --arg want "$recorded" \
				'{failed: "re-resolved panel target does not match the one preflight approved",
				  expected: $want, resolved: $got}')"
			return 1
		fi
	fi

	local version url sig_url sha256
	version="$(jq -r '.version' <<<"$target")"
	url="$(jq -r '.url' <<<"$target")"
	sig_url="$(jq -r '.sig_url' <<<"$target")"
	sha256="$(jq -r '.sha256' <<<"$target")"
	engine_log "panel: ${current:-unknown} -> ${version}"

	# The path console preflight fetched to, so the transfer is reused when the
	# bytes are still there — this run or a resume of it (shcp-updater#136).
	# Never fall back to a path outside that check: this is the tarball that gets
	# extracted and flipped.
	local tarball
	if ! tarball="$(panel_candidate_cache_path "$version")"; then
		STAGE_RESULT="$(jq -nc --arg v "$version" 			'{failed: "run download directory is not root-owned, or the target version is malformed", version: $v}')"
		return 1
	fi
	if ! artifact_fetch_verify "$url" "$sig_url" "$sha256" "$tarball"; then
		STAGE_RESULT="$(jq -nc --arg v "$version" '{failed: "artifact verification failed", version: $v}')"
		return 1
	fi

	# Stage beside the final path, on the SAME filesystem, so promotion is a
	# rename and not a copy. A leftover staging dir from a crashed attempt is
	# removed rather than merged — a half-extracted tree is not a base to build on.
	local final="${RELEASES_DIR}/${version}"
	local staging="${final}.staging"
	mkdir -p "$RELEASES_DIR" || { artifact_close; return 1; }
	# A reinstall killed between its two renames left the live tree under the
	# aside name and the panel symlink dangling. Put it back before anything else.
	if ! panel_promote_recover "$final"; then
		artifact_close
		STAGE_RESULT="$(jq -nc --arg d "$final" '{failed: ("a previous reinstall left " + $d + ".reinstall-prior behind and it could not be moved back")}')"
		return 1
	fi
	rm -rf "$staging"
	mkdir -p "$staging" || { artifact_close; return 1; }
	# --no-same-owner: the archive's recorded uids mean nothing here, and honouring
	# them would scatter foreign ownership through the release tree. Extract the
	# inode artifact_fetch_verify checked: $tarball sits under the panel-owned
	# /var/lib/shcp and may name other bytes by now.
	local extract_rc=0
	tar xzf "$ARTIFACT_HELD" -C "$staging" --no-same-owner || extract_rc=$?
	artifact_close
	if (( extract_rc != 0 )); then
		engine_log "panel: extraction failed"
		rm -rf "$staging"
		STAGE_RESULT='{"failed": "extraction failed"}'
		return 1
	fi
	local target_receivers
	if ! console_identity_release_gate "$staging"; then
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"extracted release lacks valid console identity metadata/resolver"}'
		return 1
	fi
	# Provision for old-wrapper/new-base upgrades as well, so a later shcpd
	# package upgrade can activate routing without needing an installer rerun.
	if console_identity_release_valid "$staging" && ! console_environment_install; then
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"console identity environment prerequisites are missing or unsafe"}'
		return 1
	fi
	target_receivers="$(jq -c '.receivers // []' <<<"$target")"
	if ! upload_cleanup_artifact_agrees "$staging" "$target_receivers"; then
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"authenticated manifest receiver declaration disagrees with extracted cleanup route/unit"}'
		return 1
	fi
	if ! verify_worker_release_state "$staging" >/dev/null; then
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"extracted verify route/unit contract is malformed or unsupported"}'
		return 1
	fi

	release_link_env "$staging" || { rm -rf "$staging"; STAGE_RESULT='{"failed": "env link failed"}'; return 1; }

	# Refuse to flip into a release with no environment at all.
	#
	# The installer mints every secret into the canonical env and the staged
	# tree carries no .env.local of its own (package.sh strips it), so a box
	# that has lost /etc/shcp/panel.env would flip onto the tarball's `.env`
	# defaults: a published constant APP_SECRET, an empty EDGE_PROXY_SECRET
	# (SC-423), and undecryptable backup and smarthost credentials. panel_smoke
	# below cannot catch it, because those defaults are complete enough for the
	# container to build and `about` to exit 0.
	#
	# Failing the stage here costs an update; flipping costs the box's secrets.
	if [[ ! -e "$PANEL_ENV" ]]; then
		engine_log "panel: refusing to flip — no canonical env at ${PANEL_ENV} and the staged release carries none"
		rm -rf "$staging"
		STAGE_RESULT="$(jq -nc --arg p "$PANEL_ENV" \
			'{failed: ("no panel environment at " + $p + " — refusing to flip onto tarball defaults (a published APP_SECRET and an empty EDGE_PROXY_SECRET). Restore it from /root/shcp/credentials or re-run the installer.")}')"
		return 1
	fi

	release_link_var "$staging" || { rm -rf "$staging"; STAGE_RESULT='{"failed": "var link failed"}'; return 1; }
	if ! release_secure_ownership "$staging"; then
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"could not enforce release application ownership on the staged release"}'
		return 1
	fi
	release_selinux_restore "$staging" R
	# release_secure_ownership covers unit sources and helper executables along
	# with the rest of the root-executed application tree.
	upload_cleanup_snapshot_once || {
		rm -rf "$staging"
		STAGE_RESULT='{"failed": "upload cleanup unit ownership snapshot refused"}'
		return 1
	}
	verify_worker_snapshot_once || {
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"verify worker unit ownership snapshot refused"}'
		return 1
	}
	license_recover_snapshot_once "$staging" || {
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"license recovery unit ownership snapshot refused"}'
		return 1
	}
	panel_workers_snapshot_once || {
		rm -rf "$staging"
		STAGE_RESULT='{"failed":"complete Messenger worker state could not be inventoried and journaled"}'
		return 1
	}

	# --- outage window opens here ---------------------------------------------
	# schema-pending: the flip below puts the NEW panel on the OLD DB. The panel
	# gates reads to 503 (not 500) until stage_db drops the marker after
	# schema:update. SC-update-schema-pending-window-safe.
	panel_timers_hold
	maintenance_write "$CURRENT_RUN_ID" "schema-pending"
	# shcp-verify-worker is deliberately NOT stopped here. Unlike shcp-worker /
	# shcp-backup-worker, which mutate the system as root and must not run against a
	# half-swapped tree, the verify worker only unseals one credential and opens one
	# outbound SMTP session — running it across the flip is harmless. Stopping it
	# here WITHOUT a paired restart on every pre-flip failure path (panel_workers_start
	# covers only the two main workers) would leave it dead after a failed update on a
	# box whose panel routes verify -> verify: the #563 hazard, re-created on the
	# failure path. verify_worker_redeploy's `enable --now` below is the first-transition
	# bring-up; health_unit_list catches a later-run death. See SC-513.
	if ! panel_workers_stop; then
		panel_preflip_abort "$final"
		STAGE_RESULT='{"failed": "could not quiesce every shared Messenger consumer"}'
		return 1
	fi
	# Every failure path below, up to a successful flip, MUST call
	# panel_preflip_abort before returning: it restarts the workers, restores a
	# reinstall's prior tree and drops the schema-pending gate this stage raised.

	local previous=""
	if [[ -L "$PANEL_LINK" ]]; then
		previous="$(readlink "$PANEL_LINK")"
	fi

	if ! panel_promote_release "$staging" "$final"; then
		engine_log "panel: promoting the staged release failed"
		rm -rf "$staging"
		# The stage has already failed; a worker that also will not start is
		# reported by the health stage, not by this recovery attempt.
		panel_preflip_abort "$final"
		STAGE_RESULT='{"failed": "promotion failed"}'
		return 1
	fi

	# Rebuild + smoke at the FINAL path, after the rename and before the flip.
	# The first placement warmed in ${final}.staging and a compiled container
	# is path-faithful to wherever warmup ran: after the rename every baked
	# absolute path dangled, FlockStore mkdir'd the stale .staging tree back
	# into existence, and the panel 500'd while run 20260816-114414 read
	# healthy off an already-degraded baseline. Warming here costs ~30s of
	# outage window; warming anywhere else costs correctness. The smoke rides
	# after it because the container it validates must be the one the box
	# boots. A failure leaves the tree for the auto-rollback (the flip has not
	# been recorded, so stage_db and stage_restart never gate on it) and for
	# forensics — post-rename, rm would fight the rollback's flip-back target
	# in the reinstall case.
	if ! panel_cache_rebuild "$final"; then
		engine_log "panel: cache rebuild failed at ${final} — not flipping"
		panel_preflip_abort "$final"
		STAGE_RESULT="$(jq -nc --arg v "$version" 			'{failed: "release cache rebuild failed at the final path — the shipped compiled container was discarded and warmup on this box did not produce a replacement", version: $v}')"
		return 1
	fi
	if ! panel_smoke "$final"; then
		engine_log "panel: smoke test failed on the rebuilt release — not flipping"
		panel_preflip_abort "$final"
		STAGE_RESULT="$(jq -nc --arg v "$version" '{failed: "release failed its post-rebuild smoke test", version: $v}')"
		return 1
	fi
	# The re-extracted tree is warmed and smoke-tested at the live path: the tree it
	# replaced is no longer a better rollback target than it, so it goes now.
	if ! panel_promote_commit "$final"; then
		panel_preflip_abort "$final"
		STAGE_RESULT='{"failed": "could not commit the reinstall swap"}'
		return 1
	fi
	# Record the flip INTENT before performing it. `.panel` is the signal stage_db
	# and stage_restart gate on, and it used to be written only after
	# helpers_redeploy — several service restarts and a daemon-reload later. A
	# kill -9 in that window (power loss, or the unit's TimeoutStartSec
	# SIGKILL) left /opt/shcp pointing at the NEW release with `.panel` still
	# null. On resume, stage_panel re-reads the version, now finds the target
	# already current, and correctly reports "nothing eligible" — so the flip was
	# never recorded at all. stage_db then skipped the migration, stage_restart
	# skipped the shcpd restart, finalize cleared maintenance, and the run
	# finished HEALTHY with new code permanently serving an old schema.
	#
	# Writing it first inverts the failure: a crash now leaves evidence that a
	# flip was attempted, and `flipped` distinguishes "attempted" from "done".
	journal_update "$CURRENT_RUN_ID" '.panel = $p' --argjson p "$(jq -nc \
		--arg from "$current" --arg to "$version" --arg prev "$previous" --arg dir "$final" \
		'{from: (if $from == "" then null else $from end), to: $to,
		  previous_release_dir: (if $prev == "" then null else $prev end),
		  release_dir: $dir, flipped: false}')"

	if ! panel_flip "$final"; then
		# The symlink still points at the previous release, so the panel is
		# intact — only the new tree is orphaned, and finalize's retention
		# prunes it. Drop the intent record: nothing moved, so the later stages
		# must not believe a flip happened.
		engine_log "panel: symlink flip failed"
		journal_update "$CURRENT_RUN_ID" '.panel = null'
		# The stage has already failed; a worker that also will not start is
		# reported by the health stage, not by this recovery attempt.
		panel_preflip_abort "$final"
		STAGE_RESULT='{"failed": "symlink flip failed"}'
		return 1
	fi

	# Best-effort (a helper that will not redeploy is not a rollback trigger),
	# but NEVER silent: a key that could not be reconciled leaves that helper at
	# its prior version, and the stage result says so (degraded + detail).
	local helpers_ok=true
	helpers_redeploy "$final" || { helpers_ok=false; engine_log "panel: helper redeploy reported a failure"; }

	# Install and enable the smarthost verify worker from the just-flipped release.
	# It remains stopped; stage_restart/panel_workers_start is the only activation
	# seam after schema and shared-table verification.
	# helpers_redeploy above, a failure here is FATAL: the new panel routes
	# VerifySmarthostMessage -> `verify`, so a box that cannot start the consumer
	# would hang the relay-verify button. panel is in the [R] set, so `return 1`
	# here triggers auto_rollback (which flips back and restarts the workers via
	# rollback_run). See SC-513.
	verify_worker_redeploy "$final" || return 1
	verify_worker_checkpoint forward-redeploy || return 1
	license_recover_install "$final" || return 1

	# Install + enable the three auxiliary workers from the just-flipped release,
	# each left STOPPED — stage_restart starts them after stage_db (see managed_workers_install/activate).
	# FATAL like verify (SC-513): the panel routes DeliverWebhookMessage / the mailhealth
	# samples / ImportAccountMessage onto these consumers' transports, and the health-stage
	# unit diff cannot see a unit introduced by the same release, so a bring-up failure
	# must roll back rather than finalize a box whose new routing has no consumer.
	managed_workers_install "$final" || return 1

	# .panel is what stage_restart reads to decide the unconditional shcpd
	# restart, and what rollback reads for the flip-back target.
	local panel_json
	panel_json="$(jq -nc --argjson flipped true --arg from "$current" --arg to "$version" \
		--arg prev "$previous" --arg dir "$final" \
		'{from: (if $from == "" then null else $from end), to: $to,
		  previous_release_dir: (if $prev == "" then null else $prev end),
		  release_dir: $dir, flipped: $flipped}')"
	journal_update "$CURRENT_RUN_ID" '.panel = $p' --argjson p "$panel_json"
	STAGE_RESULT="$panel_json"
	[[ "$helpers_ok" == true ]] || STAGE_RESULT="$(panel_helpers_degrade "$STAGE_RESULT")"
	return 0
}

# panel_helpers_degrade <result-json> — fold a helpers_redeploy shortfall into a
# panel stage result: degraded, with HELPERS_SHORTFALLS as the detail, so an
# operator reading the journal sees WHICH helper stayed at its prior version and
# why, instead of a healthy-looking flip over a daemon the next reboot breaks.
panel_helpers_degrade() {
	local shortfalls
	shortfalls="$(printf '%s\n' "${HELPERS_SHORTFALLS[@]+"${HELPERS_SHORTFALLS[@]}"}" \
		| jq -R -s 'split("\n") | map(select(length > 0))')"
	jq -c --argjson s "$shortfalls" \
		'. + {degraded: true, helpers_degraded: true, helpers_shortfalls: $s,
		      detail: "helper daemon redeploy incomplete — the named helpers were NOT installed or restarted (see helpers_shortfalls)"}' \
		<<<"$1"
}

# UPD-4 (§4.2-5): run the NEW release's schema-upgrade command. The only stage
# that can leave the product in a state no artifact can rebuild, so `[R]`: a
# non-zero exit marks the stage failed, which is the auto-rollback trigger (the
# rollback restores the snapshot through db_restore_from_snapshot above, gated on
# this stage's db-update.log actually existing).
stage_db() {
	local jf; jf="$(journal_path "$CURRENT_RUN_ID")"

	# §4.2-5 "only if panel stage ran". The gate is NOT `.panel != null` and NOT
	# `.panel.flipped == true` — it is "the symlink actually resolves to the
	# release .panel names". Both simpler tests are wrong, in opposite directions:
	#
	#   .panel != null       — since the intent record moved to BEFORE the flip,
	#                          this means "a flip was ATTEMPTED". A crash in the
	#                          ~5 ms between the intent write and the rename, then
	#                          a resume whose stage_panel legitimately skips
	#                          ("candidate no longer eligible" — a resumed process
	#                          has no REQ_SERIES, so panel_target re-derives from
	#                          the RUNNING version), leaves that record standing
	#                          while /opt/shcp still points at the OLD release.
	#                          stage_db would then migrate the schema forward with
	#                          the new release's console while the old code
	#                          serves — silent, unrecoverable schema drift,
	#                          reported healthy.
	#   .panel.flipped==true — re-opens the wider bug the intent record was added
	#                          to close: a kill during helpers_redeploy leaves the
	#                          NEW symlink live with flipped still false, and the
	#                          migration would be skipped.
	#
	# Asking the filesystem answers both, because it is the ground truth neither
	# journal field can be out of step with.
	local panel_target_dir
	panel_target_dir="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	if [[ -z "$panel_target_dir" ]]; then
		STAGE_RESULT='{"skipped": "panel stage did not run — no schema change to apply"}'
		return 0
	fi
	if ! panel_link_points_at "$panel_target_dir"; then
		engine_log "db: panel stage did not complete its flip (${PANEL_LINK} is not ${panel_target_dir}) — not migrating"
		STAGE_RESULT="$(jq -nc --arg want "$panel_target_dir" --arg have "$(panel_link_target)" \
			'{skipped: "panel flip did not complete — migrating would drift the schema past the running code",
			  expected_release: $want, active_release: $have}')"
		return 0
	fi

	# Guarantee our OWN precondition rather than inheriting it. stage_panel stops
	# the workers, but this stage does not always run in the same process — or
	# even the same boot — as stage_panel. A run killed after the flip leaves
	# `resume` to re-enter here, and by then systemd has started shcp-worker
	# again, so the schema DDL would run against a live root worker consuming
	# messages on the same WAL file: `database is locked` mid-DDL leaves a
	# half-applied schema, and handlers execute against a schema mutating
	# underneath them.
	#
	# This also repairs a false assurance on the panel side: shcp:update:db's
	# only worker check is "somebody holds the updater lock exclusively", which
	# it reads as "the workers are stopped". The lock is taken at the top of
	# cmd_apply, minutes before any worker is stopped, so it never proved that.
	# Stopping them here is what finally makes the command's inference true.
	# Idempotent — systemctl stop on an inactive unit is a no-op.
	# stage_restart starts them again (§4.2-6); a `systemctl stop` is deliberate,
	# so Restart=always does NOT bring them back on its own.
	panel_workers_stop || {
		stage_error db "could not verify every shared Messenger consumer inactive"
		return 1
	}
	# The same for the timers: a reboot inside the window started every one of
	# them again (shcp-updater#302).
	panel_timers_hold

	# AD-7: the NEW release's console, not the running one — the migration that
	# has to run is the one the new code expects.
	local release
	release="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	[[ -n "$release" ]] || release="$PANEL_LINK"
	local console="${release}/bin/console"

	if [[ ! -x "$SHCPD_BIN" ]]; then
		engine_log "db: ${SHCPD_BIN} is not executable"
		stage_error db "${SHCPD_BIN} is not executable"
		return 1
	fi
	if [[ ! -f "$console" ]]; then
		engine_log "db: no console in the new release (${console})"
		stage_error db "no console at ${console}"
		return 1
	fi

	# Which branch shcp:update:db will take is decided by whether the migrations
	# bundle is present in the release it runs from — the SAME predicate the
	# command itself branches on (UPD-4 task 2: "branch on bundle presence, so
	# the seam needs no flag-day"). Observed from the tree rather than parsed out
	# of the command's output, so the engine invents no cross-repo output
	# contract for a value the journal contract requires it to report.
	# Spelled as an `if` rather than `[[ … ]] && x=y` on purpose: the latter
	# returns 1 when the directory is absent, which is fine while the runner
	# calls stage bodies in a condition context (errexit suspended) and a
	# landmine the day something calls stage_db directly.
	local strategy=schema-update
	if [[ -d "${release}/vendor/doctrine/doctrine-migrations-bundle" ]]; then
		strategy=migrations
	fi

	local db="$SHCP_UPDATE_DB"
	local fk_before
	fk_before="$(jq -r '(.stages[]? | select(.stage == "snapshot")
		| .result.foreign_key_check_before) // empty' "$jf" 2>/dev/null || true)"

	# Nothing else may open the DB while shcp:update:db rewrites the schema. The
	# workers are down (above) and shcpd serves 503 behind the schema-pending gate,
	# but the SC-236 outbound policy socket is a SEPARATE opener: an authenticated
	# submission starts a policy process that queries the DB directly, bypassing the
	# HTTP gate, and a query mid-DDL answers "mailbox not found" -> DUNNO (no rate
	# limit) for every sender. Stop it for the migration and FAIL CLOSED (-> the [R]
	# auto-rollback) if it will not stop. The resume is deliberately at the END of the
	# stage: on any failure below the socket is left DOWN (fail closed) and the
	# rollback's db_restore brings it back over a consistent, restored DB.
	if ! outbound_policy_socket_quiesce; then
		stage_error db "outbound policy socket could not be stopped for the migration (DUNNO-for-everyone risk, SC-236)"
		return 1
	fi

	# stdout is the --json report the stage decides on (SC-782: an exit status
	# alone is not proof the command ran). The log is stderr, then the report.
	local out="${RUNS_DIR}/${CURRENT_RUN_ID}/db-update.log"
	local report="${RUNS_DIR}/${CURRENT_RUN_ID}/db-update.json"
	engine_log "db: ${strategy} via ${console}"
	local rc=0
	( cd "$release" && "$SHCPD_BIN" php-cli "$console" shcp:update:db \
		--env=prod --no-interaction --json ) >"$report" 2>"$out" || rc=$?
	chmod 0600 "$out" "$report" 2>/dev/null || true
	cat -- "$report" >>"$out" 2>/dev/null || true
	# A root console run creates root-owned WAL/SHM sidecars beside the panel DB
	# and can rewrite the file itself as root; the FrankenPHP daemon runs as the
	# panel user and could then never write it again. The installer documents
	# this hazard and re-chowns after its own root console calls — stage_db is
	# where it actually happens during an update.
	db_fix_ownership "$db"

	# §4.2-5 "capture executed SQL/migrations to journal". The full transcript
	# stays on disk (see MAX_DB_LOG_BYTES) and the journal carries a capped,
	# ASCII-clamped excerpt — clamped because head -c can cut a multi-byte
	# sequence in half and jq refuses invalid UTF-8 in --arg.
	local sql
	sql="$(jq -r '(.statements // [])[], (.migrations_output // empty)' "$report" 2>/dev/null \
		| head -c "$MAX_DB_LOG_BYTES" | tr -cd '\11\12\15\40-\176' || true)"

	if [[ $rc -ne 0 ]]; then
		local why
		why="$(jq -r '.error // empty' "$report" 2>/dev/null || true)"
		[[ -n "$why" ]] || why="$(tail -n 5 "$out" 2>/dev/null || true)"
		why="$(printf '%s' "$why" | tr -cd '\11\12\40-\176' | tr '\n' ' ' || true)"
		engine_log "db: shcp:update:db exited ${rc}"
		stage_error db "shcp:update:db exited ${rc}: ${why}"
		return 1
	fi
	if ! console_update_db_ok <"$report"; then
		engine_log "db: shcp:update:db exited 0 without a success report"
		stage_error db "shcp:update:db exited 0 without a success report (SC-782)"
		return 1
	fi

	# schema:update succeeded — the DB now carries the new columns, so the new
	# panel code no longer 500s against it. Drop the schema-pending marker to
	# lift the panel's GET-gate for the remaining converge stages; the flag
	# itself stays up (banner) until finalize. On the FAILURE path above we do
	# NOT rewrite: the EXIT trap leaves the marker in place so the panel stays
	# fail-closed at 503, never 500 (SC-update-schema-pending-window-safe).
	maintenance_write "$CURRENT_RUN_ID" ""

	local integrity="" fk=""
	if [[ -f "$db" ]]; then
		integrity="$(db_integrity "$db")"
		fk="$(db_foreign_keys "$db")"
		db_fix_ownership "$db"
		if [[ "$integrity" != "ok" ]]; then
			engine_log "db: post-migration integrity_check: ${integrity}"
			stage_error db "post-migration integrity_check: ${integrity}"
			return 1
		fi
		# An orphaned row that was ALREADY there before the migration is not
		# this migration's doing and a rollback would not fix it. One that
		# appeared during the migration is exactly the `[R]` case, so it only
		# fails the stage when the snapshot stage recorded a clean pre-state.
		if [[ "$fk" != "ok" && "$fk_before" == "ok" ]]; then
			engine_log "db: the schema change orphaned rows: ${fk}"
			stage_error db "the schema change orphaned rows (foreign_key_check: ${fk})"
			return 1
		fi
	fi

	# The migration succeeded and the DB verified consistent -- policy checks may run
	# against it again. Every failure path above returns before here, leaving the
	# socket down (fail closed) for the auto-rollback to restart over the restored DB.
	outbound_policy_socket_resume >/dev/null 2>&1 \
		|| engine_log "db: outbound policy socket did not restart after the migration; authenticated submission fails closed (451) until it is started"

	if ! upload_cleanup_install_for_release "$release"; then
		stage_error db "cleanup consumer unit could not be installed and verified after schema verification"
		return 1
	fi

	STAGE_RESULT="$(jq -nc --arg s "$strategy" --arg i "$integrity" --arg fk "$fk" \
		--arg log "$out" --arg sql "$sql" \
		'{strategy: $s,
		  integrity: (if $i == "" then null else $i end),
		  foreign_key_check: (if $fk == "" then null else $fk end),
		  log: $log,
		  sql: (if $sql == "" then null else $sql end)}')"
	# The schema is the one the live code expects: the timers may run again.
	# Not earlier — a failure above goes to rollback with them still held.
	panel_timers_release || true
	return 0
}

# --- republish-vhosts (WEB-5 / SC-436) + override-dir reap (SC-525) -------------
# This stage runs the two post-upgrade Apache-state reconcilers that share its
# exact envelope (panel-flip gate, new-release console, workers stopped, advisory/
# never-rollback): shcp:apache:republish-vhosts (below) and, after it,
# shcp:apache:reconcile-overrides (the orphan override-dir reap — see its inline
# block). Both fold their verdict into this stage's result; neither is in the [R]
# rollback set.
#
# Deliver the WEB-5 tenant-vhost fix to sites that already existed before it
# landed. WEB-5 fixed the GENERATOR (a tenant vhost stopped emitting a per-vhost
# TLS/header block that WEAKENED the global baseline — SSLHonorCipherOrder off, a
# CHACHA20/DHE-less cipher list, a shorter HSTS), but a vhost file is written
# once, at the site's create/update, and nothing re-renders it afterwards. So on
# a box with pre-WEB-5 sites the hardened baseline reaches only sites an operator
# happens to edit later — and the day the default-vhost address shadowing is
# removed (shcp-master#316 / installer#135), every un-republished tenant *:443
# vhost starts serving its OLD weak block at once. This stage runs the existing,
# tested shcp:apache:republish-vhosts once per panel upgrade so the fix reaches
# the server, not just the generator.
#
# ADVISORY, NEVER ROLLBACK. The command exits non-zero when it leaves a site on
# the old config, but a hardening sweep that stumbles on a few sites must not
# roll the whole update back to the OLD release — that would revert the very
# generator fix this stage exists to deliver, which is strictly worse than a
# handful of un-republished vhosts an operator can re-run the command against. So
# every reachable-panel path returns 0; a non-zero exit or a timeout is journaled
# as `degraded` and shouted into the system journal, and the stage is
# deliberately ABSENT from run_stages' [R] rollback set (apt|panel|db|restart).
#
# POST-SWEEP CONFIGTEST (#38 / SC-436). The sweep is soft-bounded, so a timeout
# can kill it after a per-host enableVhost but before that host's own configtest,
# leaving a vhost enabled-but-untested that nothing downstream would catch. A
# single global `apachectl configtest` after the sweep closes that window: a
# non-parsing config becomes `degraded` here (still never a rollback) instead of
# a surprise at the box's next Apache reload.
stage_republish_vhosts() {
	local jf; jf="$(journal_path "$CURRENT_RUN_ID")"

	# The flip-gate is stage_db's, verbatim in spirit: run ONLY when the panel
	# symlink actually resolves to the release this run deployed. A security- or
	# packages-scope run performs no flip, so this skips for free — which is what
	# keeps the panel-independent security path (SC-319) invoking no console.
	# Asking the filesystem, not `.panel`/`.panel.flipped`, is the ground truth
	# neither journal field can be out of step with (see stage_db's header).
	local panel_target_dir
	panel_target_dir="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	if [[ -z "$panel_target_dir" ]]; then
		STAGE_RESULT='{"skipped": "panel stage did not run — no new vhost baseline to deliver"}'
		return 0
	fi
	if ! panel_link_points_at "$panel_target_dir"; then
		engine_log "republish-vhosts: panel flip did not complete (${PANEL_LINK} is not ${panel_target_dir}) — not republishing"
		STAGE_RESULT="$(jq -nc --arg want "$panel_target_dir" --arg have "$(panel_link_target)" \
			'{skipped: "panel flip did not complete — republishing would run against a release the symlink does not serve",
			  expected_release: $want, active_release: $have}')"
		return 0
	fi

	# AD-7, same as stage_db: the NEW release's console, because the fix we are
	# delivering lives in the new code, not the one that was running.
	local release
	release="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	[[ -n "$release" ]] || release="$PANEL_LINK"
	local console="${release}/bin/console"

	if [[ ! -x "$SHCPD_BIN" || ! -f "$console" ]]; then
		# stage_db ran a moment ago with this exact console, so this is close to
		# impossible; being advisory, treat it as degraded rather than failing an
		# update that is otherwise complete.
		engine_log "republish-vhosts: no usable console (${SHCPD_BIN} / ${console}) — skipping the sweep"
		STAGE_RESULT="$(jq -nc --arg c "$console" \
			'{degraded: true, detail: ("no usable console at " + $c)}')"
		return 0
	fi

	# Match stage_db's precondition rather than inherit it: a resume after a reboot
	# re-enters here with shcp-worker started again by systemd, and the command's
	# orphan-DNS reap writes the panel DB — a concurrent worker writing the same
	# WAL is the race stage_db stops the workers to avoid. Idempotent; stage_restart
	# starts them again, and a deliberate stop is not undone by Restart=always.
	if ! panel_workers_stop; then
		STAGE_RESULT='{"degraded":true,"detail":"could not verify every shared Messenger consumer inactive"}'
		return 0
	fi

	# The command opens the panel DB READ-WRITE as root (it iterates active hosts
	# through Doctrine and reaps orphan managed DNS rows), so — exactly like
	# stage_db and health_panel_probe — it re-materializes root-owned
	# -wal/-shm/-journal sidecars beside the panel DB. shcpd starts as the panel
	# user in stage_restart and could then never write its own database. So
	# db_fix_ownership MUST run on every exit path below, success or not.
	local out="${RUNS_DIR}/${CURRENT_RUN_ID}/republish.log"
	engine_log "republish-vhosts: sweeping active tenant vhosts via ${console}"
	local rc=0
	( cd "$release" && run_bounded "$REPUBLISH_TIMEOUT" \
		"$SHCPD_BIN" php-cli "$console" shcp:apache:republish-vhosts \
		--env=prod --no-interaction ) >"$out" 2>&1 || rc=$?
	chmod 0600 "$out" 2>/dev/null || true

	# ORPHAN OVERRIDE-DIR REAP (SC-525, shcp-base#878). Co-located here rather than
	# in its own stage because it shares this stage's whole envelope verbatim: it
	# runs ONLY after the panel flip (the gate above), against the NEW release's
	# console, with the shared Messenger consumers stopped, and it is ADVISORY —
	# never a rollback (reverting to the old release cannot un-orphan a dir, and
	# would drop the panel upgrade). shcp:apache:reconcile-overrides reaps orphan
	# /etc/shcp/apache-user.d/vhost-<id>/ dirs whose web host no longer exists; an
	# orphan holding a guarded .conf makes ApacheOverrideGuard refuse EVERY tenant's
	# Apache/certbot reload — a cross-tenant DoS the panel upgrade is the natural
	# moment to clear. Idempotent and fail-closed (an empty/failed live-host set
	# reaps nothing), so re-running it on a resume is safe. Its exit code is
	# advisory only: non-zero means an orphan persists (a DoS the operator must
	# clear), degraded here, never a rollback. Adding a STAGES entry would force a
	# byte-identical engine-stages.json + panel translation landing in shcp-base;
	# folding it into this stage keeps the reap panel-side-contract-free.
	local ovr_out="${RUNS_DIR}/${CURRENT_RUN_ID}/reconcile-overrides.log"
	local ovr_rc=0
	engine_log "republish-vhosts: reaping orphan Apache override dirs via ${console}"
	( cd "$release" && run_bounded "$REPUBLISH_TIMEOUT" \
		"$SHCPD_BIN" php-cli "$console" shcp:apache:reconcile-overrides \
		--env=prod --no-interaction ) >"$ovr_out" 2>&1 || ovr_rc=$?
	chmod 0600 "$ovr_out" 2>/dev/null || true

	# ONE ownership repair after BOTH sweeps: each opens the panel DB read-write as
	# root and re-materializes root-owned -wal/-shm/-journal sidecars beside it, so
	# shcpd (the panel user) must be able to write its own DB again after either.
	db_fix_ownership "$SHCP_UPDATE_DB"

	# Build the reap verdict once; folded into every terminal result below. A
	# non-zero exit (124 = the soft timeout fired; 1 = lock-held or an orphan that
	# could not be reaped) is degraded, carrying a capped ASCII-clamped tail as a
	# hint — the exit code is the decision, not the parsed human output. The one
	# exception is an exit 0 with no "[OK]" line, which is a crash (SC-782), not a reap.
	local override_reconcile ovr_ok=0
	[[ $ovr_rc -eq 0 ]] && console_reported_ok "$ovr_out" && ovr_ok=1
	if (( ! ovr_ok )); then
		local ovr_why
		ovr_why="$(tail -n 5 "$ovr_out" 2>/dev/null | tr -cd '\11\12\40-\176' \
			| tr '\n' ' ' || true)"
		if [[ $ovr_rc -eq 0 ]]; then
			ovr_why="exited 0 without reporting success${ovr_why:+: ${ovr_why}}"
			engine_log "reconcile-overrides: reap exited 0 without reporting success (SC-782) — DEGRADED, not rolling back; re-run shcp:apache:reconcile-overrides"
		elif [[ $ovr_rc -eq 124 ]]; then
			engine_log "reconcile-overrides: reap exceeded ${REPUBLISH_TIMEOUT}s — DEGRADED, not rolling back"
		else
			engine_log "reconcile-overrides: reap left an orphan override dir (exit ${ovr_rc}) — DEGRADED, not rolling back; re-run shcp:apache:reconcile-overrides"
		fi
		override_reconcile="$(jq -nc --argjson rc "$ovr_rc" --arg log "$ovr_out" --arg why "$ovr_why" \
			'{degraded: true, reconcile_exit: $rc, log: $log,
			  detail: (if $why == "" then null else $why end)}')"
	else
		override_reconcile="$(jq -nc --arg log "$ovr_out" '{reaped: true, log: $log}')"
	fi

	# #38 / SC-436: run_bounded is a SOFT timeout (timeout(1), no --kill-after), so
	# a SIGTERM to the sweep can land after a per-host enableVhost but before that
	# host's own configtest — leaving a vhost enabled-but-untested. No later engine
	# stage parses the Apache config, so a bad one would surface only at the box's
	# next reload (or a needrestart-driven stage_restart, failing Apache to start).
	# One GLOBAL config-parse here is the belt-and-suspenders: it cannot attribute
	# the fault to a file the way the command's per-host path does, but it turns a
	# silent broken config into a DEGRADED verdict the operator sees now, before
	# stage_restart runs. Reuses APACHE_CTL (the SC-029/SC-423 gate) — same
	# apachectl/httpd binary, already OS-family-neutral and test-shimmable — so this
	# adds no distro-branching. Advisory like the whole stage: never a rollback. A
	# missing apachectl is `skipped`, not a failure, so an apache-less box (DNS-only
	# edition) is not tripped; the parse is bounded so a wedged apachectl cannot hang
	# the update.
	local configtest="skipped"
	if command -v "$APACHE_CTL" >/dev/null 2>&1; then
		if run_bounded 60 "$APACHE_CTL" configtest >/dev/null 2>&1; then
			configtest="ok"
		else
			configtest="failed"
		fi
	fi

	if [[ $rc -ne 0 ]] || ! console_reported_ok "$out"; then
		# 124 is timeout(1)'s "the bound fired"; the command itself only ever exits
		# 0 (SUCCESS/nothing-to-do) or 1 (lock-held, or one+ active sites left on
		# the old config). Both non-zero cases are degraded, neither rolls back. Carry a
		# capped, ASCII-clamped tail so the operator sees WHICH sites without the
		# engine parsing the command's human output into a cross-repo contract — the
		# exit code is the decision, the tail is only a hint. The exception is an
		# exit 0 with no "[OK]" line: a crash (SC-782), and republish-pools must not
		# gate pools behind vhosts that never re-rendered (SC-710).
		local why
		why="$(tail -n 5 "$out" 2>/dev/null | tr -cd '\11\12\40-\176' \
			| tr '\n' ' ' || true)"
		if [[ $rc -eq 0 ]]; then
			why="exited 0 without reporting success${why:+: ${why}}"
			engine_log "republish-vhosts: sweep exited 0 without reporting success (SC-782) — DEGRADED, not rolling back; re-run shcp:apache:republish-vhosts"
		elif [[ $rc -eq 124 ]]; then
			engine_log "republish-vhosts: sweep exceeded ${REPUBLISH_TIMEOUT}s — DEGRADED, not rolling back"
		else
			engine_log "republish-vhosts: sweep left sites unrepublished (exit ${rc}) — DEGRADED, not rolling back; re-run shcp:apache:republish-vhosts"
		fi
		STAGE_RESULT="$(jq -nc --argjson rc "$rc" --arg log "$out" --arg why "$why" \
			--arg ct "$configtest" --argjson ovr "$override_reconcile" \
			'{degraded: true, republish_exit: $rc, configtest: $ct, log: $log,
			  detail: (if $why == "" then null else $why end),
			  override_reconcile: $ovr}')"
		return 0
	fi

	# The command reported success (every host it touched configtested clean per
	# host), yet the on-disk config does not parse as a whole — the enabled-but-
	# untested window #38 describes. Degrade so the operator re-runs; still no
	# rollback, for the same reason the rc!=0 path does not: reverting would drop
	# the very generator fix this stage delivers.
	if [[ "$configtest" == "failed" ]]; then
		engine_log "republish-vhosts: sweep reported success but '${APACHE_CTL} configtest' FAILED — a vhost is enabled-but-untested; DEGRADED, not rolling back; re-run shcp:apache:republish-vhosts"
		STAGE_RESULT="$(jq -nc --arg log "$out" --argjson ovr "$override_reconcile" \
			'{degraded: true, configtest: "failed",
			  detail: "post-sweep apachectl configtest failed — enabled-but-untested vhost", log: $log,
			  override_reconcile: $ovr}')"
		return 0
	fi

	# Republish is clean. The stage still degrades if the orphan reap could not
	# finish — a persisting override orphan is a live cross-tenant DoS the operator
	# must clear — but it NEVER rolls back (this stage is absent from the [R] set),
	# and the reap's own verdict is always carried in override_reconcile.
	engine_log "republish-vhosts: all active tenant vhosts re-rendered (configtest ${configtest})"
	if (( ! ovr_ok )); then
		STAGE_RESULT="$(jq -nc --arg log "$out" --arg ct "$configtest" --argjson ovr "$override_reconcile" \
			'{degraded: true, republished: true, configtest: $ct, log: $log,
			  override_reconcile: $ovr}')"
	else
		STAGE_RESULT="$(jq -nc --arg log "$out" --arg ct "$configtest" --argjson ovr "$override_reconcile" \
			'{republished: true, configtest: $ct, log: $log,
			  override_reconcile: $ovr}')"
	fi
	return 0
}

# vhost_republish_configtest_failed <journal> — 0 iff THIS run's republish-vhosts
# stage journaled configtest:"failed", i.e. it proved the on-disk Apache config
# does not parse (stage_republish_vhosts' #38/SC-436 global configtest gate). It
# reads the verdict the run ALREADY produced — it never re-runs configtest, so it
# consumes exactly one ground-truth signal and cannot disagree with the stage that
# minted it. Keyed on configtest, not the broader `degraded` flag: a stage can
# degrade for reasons that leave the config perfectly parseable (an orphan
# override reap that could not finish, a per-host site left on the old config, a
# soft-timeout whose configtest still came back ok), and withholding a needed
# web-server restart in those cases would strand a just-patched CVE in a running
# Apache for no safety gain. "failed" is the only value that means broken config.
vhost_republish_configtest_failed() {
	local jf="$1" ct
	ct="$(jq -r '(.stages[] | select(.stage == "republish-vhosts") | .result.configtest) // empty' \
		"$jf" 2>/dev/null || true)"
	[[ "$ct" == "failed" ]]
}

# --- republish-pools (SC-710, shcp-updater#195) ------------------------------------
# Deliver the SC-710 per-pool FastCGI token gate (shcp-base#1556) to tenant PHP-FPM
# pools that existed before it landed. New/changed pools render with the gate, but
# a pool file is written once, so on an upgraded box a tenant .htaccess can still
# force Apache's handler to ANOTHER tenant's pool socket and run that tenant's code
# as its uid until every pool is re-rendered. This stage runs the panel's
# shcp:php:republish-pools once per panel upgrade.
#
# ORDERING IS THE WHOLE POINT. A gated pool 403s any request whose vhost does not
# present its token, so a pool must NEVER enforce before its vhost carries the
# token — that would take the tenant's site down. The vhosts get the token in
# republish-vhosts; this stage therefore:
#   1. runs AFTER republish-vhosts, and SKIPS unless that stage, in THIS run,
#      journaled republished:true with a parsing config (configtest != failed). A
#      vhost sweep that timed out, left a site on the old config, or broke the
#      config means some vhost may lack its token: no pool is touched.
#   2. runs AFTER restart and health, and SKIPS when the run is heading for an
#      auto-rollback (health not healthy, unless unhealthy with no regression —
#      the one unhealthy verdict the runner does not roll back). A rollback
#      restores the OLD panel, whose vhost renderer emits no token; enforcing pools
#      first would leave the next re-rendered site 403ing under the old release.
#      Placed here, the only rollback trigger left after this stage (finalize's
#      health check) reads the verdict this stage has already honoured.
#   3. The panel command itself refuses, per owner, to gate a pool whose on-disk
#      vhost serves that pool's socket without presenting the token (shcp-base,
#      defence in depth for manual runs and anything the journal cannot see).
# A skip for reason 1 or 2 is DEGRADED — the cross-pool reach stays open and the
# operator must know — never failed and never a rollback. Re-run by hand with
# `shcp:php:republish-pools` once the vhosts are clean.
#
# ADVISORY, NEVER ROLLBACK, same envelope as republish-vhosts: flip-gated, NEW
# release console, shared Messenger consumers stopped, DB ownership repaired after,
# absent from the [R] rollback set. A partial pool sweep is safe: a pool left
# ungated is still reachable (the pre-existing state), never broken. Every 1.0
# release carries shcp:php:republish-pools, so a console that cannot run it is
# an ordinary command failure of this advisory stage (degraded), never a skip.
stage_republish_pools() {
	local jf; jf="$(journal_path "$CURRENT_RUN_ID")"

	local panel_target_dir
	panel_target_dir="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	if [[ -z "$panel_target_dir" ]]; then
		STAGE_RESULT='{"skipped": "panel stage did not run — no new pool baseline to deliver"}'
		return 0
	fi
	if ! panel_link_points_at "$panel_target_dir"; then
		engine_log "republish-pools: panel flip did not complete (${PANEL_LINK} is not ${panel_target_dir}) — not republishing"
		STAGE_RESULT="$(jq -nc --arg want "$panel_target_dir" --arg have "$(panel_link_target)" \
			'{skipped: "panel flip did not complete — republishing would run against a release the symlink does not serve",
			  expected_release: $want, active_release: $have}')"
		return 0
	fi

	local release="$panel_target_dir"
	local console="${release}/bin/console"

	# Gate 1: this run's vhost sweep must have put every token in place.
	local vhosts_republished vhosts_configtest
	vhosts_republished="$(jq -r '(.stages[] | select(.stage == "republish-vhosts") | .result.republished) // empty' \
		"$jf" 2>/dev/null || true)"
	vhosts_configtest="$(jq -r '(.stages[] | select(.stage == "republish-vhosts") | .result.configtest) // empty' \
		"$jf" 2>/dev/null || true)"
	if [[ "$vhosts_republished" != "true" || "$vhosts_configtest" == "failed" ]]; then
		engine_log "republish-pools: this run's vhost republish did not complete cleanly (republished=${vhosts_republished:-none}, configtest=${vhosts_configtest:-none}) — NOT gating any pool (SC-710: a pool must not enforce before its vhost presents the token); DEGRADED, re-run shcp:apache:republish-vhosts then shcp:php:republish-pools"
		STAGE_RESULT="$(jq -nc --arg r "${vhosts_republished:-none}" --arg c "${vhosts_configtest:-none}" \
			'{degraded: true, deferred: true, vhosts_republished: $r, vhosts_configtest: $c,
			  detail: "vhost republish incomplete — pools left ungated so no site loses its token"}')"
		return 0
	fi

	# Gate 2: never enforce ahead of an auto-rollback to a release that emits no token.
	local health_status health_regression
	health_status="$(jq -r '.health.status // empty' "$jf" 2>/dev/null || true)"
	health_regression="$(jq -r 'if .health.regression == false then "false" else "true" end' "$jf" 2>/dev/null || echo true)"
	if [[ "$health_status" != "healthy" ]] && ! [[ "$health_status" == "unhealthy" && "$health_regression" == "false" ]]; then
		engine_log "republish-pools: health is '${health_status:-unknown}' and this run may roll back — NOT gating any pool; DEGRADED"
		STAGE_RESULT="$(jq -nc --arg h "${health_status:-unknown}" \
			'{degraded: true, deferred: true, health: $h,
			  detail: "run may auto-roll back to a release without the pool gate — pools left ungated"}')"
		return 0
	fi

	if [[ ! -x "$SHCPD_BIN" || ! -f "$console" ]]; then
		engine_log "republish-pools: no usable console (${SHCPD_BIN} / ${console}) — skipping the sweep"
		STAGE_RESULT="$(jq -nc --arg c "$console" '{degraded: true, detail: ("no usable console at " + $c)}')"
		return 0
	fi

	# THIS STAGE RUNS AFTER stage_restart, which is the one place that starts the
	# Messenger consumers again. Stopping them here and returning — on ANY path —
	# used to leave every singleton worker, every managed worker and every
	# shcp-cron-worker@N STOPPED for good: nothing after this stage starts a
	# worker (stage_finalize does not; panel_workers_start is only reached from
	# panel_preflip_abort and rollback_run), so every successful panel upgrade
	# ended with health already recorded as healthy and BOTH Messenger consumers
	# dead — the exact state stage_restart's own comment warns about. So: record
	# which consumers are running NOW, stop them for the sweep, and start exactly
	# those again on every exit path that follows the stop (success, command
	# failure, timeout, an incomplete stop). Replaying the run's journaled
	# .workers.snapshot would not do: it was taken at stage_panel against the
	# OLD release, so a managed worker the NEW release introduced (started by
	# stage_restart's managed_workers_activate) is absent from it and would stay
	# down. A start that fails makes the stage degraded with the unit named —
	# never silently healthy.
	POOLS_WORKERS_WAS_ACTIVE=()
	local wunit wstate
	if panel_messenger_inventory; then
		for wunit in "${PANEL_MESSENGER_UNITS[@]}"; do
			wstate="$(panel_worker_state "$wunit")" || continue
			case "$wstate" in active|activating|reloading) POOLS_WORKERS_WAS_ACTIVE+=("$wunit") ;; esac
		done
	fi
	if ! panel_workers_stop; then
		republish_pools_workers_restart
		STAGE_RESULT="$(jq -nc --argjson w "$POOLS_WORKERS_JSON" \
			'{degraded: true, detail: "could not verify every shared Messenger consumer inactive"} + $w')"
		return 0
	fi

	local out="${RUNS_DIR}/${CURRENT_RUN_ID}/republish-pools.log"
	engine_log "republish-pools: gating tenant PHP-FPM pools via ${console}"
	local rc=0
	( cd "$release" && run_bounded "$REPUBLISH_TIMEOUT" \
		"$SHCPD_BIN" php-cli "$console" shcp:php:republish-pools \
		--env=prod --no-interaction ) >"$out" 2>&1 || rc=$?
	chmod 0600 "$out" 2>/dev/null || true

	# The command opens the panel DB as root and re-materializes root-owned
	# sidecars; restore shcpd's ownership on every path (same as republish-vhosts).
	db_fix_ownership "$SHCP_UPDATE_DB"

	# The consumers come back BEFORE the verdict is built, on every path.
	republish_pools_workers_restart

	if [[ $rc -ne 0 ]] || ! console_reported_ok "$out"; then
		local why
		why="$(tail -n 5 "$out" 2>/dev/null | tr -cd '\11\12\40-\176' | tr '\n' ' ' || true)"
		if [[ $rc -eq 0 ]]; then
			why="exited 0 without reporting success${why:+: ${why}}"
			engine_log "republish-pools: sweep exited 0 without reporting success (SC-782) — DEGRADED, not rolling back; re-run shcp:php:republish-pools"
		elif [[ $rc -eq 124 ]]; then
			engine_log "republish-pools: sweep exceeded ${REPUBLISH_TIMEOUT}s — DEGRADED, not rolling back"
		else
			engine_log "republish-pools: sweep left pools ungated (exit ${rc}) — DEGRADED, not rolling back; re-run shcp:php:republish-pools"
		fi
		STAGE_RESULT="$(jq -nc --argjson rc "$rc" --arg log "$out" --arg why "$why" --argjson w "$POOLS_WORKERS_JSON" \
			'{degraded: true, republish_exit: $rc, log: $log,
			  detail: (if $why == "" then null else $why end)} + $w')"
		return 0
	fi

	engine_log "republish-pools: all tenant PHP-FPM pools re-rendered with the SC-710 gate"
	STAGE_RESULT="$(jq -nc --arg log "$out" --argjson w "$POOLS_WORKERS_JSON" \
		'{republished: true, log: $log} + $w')"
	return 0
}

# republish_pools_workers_restart — start every unit POOLS_WORKERS_WAS_ACTIVE
# names (the consumers stage_republish_pools found running before its stop),
# prove each is active/activating afterwards, and leave the verdict in
# POOLS_WORKERS_JSON for the stage result: workers_restarted (bool),
# workers_stopped (count), workers_restart_failed (units), and — when any unit
# would not come back — degraded:true with a detail naming them, so a stage
# that ran its sweep cleanly still cannot report a box whose consumers are dead.
POOLS_WORKERS_WAS_ACTIVE=()
POOLS_WORKERS_JSON='{}'
republish_pools_workers_restart() {
	local unit state
	local -a restart_failed=()
	for unit in "${POOLS_WORKERS_WAS_ACTIVE[@]+"${POOLS_WORKERS_WAS_ACTIVE[@]}"}"; do
		if ! systemctl start "$unit" >/dev/null 2>&1; then
			restart_failed+=("$unit"); continue
		fi
		state="$(panel_worker_state "$unit")" || { restart_failed+=("$unit"); continue; }
		case "$state" in active|activating|reloading) ;; *) restart_failed+=("$unit") ;; esac
	done
	local failed_json
	failed_json="$(printf '%s\n' "${restart_failed[@]+"${restart_failed[@]}"}" | jq -R -s 'split("\n") | map(select(length > 0))')"
	if (( ${#restart_failed[@]} == 0 )); then
		POOLS_WORKERS_JSON="$(jq -nc --argjson n "${#POOLS_WORKERS_WAS_ACTIVE[@]}" \
			'{workers_stopped: $n, workers_restarted: true, workers_restart_failed: []}')"
		return 0
	fi
	engine_log "republish-pools: ${#restart_failed[@]} Messenger consumer(s) did NOT come back after the sweep (${restart_failed[*]}) — DEGRADED; start them by hand or the queues will not drain"
	POOLS_WORKERS_JSON="$(jq -nc --argjson n "${#POOLS_WORKERS_WAS_ACTIVE[@]}" --argjson f "$failed_json" \
		'{workers_stopped: $n, workers_restarted: false, workers_restart_failed: $f, workers_not_restarted: $f, degraded: true,
		  detail: ("Messenger consumers stopped for the pool sweep did not restart: " + ($f | join(", ")) + " — start them by hand or the queues will not drain")}')"
	return 0
}


# --- the SC-710 pool gate across a rollback (shcp-updater#198) ------------------
# A gated pool refuses every request whose vhost does not present ITS token. A
# rollback flips the panel back to a release whose vhost renderer may present a
# different token (another derivation). The pools stay gated — nothing in the
# flip touches them — so the first vhost the old panel re-renders 403s that
# whole site, and every later one follows.
#
# pool_gate_identity <release-dir> — what a release's pool tokens are derived as:
#   "shcp-fpm-pool-gate:vN"  the HMAC label in FpmPoolGate::tokenFor; shcp-base
#                            bumps it whenever tokens change (v1 -> v2 added the
#                            per-box salt), so equal labels mean equal tokens
#   "unknown:<sha256>"       the file exists but the label could not be read; only
#                            a byte-identical file counts as the same gate
#   "unknown:...:<dir>"      the gate class is missing (every 1.0 release ships
#                            it, so this is a damaged or mis-packaged tree), a
#                            symlink, or unreadable: never equal to another
#                            release, so it always un-gates. "Can't tell" must
#                            never read as "no gate".
# The label is read only from the real `return substr(hash_hmac(` line, so a comment
# quoting another label cannot change the answer. shcp-base bumps the label whenever
# the tokens OR how a vhost presents them change (the FpmPoolGate docblock says so).
pool_gate_identity() {
	local dir="$1" label
	local f="${dir}/src/Service/Php/FpmPoolGate.php"
	if [[ ! -e "$f" && ! -L "$f" ]]; then
		printf 'unknown:missing:%s' "$dir"
		return 0
	fi
	if [[ -L "$f" || ! -f "$f" || ! -r "$f" ]]; then
		printf 'unknown:unreadable:%s' "$dir"
		return 0
	fi
	label="$(grep -E '^[[:space:]]*return[[:space:]]+substr\(hash_hmac\(' -- "$f" 2>/dev/null \
		| grep -oE 'shcp-fpm-pool-gate:v[0-9]+' | head -n1 || true)"
	if [[ -n "$label" ]]; then
		printf '%s' "$label"
	else
		printf 'unknown:%s' "$(sha256sum -- "$f" 2>/dev/null | cut -d' ' -f1)"
	fi
}

# pool_gate_can_ungate <release-dir> — the release's console has
# `shcp:php:republish-pools --ungate` (shcp-base, shcp-updater#198).
pool_gate_can_ungate() {
	local f="$1/src/Command/PhpRepublishPoolsCommand.php"
	[[ -f "$f" && ! -L "$f" ]] && grep -qF "'ungate'" -- "$f" 2>/dev/null
}

# rollback_ungate_pools <run_id> <release-dir> — re-render every tenant pool
# WITHOUT the gate through <release-dir>'s console (the SOURCE release: it wrote
# the gate, it knows the family's pool layout, and it does the configtest +
# restore). 0 iff the command exited 0 and printed its "[OK]" summary (SC-782).
# POOL_GATE_UNGATE_DETAIL says why not.
POOL_GATE_UNGATE_DETAIL=""
rollback_ungate_pools() {
	local run_id="$1" release="$2"
	local console="${release}/bin/console"
	local out="${RUNS_DIR}/${run_id}/rollback-ungate-pools.log"
	POOL_GATE_UNGATE_DETAIL=""
	if [[ ! -x "$SHCPD_BIN" || ! -f "$console" ]]; then
		POOL_GATE_UNGATE_DETAIL="no usable console at ${console}"
		return 1
	fi
	mkdir -p "${RUNS_DIR}/${run_id}" 2>/dev/null || true
	local rc=0
	( cd "$release" && run_bounded "$REPUBLISH_TIMEOUT" \
		"$SHCPD_BIN" php-cli "$console" shcp:php:republish-pools --ungate \
		--env=prod --no-interaction ) >"$out" 2>&1 || rc=$?
	chmod 0600 "$out" 2>/dev/null || true
	db_fix_ownership "$SHCP_UPDATE_DB"
	if [[ $rc -eq 0 ]]; then
		console_reported_ok "$out" && return 0
		POOL_GATE_UNGATE_DETAIL="exited 0 without reporting success"
		return 1
	fi
	POOL_GATE_UNGATE_DETAIL="exit ${rc}: $(tail -n 5 "$out" 2>/dev/null | tr -cd '\11\12\40-\176' | tr '\n' ' ' || true)"
	return 1
}

# authlog_protection_ok <release-dir> — one oracle for the pre-apply detector
# and the post-apply convergence proof (SC-050 / SC-480). The release assets are
# authenticated by stage_panel before that release can become current.
authlog_systemd_exec_exact() {
	local value="$1" expected_path="$2" expected_argv="$3"
	local path argv
	if [[ "$value" == "$expected_argv" ]]; then
		return 0
	fi
	# systemctl show serializes Exec* entries as
	# { path=/bin/x ; argv[]=/bin/x arg ; ignore_errors=... ; ... }.
	# Anchoring the single whole entry prevents a decoy executable from passing
	# merely because our command appears later in its argv.
	[[ "$value" =~ ^\{[[:space:]]path=([^[:space:];]+)[[:space:]]\;[[:space:]]argv\[\]=([^;]+)[[:space:]]\;.*\}$ ]] || return 1
	path="${BASH_REMATCH[1]}"
	argv="${BASH_REMATCH[2]}"
	argv="${argv%"${argv##*[![:space:]]}"}"
	[[ "$path" == "$expected_path" && "$argv" == "$expected_argv" ]]
}

authlog_protection_ok() {
	local release="$1"
	local assets="${release}/config/system/authlog"
	local attrs effective_start effective_stop
	[[ -f "${assets}/shcp-authlog-rotate" && ! -L "${assets}/shcp-authlog-rotate" \
		&& -f "${assets}/shcp-authlog.logrotate" && ! -L "${assets}/shcp-authlog.logrotate" \
		&& -f "${assets}/shcp-authlog-systemd.conf" && ! -L "${assets}/shcp-authlog-systemd.conf" ]] || return 1
	[[ "$SHCP_AUTH_LOG_DIR" == "$(dirname -- "$SHCP_AUTH_LOG")" ]] || return 1
	[[ -d "$SHCP_AUTH_LOG_DIR" && ! -L "$SHCP_AUTH_LOG_DIR" ]] || return 1
	[[ -f "$SHCP_AUTH_LOG" && ! -L "$SHCP_AUTH_LOG" ]] || return 1
	[[ -f "$SHCP_AUTH_LOG_GUARD" && ! -L "$SHCP_AUTH_LOG_GUARD" ]] || return 1
	[[ -f "$SHCP_AUTH_LOGROTATE_CONF" && ! -L "$SHCP_AUTH_LOGROTATE_CONF" ]] || return 1
	[[ -f "$SHCP_AUTH_LOGROTATE_DROPIN" && ! -L "$SHCP_AUTH_LOGROTATE_DROPIN" ]] || return 1
	[[ "$(stat -c '%U:%G %a' -- "$SHCP_AUTH_LOG_DIR" 2>/dev/null || true)" == 'root:shcp 750' ]] || return 1
	[[ "$(stat -c '%U:%G %a' -- "$SHCP_AUTH_LOG" 2>/dev/null || true)" == 'shcp:shcp 640' ]] || return 1
	cmp -s -- "${assets}/shcp-authlog-rotate" "$SHCP_AUTH_LOG_GUARD" || return 1
	cmp -s -- "${assets}/shcp-authlog.logrotate" "$SHCP_AUTH_LOGROTATE_CONF" || return 1
	cmp -s -- "${assets}/shcp-authlog-systemd.conf" "$SHCP_AUTH_LOGROTATE_DROPIN" || return 1
	[[ "$(stat -c '%U:%G %a' -- "$SHCP_AUTH_LOG_GUARD" 2>/dev/null || true)" == 'root:root 755' ]] || return 1
	[[ "$(stat -c '%U:%G %a' -- "$SHCP_AUTH_LOGROTATE_CONF" 2>/dev/null || true)" == 'root:root 644' ]] || return 1
	[[ "$(stat -c '%U:%G %a' -- "$SHCP_AUTH_LOGROTATE_DROPIN" 2>/dev/null || true)" == 'root:root 644' ]] || return 1
	effective_start="$(systemctl show logrotate.service -p ExecStart --value 2>/dev/null)" || return 1
	effective_stop="$(systemctl show logrotate.service -p ExecStopPost --value 2>/dev/null)" || return 1
	authlog_systemd_exec_exact "$effective_start" "$SHCP_AUTH_LOG_GUARD" \
		"$SHCP_AUTH_LOG_GUARD /etc/logrotate.conf" || return 1
	authlog_systemd_exec_exact "$effective_stop" "$SHCP_AUTH_LOG_GUARD" \
		"$SHCP_AUTH_LOG_GUARD --recover" || return 1
	attrs="$(lsattr -d -- "$SHCP_AUTH_LOG" 2>/dev/null | awk 'NR == 1 {print $1}')" || return 1
	[[ "$attrs" == *a* ]]
}

# authlog_protection_reconcile <release-dir> — converge an existing host from
# canonical files in the verified panel release, then prove the exact state the
# detector reads. Never claim success from install(1) alone (SC-480).
authlog_protection_reconcile() {
	local release="$1"
	local assets="${release}/config/system/authlog"
	local target parent
	[[ -f "${assets}/shcp-authlog-rotate" && ! -L "${assets}/shcp-authlog-rotate" \
		&& -f "${assets}/shcp-authlog.logrotate" && ! -L "${assets}/shcp-authlog.logrotate" \
		&& -f "${assets}/shcp-authlog-systemd.conf" && ! -L "${assets}/shcp-authlog-systemd.conf" ]] || return 2
	if authlog_protection_ok "$release"; then
		return 0
	fi
	# These are root-executed assets. Refuse redirects and non-regular survivors
	# instead of letting install(1) follow a planted target or parent symlink.
	for target in "$SHCP_AUTH_LOG_GUARD" "$SHCP_AUTH_LOGROTATE_CONF" "$SHCP_AUTH_LOGROTATE_DROPIN"; do
		parent="$(dirname -- "$target")"
		[[ ! -L "$parent" && ( ! -e "$parent" || -d "$parent" ) ]] || return 1
		[[ ! -L "$target" && ( ! -e "$target" || -f "$target" ) ]] || return 1
	done
	install -d -m 0755 -o root -g root -- "$(dirname -- "$SHCP_AUTH_LOG_GUARD")" \
		"$(dirname -- "$SHCP_AUTH_LOGROTATE_DROPIN")" || return 1
	install -m 0755 -o root -g root -- "${assets}/shcp-authlog-rotate" "$SHCP_AUTH_LOG_GUARD" || return 1
	install -m 0644 -o root -g root -- "${assets}/shcp-authlog.logrotate" "$SHCP_AUTH_LOGROTATE_CONF" || return 1
	install -m 0644 -o root -g root -- "${assets}/shcp-authlog-systemd.conf" "$SHCP_AUTH_LOGROTATE_DROPIN" || return 1
	systemctl daemon-reload || return 1
	SHCP_AUTHLOG_PATH="$SHCP_AUTH_LOG" SHCP_AUTHLOG_DIR="$SHCP_AUTH_LOG_DIR" \
		"$SHCP_AUTH_LOG_GUARD" --lock-only || return 1
	authlog_protection_ok "$release"
}

# managed_daemon_restore_excluded <svc> — the SHCP_MANAGED_SERVICES entries that
# restore_baseline_managed_daemons must NOT touch, because another seam already
# owns their restart: shcpd has its own unconditional restart (an apt binary swap
# has no restart hook), and shcp-worker is a panel worker driven by the worker
# loop / panel_workers_* state machine (which can deliberately leave it stopped,
# e.g. a rollback with an unknown DB). Everything else in the list is a
# third-party/OS daemon whose only automatic restart hook in this stage is the
# needrestart pass — which is blind to a STOPPED unit.
managed_daemon_restore_excluded() {
	case "$1" in shcpd|shcp-worker) return 0 ;; *) return 1 ;; esac
}

# unit_ctl_bounded <start|restart> <unit.service> — `systemctl <verb> <unit>`,
# bounded by a wall-clock timeout so a wedged unit or a stalled systemctl/D-Bus
# cannot hang the update window indefinitely (systemd's own TimeoutStartSec
# bounds the JOB, not a hung client; a unit that never reaches active runs to
# that timeout, is SIGKILLed, and the engine — mid-stage, maintenance gate up —
# sits behind it the whole way, leaving the journal `running`). EVERY start and
# restart stage_restart issues goes through here; SHCP_MANAGED_RESTART_TIMEOUT
# (seconds, default 120) is the one bound. Falls back to a plain call where
# coreutils `timeout` is absent. 0 iff systemctl reported success; a restart
# whose stop systemd had to force is still a success, and is reported.
unit_ctl_bounded() {
	local verb="$1" unit="$2" t="${SHCP_MANAGED_RESTART_TIMEOUT:-120}" t0 rc=0
	[[ "$t" =~ ^[0-9]+$ ]] || t=120
	t0="$(date +%s)"
	if command -v timeout >/dev/null 2>&1; then
		timeout "$t" systemctl "$verb" "$unit" >/dev/null 2>&1 || rc=$?
	else
		systemctl "$verb" "$unit" >/dev/null 2>&1 || rc=$?
	fi
	[[ $rc -eq 0 && "$verb" == restart ]] && report_forced_stop "$unit" "$t0"
	return "$rc"
}

# report_forced_stop <unit> <epoch> [<action>] — a stop or restart that outlived the
# unit's TimeoutStopSec still succeeds: systemd kills (shcpd.service: aborts) the old
# process, and systemctl exits 0. Say so in the run log, with the unit's journal
# since the <action> (default restart) began; for shcpd that carries the goroutine
# dump the abort wrote, the only record of what the stop waited on. The installer's
# manage_service gives its own stops and restarts the same report.
report_forced_stop() {
	local unit="$1" since="$2" action="${3:-restart}" journal
	journal="$(journalctl -u "$unit" --since "@${since}" --no-pager -o short-precise 2>/dev/null)" || return 0
	grep -q "State 'stop[a-z-]*' timed out" <<<"$journal" || return 0
	engine_log "${action}: systemd timed out stopping ${unit}; the stop was forced. Journal since the ${action} began:
$(tail -n 600 <<<"$journal")"
}

# managed_unit_start_bounded <unit> — unit_ctl_bounded start of <unit>.service.
managed_unit_start_bounded() {
	unit_ctl_bounded start "${1}.service"
}

# restore_baseline_managed_daemons <journal> — §4.2-6 safety net (updater#161,
# SC-476). A package upgrade can STOP a managed daemon in its prerm and never
# restart it: shcp-valkey/-dovecot's deb prerm stops the unit unconditionally and
# their postinst only runs daemon-reload + apparmor_parser, so the daemon is left
# down. needrestart/needs-restarting name RUNNING processes, so a stopped unit is
# invisible to stage_restart's needrestart pass above; health's unit-states check
# then reports the regression and rolls the update back — and the rollback path
# hits the SAME gap after the downgrade, ending rolled_back_partial with the
# maintenance gate stuck. (rpm leaves the unit running on upgrade, so its stale
# process IS caught by the restart pass; the exposure is real on deb and a
# backstop for any third-party/distro daemon that behaves the same way.)
#
# Replay the §4.2-1 baseline (preflight result .baseline.units): for every managed
# daemon that was `active` at the run baseline and is NOT active now, start it,
# bounded and logged. A unit the operator had already stopped/disabled
# (baseline != active) is LEFT ALONE — the exact rule the unit-states health
# check uses, so the two never disagree. The names are the baseline's own MEASURED
# units, so apache2-vs-httpd resolves by what was actually running rather than a
# hardcoded per-family string. The SC-573 web-server withhold is honoured: a web
# unit whose on-disk config THIS run proved broken is reported, never started into
# the fault. Results land in three globals so both stage_restart and rollback_run
# can fold them into their existing reporting.
MANAGED_DAEMONS_RESTORED=()
MANAGED_DAEMONS_FAILED=()
MANAGED_DAEMONS_WITHHELD=()
restore_baseline_managed_daemons() {
	local jf="$1"
	MANAGED_DAEMONS_RESTORED=(); MANAGED_DAEMONS_FAILED=(); MANAGED_DAEMONS_WITHHELD=()
	local baseline
	baseline="$(jq -c '(.stages[]? | select(.stage == "preflight") | .result.baseline) // empty' \
		"$jf" 2>/dev/null || true)"
	[[ -n "$baseline" && "$baseline" != "null" ]] || return 0
	command -v systemctl >/dev/null 2>&1 || return 0
	local web_unit; web_unit="$(osf_web_server_unit)"
	local svc was
	# Concrete names only: a glob entry (php*-fpm) names no unit the baseline
	# could have measured.
	while IFS= read -r svc; do
		[[ -n "$svc" ]] || continue
		managed_daemon_restore_excluded "$svc" && continue
		was="$(jq -r --arg u "$svc" '(.units // {})[$u] // empty' <<<"$baseline" 2>/dev/null || true)"
		[[ "$was" == "active" ]] || continue                     # operator's stopped/absent unit: leave alone
		# Start ONLY a cleanly-stopped unit. `inactive` is what a prerm
		# `systemctl stop` leaves behind (the updater#161 symptom is literally
		# "valkey=inactive"); `failed` means the unit tried to run and could not,
		# which is a genuine breakage THIS run caused — auto-starting it would mask
		# the fault the unit-states health check exists to catch and roll back. So
		# a non-inactive, non-active state (failed/activating/…) is left for health.
		[[ "$(unit_state "$svc")" == "inactive" ]] || continue
		if [[ -n "$web_unit" && "$svc" == "$web_unit" ]] && vhost_republish_configtest_failed "$jf"; then
			MANAGED_DAEMONS_WITHHELD+=("$svc")
			engine_log "restart: WITHHOLDING ${svc} start (SC-573) — it was active at baseline and is stopped now, but this run's vhost republish journaled configtest:\"failed\", so the on-disk config does not parse; re-run shcp:apache:republish-vhosts then start ${svc} by hand."
			continue
		fi
		engine_log "restart: ${svc} was active at baseline and is stopped now (a package stage stopped it with no restart hook) — starting it (updater#161, SC-476)"
		if managed_unit_start_bounded "$svc"; then
			MANAGED_DAEMONS_RESTORED+=("$svc")
		else
			MANAGED_DAEMONS_FAILED+=("$svc")
			engine_log "restart: ${svc} FAILED to start after a package stage stopped it — the unit-states health check will flag it"
		fi
	done < <(managed_services_concrete)
}

# ActiveEnterTimestampMonotonic is systemd's measured activation time. An empty
# property or an unchanged timestamp cannot prove a restart succeeded.
worker_active_enter_timestamp() {
	local stamp
	stamp="$(systemctl show "$1" --property=ActiveEnterTimestampMonotonic --value 2>/dev/null)" || return 1
	[[ "$stamp" =~ ^[0-9]+$ && "$stamp" != 0 ]] || return 1
	printf '%s' "$stamp"
}

# UPD-2: §4.4 restart/reboot detection + in-window service restarts. Records the
# journal's top-level reboot_required (bool) and restart_required (list) — the
# fields the panel importer turns into the HIGH notification. shcp-managed
# services are restarted automatically inside the window; anything else (and a
# managed service whose restart failed, or a database server when allow_db=false) is left
# for the operator and reported in restart_required.
stage_restart() {
	local jf; jf="$(journal_path "$CURRENT_RUN_ID")"
	local managed_release authlog_status='unchanged' authlog_rc=0 authlog_detail=""
	# The release whose assets (auth-log protection, license-recover timer,
	# managed workers) this stage activates is the one that is LIVE, not the one
	# the journal INTENDED. `.panel.release_dir` is written before the flip and
	# survives an intent-then-crash: a resumed run can carry it while /opt/shcp
	# still points at the old release, and taking assets from a release that is
	# not serving would arm timers and workers against a tree that is not there.
	# Same gate stage_db and the republish stages use (panel_link_points_at);
	# the live target is the fallback.
	managed_release="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	if [[ -n "$managed_release" ]] && ! panel_link_points_at "$managed_release"; then
		engine_log "restart: the journal's .panel.release_dir (${managed_release}) is not what ${PANEL_LINK} resolves to — using the live release instead"
		managed_release=""
	fi
	[[ -n "$managed_release" ]] || managed_release="$(panel_link_target)"
	if [[ -n "$managed_release" ]]; then
		authlog_protection_reconcile "$managed_release" || authlog_rc=$?
		case "$authlog_rc" in
			0) authlog_status='converged' ;;
			2)
				# The release carries no auth-log assets at all: a failure like any
				# other, every 1.0 release ships them (SC-050).
				engine_log 'restart: auth-log protection did not converge'
				STAGE_RESULT='{"failed":"auth-log protection did not converge"}'
				return 1
				;;
			*)
				# The assets are there but the host would not CONVERGE (rc=1): an
				# absent lsattr, an install(1) refusal, a logrotate.service whose
				# ExecStart the distro reshaped past authlog_systemd_exec_exact's
				# single-argv match. DEGRADED, never fatal: restart is an [R]
				# stage, so failing here rolled a security-only run back and
				# re-exposed the CVE it had just patched — over a log-rotation
				# guard. Recorded on the stage, logged loudly, and `check` raises
				# authlog_protection_unconverged until a later run converges it.
				authlog_status='failed'
				authlog_detail="auth-log protection did not converge on ${managed_release} (reconcile rc=${authlog_rc}); the run continues DEGRADED — SC-050 protection is not proven on this host until a later run converges it"
				engine_log "restart: ${authlog_detail}"
				;;
		esac
	fi
	# §4.4 reboot-required, journal-is-truth (SC-420). deb's /var/run/reboot-required
	# lives on tmpfs and is wiped by the very reboot a cross-reboot park performs, so
	# a resumed run re-probing here reads false and would clobber a `true` recorded
	# before the park — park_for_reboot captures osf_reboot_required into the journal
	# while the marker still exists. rpm's `needs-restarting -r` is a live probe that
	# self-heals post-reboot the same way. So NEVER downgrade: OR the fresh probe with
	# what the journal already holds. Chosen over reordering the reboot-requesting
	# stage to sit at/after `restart` because the OS-upgrade epic (ONB-7) must reboot
	# BETWEEN apt phases, and the journal is already the authority a resume rebuilds
	# state from (re-entry point, HEALTH_STATUS).
	local probe; probe="$(osf_reboot_required)"; [[ "$probe" == "true" ]] || probe="false"
	local prior; prior="$(jq -r '.reboot_required // false' "$jf")"
	local reboot="false"; { [[ "$prior" == "true" || "$probe" == "true" ]]; } && reboot="true"
	journal_update "$CURRENT_RUN_ID" '.reboot_required = $r' --argjson r "$reboot"

	# §4.2-6: reload unit files before restarting, so a package that shipped a
	# new/updated .service is picked up. Only when this run changed packages.
	if jq -e '(.packages.changed | length) > 0' "$jf" >/dev/null 2>&1; then
		systemctl daemon-reload >/dev/null 2>&1 || true
	fi

	local allow_db; allow_db="$(settings_get 'update.restart.allow_db' 'true')"
	local pg_unit; pg_unit="$(pgsql_managed_unit)"

	# An absent detector yields an empty list that looks exactly like a clean
	# run. Say so once, loudly, in the run log — the `check` verb carries the
	# same fact as an advisory so it reaches the panel without a schema change.
	# The same binary is the kernel/libc reboot verdict on both families, so its
	# absence is also why reboot_required above reads false.
	if ! osf_restart_detection_available; then
		engine_log "restart: $(osf_restart_detector) is not installed — no service-restart detection is possible on this host; restart_required will be empty regardless of what actually needs restarting, and reboot_required=${reboot} is not a measured verdict"
	fi

	# SC-573: the web-server unit (apache2/httpd, resolved by family — never
	# hardcoded) whose restart must be WITHHELD when this run's own vhost republish
	# already proved the on-disk config broken. Resolved once, outside the loop.
	local web_unit; web_unit="$(osf_web_server_unit)"

	local -a restarted=() started_units=() needs_manual=() withheld=()
	local -a worker_units=()
	local worker_inventory
	worker_inventory="$(panel_messenger_units)" || {
		STAGE_RESULT='{"failed":"Messenger worker inventory could not be read"}'
		return 1
	}
	[[ -z "$worker_inventory" ]] || mapfile -t worker_units <<<"$worker_inventory"
	local svc wunit app_units=0 worker_verify_failed=0 flagged_worker before after
	while IFS= read -r svc; do
		# SC-924 (APPH-9): a tenant application is the tenant's process, not the
		# operator's service. Restarting it is an outage nobody asked for, and
		# listing it in needs_manual buries the operator's own services under one
		# line per app on every run. Count it, say so once below, touch nothing.
		# Exact grammar only: anything else takes the existing path.
		if is_tenant_app_unit "$svc"; then
			app_units=$((app_units + 1))
			continue
		fi
		svc="${svc%.service}"
		[[ -n "$svc" ]] || continue
		flagged_worker=false
		for wunit in "${worker_units[@]}"; do
			if [[ "$wunit" == "${svc}.service" ]] && panel_worker_unit_allowed "$wunit" && panel_worker_target_compatible "$wunit"; then
				flagged_worker=true
				break
			fi
		done
		if ! osf_service_is_managed "$svc" "$pg_unit" && [[ "$flagged_worker" != true ]]; then
			needs_manual+=("$svc")   # operator's own service — report, don't touch
			continue
		fi
		# A detector can name a stopped worker, but it has no old process to drain.
		[[ "$flagged_worker" != true || "$(unit_state "$svc")" == active ]] || continue
		# SC-573 (updater#108): needrestart flags apache2/httpd for restart when a
		# concurrent library bump (libssl, apache2-bin/httpd) touches it — but if
		# THIS SAME run's stage_republish_vhosts journaled configtest:"failed", the
		# on-disk config does not parse, and `systemctl restart` would stop Apache
		# and fail to bring it back, taking every tenant's site down. Withhold the
		# restart (the still-running process keeps serving the last-good config,
		# strictly safer than restarting into a broken one), report it for the
		# operator, and say why. Only the web server is withheld; every other
		# flagged service still restarts. The verdict is read back, never recomputed.
		if [[ -n "$web_unit" && "$svc" == "$web_unit" ]] && vhost_republish_configtest_failed "$jf"; then
			needs_manual+=("$svc")
			withheld+=("$svc")
			engine_log "restart: WITHHOLDING ${svc} restart (SC-573) — this run's vhost republish journaled configtest:\"failed\", so the on-disk Apache config does not parse; restarting ${svc} now would take every tenant site down. Leaving the running config in place — re-run shcp:apache:republish-vhosts, then restart ${svc} manually."
			continue
		fi
		if service_is_database "$svc" "$pg_unit" && [[ "$allow_db" != "true" ]]; then
			needs_manual+=("$svc")   # DB restart withheld by policy
			continue
		fi
		before=""
		[[ "$flagged_worker" != true ]] || before="$(worker_active_enter_timestamp "${svc}.service")" || before=""
		if unit_ctl_bounded restart "${svc}.service"; then
			if [[ "$flagged_worker" == true ]]; then
				after="$(worker_active_enter_timestamp "${svc}.service")" || after=""
				if [[ -n "$before" && -n "$after" && "$after" != "$before" && "$(unit_state "$svc")" == active ]]; then
					restarted+=("$svc")
					continue
				fi
				engine_log "restart: ${svc}.service restart did not advance ActiveEnterTimestampMonotonic"
				worker_verify_failed=1
			else
				restarted+=("$svc")
				continue
			fi
		else
			[[ "$flagged_worker" != true ]] || worker_verify_failed=1
		fi
		needs_manual+=("$svc")   # failed or unverified — surface it
	done < <(osf_restart_services)
	if ((app_units > 0)); then
		engine_log "restart: ${app_units} tenant application unit(s) flagged for restart; left running and not listed (an app restarts only when its tenant or the panel restarts it)"
	fi

	# §4.2-6: apt can swap the shcpd binary with no restart hook, and needrestart
	# does not reliably flag a standalone binary — so restart shcpd
	# unconditionally when it was in the apt set (or the panel stage ran, UPD-3),
	# unless the needrestart pass already handled it.
	local shcpd_needed=0 r
	jq -e '[.packages.changed[].pkg] | index("shcpd")' "$jf" >/dev/null 2>&1 && shcpd_needed=1
	jq -e '.panel != null' "$jf" >/dev/null 2>&1 && shcpd_needed=1
	if [[ $shcpd_needed -eq 1 ]]; then
		local already=0
		for r in "${restarted[@]+"${restarted[@]}"}"; do [[ "$r" == "shcpd" ]] && already=1; done
		if [[ $already -eq 0 ]]; then
			if unit_ctl_bounded restart shcpd.service; then restarted+=("shcpd"); else needs_manual+=("shcpd"); fi
		fi
	fi

	# A changed shcpd is the ExecStart binary for every Messenger consumer.
	# `start` on a running unit leaves the old binary resident (SC-757, SC-996).
	# The worker snapshot or preflight baseline distinguishes a worker this run
	# stopped from one the operator had already stopped; a newly installed
	# managed worker is also activated.
	local wsvc wstate prior_active installed_now action
	if [[ -n "$managed_release" ]] && ! license_recover_activate "$managed_release"; then
		engine_log "restart: license recovery timer enable/start failed after schema proof"
		STAGE_RESULT='{"failed":"license recovery timer activation failed"}'
		return 1
	fi
	if [[ -n "$managed_release" ]] && ! managed_workers_activate "$managed_release"; then
		engine_log "restart: managed worker enable/start failed after schema proof"
		STAGE_RESULT='{"failed":"managed worker activation failed"}'
		return 1
	fi
	while IFS= read -r wsvc; do
		[[ -n "$wsvc" ]] || continue
		panel_worker_unit_allowed "$wsvc" || continue
		panel_worker_target_compatible "$wsvc" || continue
		# needrestart may already have handled a worker in the pass above.
		if [[ " ${restarted[*]} " == *" ${wsvc%.service} "* ]]; then continue; fi
		wstate="$(unit_state "${wsvc%.service}")"
		prior_active="$(jq -r --arg u "$wsvc" '.workers.snapshot[]? | select(.unit == $u) | .active' "$jf" 2>/dev/null | head -n 1)"
		if [[ -z "$prior_active" ]]; then
			prior_active="$(jq -r --arg u "${wsvc%.service}" '(.stages[]? | select(.stage == "preflight") | .result.baseline.units[$u]) // empty' "$jf" 2>/dev/null | head -n 1)"
			[[ "$prior_active" != active ]] || prior_active=true
		fi
		# managed_workers_install records every shipped managed worker on each
		# panel update, so .installed alone does not mean new. New means the
		# pre-run snapshot and baseline never saw the unit.
		installed_now=false
		[[ -z "$prior_active" ]] \
			&& jq -e --arg u "$wsvc" '.managed_workers.installed[]? | select(.unit == $u and .removed != true)' "$jf" >/dev/null 2>&1 \
			&& installed_now=true
		action=""
		if [[ "$wstate" == active ]]; then
			[[ $shcpd_needed -eq 1 ]] && action=restart
		# panel_workers_stop accepts `failed` as stopped (a stop past TimeoutStopSec
		# ends there), so a worker this run stopped may be failed rather than inactive.
		elif [[ ( "$wstate" == inactive || "$wstate" == failed ) && ( "$prior_active" == true || "$installed_now" == true ) ]]; then
			action=start
		fi
		[[ -n "$action" ]] || continue
		before="$(worker_active_enter_timestamp "$wsvc")" || before=""
		if unit_ctl_bounded "$action" "$wsvc"; then
			after="$(worker_active_enter_timestamp "$wsvc")" || after=""
			if [[ -n "$after" && "$after" != "$before" && ( "$action" == start || -n "$before" ) && "$(unit_state "${wsvc%.service}")" == active ]]; then
				if [[ "$action" == restart ]]; then restarted+=("${wsvc%.service}"); else started_units+=("${wsvc%.service}"); fi
				continue
			fi
			engine_log "restart: ${wsvc} ${action} did not advance ActiveEnterTimestampMonotonic"
		fi
		needs_manual+=("${wsvc%.service}")
		worker_verify_failed=1
	done < <(printf '%s\n' "${worker_units[@]+"${worker_units[@]}"}")

	# §4.2-6 safety net (updater#161, SC-476): a package upgrade can STOP a managed
	# daemon and leave it down (no restart hook; needrestart cannot flag a stopped
	# unit). Bring every daemon that was active at the run baseline and is stopped
	# now back up. Its results fold into the same restarted/needs_manual/withheld
	# reporting so the operator sees one list. A unit already handled above (a
	# needrestart-flagged restart, a withhold) is skipped by the active/handled
	# checks inside the helper.
	restore_baseline_managed_daemons "$jf"
	local d
	for d in "${MANAGED_DAEMONS_RESTORED[@]+"${MANAGED_DAEMONS_RESTORED[@]}"}"; do started_units+=("$d"); done
	for d in "${MANAGED_DAEMONS_FAILED[@]+"${MANAGED_DAEMONS_FAILED[@]}"}"; do needs_manual+=("$d"); done
	for d in "${MANAGED_DAEMONS_WITHHELD[@]+"${MANAGED_DAEMONS_WITHHELD[@]}"}"; do
		needs_manual+=("$d"); withheld+=("$d")
	done

	local manual_json restarted_json started_json withheld_json
	manual_json="$(printf '%s\n' "${needs_manual[@]+"${needs_manual[@]}"}" | jq -R -s 'split("\n") | map(select(length > 0))')"
	restarted_json="$(printf '%s\n' "${restarted[@]+"${restarted[@]}"}" | jq -R -s 'split("\n") | map(select(length > 0))')"
	started_json="$(printf '%s\n' "${started_units[@]+"${started_units[@]}"}" | jq -R -s 'split("\n") | map(select(length > 0))')"
	# SC-573: needs_manual_restart cannot tell "refused, the config is broken" from
	# "systemd said no". Name the reason in the run's own record so the operator
	# does not have to read the 0600 run log to learn which one happened.
	withheld_json="$(printf '%s\n' "${withheld[@]+"${withheld[@]}"}" | jq -R -s \
		'split("\n") | map(select(length > 0))
		 | map({unit: ., reason: "republish-vhosts journaled configtest:failed — the on-disk Apache config does not parse (SC-573)"})')"

	journal_update "$CURRENT_RUN_ID" '.restart_required = $s' --argjson s "$manual_json"
	# authlog_protection (string) is the existing field; `authlog` is the
	# structured record `check` reads for authlog_protection_unconverged.
	STAGE_RESULT="$(jq -nc --argjson reboot "$reboot" \
		--argjson restarted "$restarted_json" --argjson started "$started_json" --argjson manual "$manual_json" \
		--argjson withheld "$withheld_json" --arg authlog "$authlog_status" \
		--arg adetail "$authlog_detail" --arg arelease "$managed_release" \
		--argjson apps "$app_units" \
		'{reboot_required: $reboot, restarted: $restarted, started: $started, needs_manual_restart: $manual,
		  withheld_restarts: $withheld, app_units_not_restarted: $apps,
		  authlog_protection: $authlog,
		  authlog: {status: $authlog,
		            detail: (if $adetail == "" then null else $adetail end),
		            release_dir: (if $arelease == "" then null else $arelease end)}}')"
	(( worker_verify_failed == 0 ))
}
# --- health (UPD-5, §4.2-7) -----------------------------------------------------
# The stage that decides whether an update is allowed to stand. Its verdict is
# the auto-rollback trigger (§4.2-8), so both of its failure directions are
# expensive: a false `unhealthy` rolls back a good update, and a false `healthy`
# leaves a broken panel with the maintenance gate dropped over it.
#
# Two rules follow from that and they are not symmetric:
#   - APPARATUS-ABSENT IS NOT UNHEALTHY. A check whose tooling is missing (no
#     curl, no systemctl, no shcpd, no panel DB) reports ok:true with
#     "skipped: <why>" and increments the skip count. Only a check that RAN and
#     answered badly is ok:false. This mirrors UpdateHealthCommand::checkUnits()
#     and it is what keeps a panel-less security-only run (AD-6) from being
#     declared unhealthy and rolled back.
#   - `checks_performed` is reported beside the verdict, because a run that
#     reports `healthy` with `checks_performed: 0` has proven nothing and the
#     journal must say so rather than let `healthy` imply a measurement.

# Every unit whose state is worth diffing across the update: the §4.4 managed
# allow-list plus the backup worker and the verify worker (shcp-worker is already
# in the allow-list). NOTE: the diff is vs the PRE-FLIP baseline, so it cannot
# catch a unit introduced by THIS release (it did not exist at baseline) — that
# first transition is guarded by verify_worker_redeploy failing the panel stage.
# This entry buys the ONGOING coverage: on later runs a verify worker that dies
# is a regression here.
health_unit_list() {
	managed_services_concrete
	printf '%s\n' shcp-backup-worker shcp-verify-worker shcp-webhook-worker shcp-mailhealth-worker shcp-transfer-worker shcp-audit-forward-worker shcp-dbio-worker shcp-privileged-slow-worker
	local release managed managed_rc=0
	release="$(panel_link_target)"
	if [[ -n "$release" ]]; then
		managed="$(managed_worker_inventory "$release")" || managed_rc=$?
		(( managed_rc == 0 )) || return 1
		while IFS= read -r unit; do [[ -n "$unit" ]] && printf '%s\n' "${unit%.service}"; done <<<"$managed"
	fi
	[[ -f "$UPLOAD_CLEANUP_UNIT_PATH" ]] && printf '%s\n' shcp-upload-cleanup
	# The tenant-cron pool (shcp-updater#181): a panel run stops every instance,
	# so a pool that does not come back is a regression this list must be able to
	# see. Instances are enumerated, never guessed (the pool size is an installer
	# choice), and the template itself is not a unit that can be active.
	if command -v systemctl >/dev/null 2>&1; then
		local cline
		while IFS= read -r cline; do
			cline="${cline%%[[:space:]]*}"
			[[ "$cline" =~ ^shcp-cron-worker@[^[:space:]@/]+\.service$ ]] && printf '%s\n' "${cline%.service}"
		done < <(systemctl list-units --all --type=service --plain --no-legend 'shcp-cron-worker@*.service' 2>/dev/null || true)
	fi
	return 0
}

# `systemctl is-active <n>.service`, verbatim. "unknown" when systemd is absent
# or the unit is not known to it — never guessed as inactive, because "the unit
# does not exist" and "the unit is stopped" are different facts and only the
# second is a regression.
unit_state() {
	command -v systemctl >/dev/null 2>&1 || { printf 'unknown'; return 0; }
	local st
	st="$(systemctl is-active "${1}.service" 2>/dev/null || true)"
	st="${st%%$'\n'*}"
	[[ -n "$st" ]] || st="unknown"
	printf '%s' "$st"
}

# The §4.2-7 liveness URL. THE DEFAULT IS NOT http://localhost/up: on every SHCP
# box :80 is Apache serving TENANT vhosts, and the panel is shcpd on the TLS
# port (installer config.sh PORT_HTTPS_SHCP=4650, overridable via the panel.port
# setting). Probing :80 would measure a customer's website — which can fail for
# a perfectly healthy panel (auto-rolling back a good update) and can just as
# easily answer 200 for a broken one.
#
# --insecure is deliberate and safe here: the installer issues shcpd a
# self-signed cert, this is a loopback liveness probe rather than an
# authentication boundary, and /up carries no secret (UpController).
panel_health_url() {
	if [[ -n "${SHCP_UPDATE_HEALTH_URL+x}" ]]; then
		printf '%s' "$SHCP_UPDATE_HEALTH_URL"
		return 0
	fi
	local port
	port="$(settings_get 'panel.port' '4650')"
	[[ "$port" =~ ^[0-9]{1,5}$ ]] || port=4650
	printf 'https://127.0.0.1:%s/up' "$port"
}

# health_http_probe — 0 healthy, 1 answered badly, 2 not probeable.
# Sets HEALTH_HTTP_DETAIL.
HEALTH_HTTP_DETAIL=""
# 1 when an HTTP response of ANY status came back from the panel URL: the panel
# process is listening and answering, whatever it said. The panel-live floor
# reads this; http-up judges the answer itself.
HEALTH_HTTP_ANSWERED=0
# 1 when the probe was switched off on purpose (SHCP_UPDATE_HEALTH_URL set to
# empty). The floor then has only the unit state to go on. A MISSING curl is not
# this: nothing chose to skip the probe, so nothing proved the panel answers.
HEALTH_HTTP_DISABLED=0
# 1 when the probe could not be attempted for a local reason (no curl, no temp
# file). The panel may be fine; nothing proved it. Never a regression.
HEALTH_HTTP_UNPROBED=0
health_http_probe() {
	HEALTH_HTTP_DETAIL=""
	HEALTH_HTTP_ANSWERED=0
	HEALTH_HTTP_DISABLED=0
	HEALTH_HTTP_UNPROBED=0
	local url
	url="$(panel_health_url)"
	if [[ -z "$url" ]]; then
		HEALTH_HTTP_DISABLED=1
		HEALTH_HTTP_DETAIL="skipped: health URL is empty"
		return 2
	fi
	if ! command -v curl >/dev/null 2>&1; then
		HEALTH_HTTP_UNPROBED=1
		HEALTH_HTTP_DETAIL="skipped: no curl"
		return 2
	fi
	local body code rc=0
	if ! body="$(mktemp)"; then
		HEALTH_HTTP_UNPROBED=1
		HEALTH_HTTP_DETAIL="skipped: could not create a temp file"
		return 2
	fi
	code="$(curl -sS --insecure --max-time "$HEALTH_HTTP_TIMEOUT" \
		-o "$body" -w '%{http_code}' "$url" 2>/dev/null)" || rc=$?
	if [[ $rc -ne 0 ]]; then
		rm -f "$body"
		HEALTH_HTTP_DETAIL="${url} unreachable (curl exit ${rc})"
		return 1
	fi
	[[ "$code" =~ ^[1-5][0-9][0-9]$ ]] && HEALTH_HTTP_ANSWERED=1
	# Every 1.0 panel serves /up, so a 404 is the panel answering badly (a
	# broken router, a wrong port answering) and is judged like any other
	# non-200: unhealthy here, attributed by the baseline diff in stage_health.
	if [[ "$code" != "200" ]]; then
		rm -f "$body"
		HEALTH_HTTP_DETAIL="HTTP ${code} from ${url}"
		return 1
	fi
	local status
	status="$(jq -r '.status // empty' <"$body" 2>/dev/null || true)"
	rm -f "$body"
	if [[ "$status" != "ok" ]]; then
		HEALTH_HTTP_DETAIL="200 from ${url} but the body is not {\"status\":\"ok\"}"
		return 1
	fi
	HEALTH_HTTP_DETAIL="200 status=ok"
	return 0
}

# Run a command under `timeout` when it exists. Not a hard dependency: a host
# without coreutils' timeout must still get a health verdict, and every command
# run here already carries its own network/console bounds.
run_bounded() {
	local secs="$1"
	shift
	if command -v timeout >/dev/null 2>&1; then
		timeout "$secs" "$@"
	else
		"$@"
	fi
}

# health_console_probe — 0 ok, 1 answered badly, 2 not probeable.
# Sets HEALTH_CONSOLE_DETAIL. The same smoke stage_panel runs against a STAGED
# tree, here against whatever the live symlink resolves to.
HEALTH_CONSOLE_DETAIL=""
health_console_probe() {
	HEALTH_CONSOLE_DETAIL=""
	local console="${PANEL_LINK}/bin/console"
	if [[ ! -x "$SHCPD_BIN" ]]; then
		HEALTH_CONSOLE_DETAIL="skipped: no ${SHCPD_BIN}"
		return 2
	fi
	if [[ ! -f "$console" ]]; then
		HEALTH_CONSOLE_DETAIL="skipped: no console at ${console}"
		return 2
	fi
	local out
	if out="$( ( cd "$PANEL_LINK" && run_bounded "$HEALTH_CONSOLE_TIMEOUT" \
			"$SHCPD_BIN" php-cli "$console" about --env=prod --no-interaction ) 2>/dev/null )"; then
		if console_about_ok "$out"; then
			HEALTH_CONSOLE_DETAIL="bin/console about exited 0"
			return 0
		fi
		HEALTH_CONSOLE_DETAIL="bin/console about exited 0 but reported no prod kernel under ${PANEL_LINK}"
		return 1
	fi
	HEALTH_CONSOLE_DETAIL="bin/console about failed under ${PANEL_LINK}"
	return 1
}

# A panel-report check name a ROLLBACK COULD NOT REPAIR. `shcp:update:health`
# exits FAILURE if any of its checks says no, and two of them are pure resource
# readings (disk-data, disk-releases, below 500 MiB free). Rolling an update
# back does not create disk space: downgrading the packages, flipping the
# symlink and restoring the database over a low-disk box leaves it exactly as
# low on disk, having additionally re-opened whatever the update just fixed
# (the SC-078 exposure). So a report whose ONLY failures are resource readings
# is an advisory, not an unhealthy verdict.
health_check_is_resource() { [[ "$1" == disk-* ]]; }

# health_panel_pgsql_option <console> — the --postgresql-before=<state> line for
# shcp:update:health, or nothing. The panel's own unit check then judges
# PostgreSQL against its pre-update state as unit-states does: given only while
# the feature is enabled (pgsql_managed_unit), the baseline recorded the unit,
# and this panel says it understands the option (shcp:update:capabilities
# features: health-postgresql-baseline). An older panel gets no new argument.
health_panel_pgsql_option() {
	local unit was caps
	unit="$(pgsql_managed_unit 2>/dev/null)"
	[[ -n "$unit" && -n "$HEALTH_BASELINE" && "$HEALTH_BASELINE" != null ]] || return 0
	was="$(jq -r --arg u "$unit" '(.units // {})[$u] // empty' <<<"$HEALTH_BASELINE" 2>/dev/null)"
	[[ -n "$was" ]] || return 0
	caps="$( ( cd "$PANEL_LINK" && run_bounded "$HEALTH_CONSOLE_TIMEOUT" \
		"$SHCPD_BIN" php-cli "$1" shcp:update:capabilities --json \
		--env=prod --no-interaction ) 2>/dev/null | head -c 65536)" || true
	jq -e '(.features // []) | index("health-postgresql-baseline") != null' <<<"$caps" >/dev/null 2>&1 || return 0
	[[ "$was" == active ]] && printf -- '--postgresql-before=active\n' || printf -- '--postgresql-before=inactive\n'
}

# health_panel_probe — 0 ok (possibly with an advisory), 1 answered badly,
# 2 not probeable. Sets HEALTH_PANEL_DETAIL / HEALTH_PANEL_REPORT /
# HEALTH_PANEL_ADVISORY.
HEALTH_PANEL_DETAIL=""
HEALTH_PANEL_ADVISORY=""
health_panel_probe() {
	HEALTH_PANEL_DETAIL=""
	HEALTH_PANEL_ADVISORY=""
	HEALTH_PANEL_REPORT=null
	local console="${PANEL_LINK}/bin/console"
	if [[ ! -x "$SHCPD_BIN" ]]; then
		HEALTH_PANEL_DETAIL="skipped: no ${SHCPD_BIN}"
		return 2
	fi
	if [[ ! -f "$console" ]]; then
		HEALTH_PANEL_DETAIL="skipped: no console at ${console}"
		return 2
	fi
	local out prc=0
	local -a pg_opt=()
	mapfile -t pg_opt < <(health_panel_pgsql_option "$console")
	out="$( ( cd "$PANEL_LINK" && run_bounded "$HEALTH_CONSOLE_TIMEOUT" \
		"$SHCPD_BIN" php-cli "$console" shcp:update:health \
		${pg_opt[@]+"${pg_opt[@]}"} --env=prod --no-interaction ) 2>/dev/null )" || prc=$?
	# shcp:update:health opens the panel database (PRAGMA quick_check) and we
	# run it as root, which can materialize root-owned WAL sidecars the panel
	# user could then never write again. The db-integrity check has always
	# repaired that after its own sqlite3 open; this probe needs it too, and
	# more urgently — preflight calls it while the panel is still live and
	# ungated. Same reasoning, same repair.
	db_fix_ownership "$SHCP_UPDATE_DB"
	local parsed
	parsed="$(printf '%s' "$out" | head -c "$MAX_HEALTH_REPORT_BYTES" \
		| tr -cd '\11\12\15\40-\176' \
		| jq -c 'if type == "object" then . else empty end' 2>/dev/null || true)"
	[[ -n "$parsed" ]] && HEALTH_PANEL_REPORT="$parsed"

	if [[ $prc -eq 0 ]]; then
		# SC-782: exit 0 is only a verdict alongside the report that carries it.
		if [[ "$(jq -r '.status // empty' <<<"$parsed" 2>/dev/null)" == healthy ]]; then
			HEALTH_PANEL_DETAIL="shcp:update:health exited 0"
			return 0
		fi
		HEALTH_PANEL_DETAIL="shcp:update:health exited 0 without a healthy report"
		return 1
	fi
	if [[ -z "$parsed" ]]; then
		# UpdateHealthCommand emits its JSON object on BOTH verdicts, and every
		# 1.0 panel carries the command, so a non-zero exit with nothing
		# parseable on stdout is the panel FAILING to report — a crash, a fatal
		# during boot, or the run_bounded timeout (exit 124) — not an absent
		# apparatus. That is a failed check; the baseline diff in stage_health
		# decides whether it is this run's regression.
		HEALTH_PANEL_DETAIL="shcp:update:health exited ${prc} with no report (crash, fatal or timeout)"
		return 1
	fi
	# It ran and answered. Branch on WHAT it said, not on the exit code.
	local -a bad=() advisory=()
	local name
	while IFS= read -r name; do
		[[ -n "$name" ]] || continue
		if health_check_is_resource "$name"; then
			advisory+=("$name")
		else
			bad+=("$name")
		fi
	done < <(jq -r '[.checks[]? | select(.ok == false) | .check] | .[]' <<<"$parsed" 2>/dev/null || true)

	if [[ ${#advisory[@]} -gt 0 ]]; then
		HEALTH_PANEL_ADVISORY="the panel reports low resources: ${advisory[*]} — a rollback cannot fix this"
	fi
	if [[ ${#bad[@]} -gt 0 ]]; then
		HEALTH_PANEL_DETAIL="shcp:update:health exited ${prc}: ${bad[*]}"
		return 1
	fi
	if [[ ${#advisory[@]} -eq 0 ]]; then
		# Non-zero with a parseable report that names no failing check: the
		# report and the exit code disagree, so trust the exit code — the
		# conservative reading of a command that could not explain itself.
		HEALTH_PANEL_DETAIL="shcp:update:health exited ${prc} but its report names no failing check"
		return 1
	fi
	HEALTH_PANEL_DETAIL="shcp:update:health exited ${prc}; ${HEALTH_PANEL_ADVISORY}"
	return 0
}

# The §4.2-1 baseline stage_health diffs against, recorded INSIDE the preflight
# stage result (§5.4 enumerates top-level keys but leaves stages[].result free
# form). Read back from the journal rather than carried in a global, so it is
# still the genuine PRE-update state on a run resumed hours later in a different
# process.
#
# EVERY MEASURABLE CHECK IS RECORDED HERE, not just the unit states. The whole
# point of the baseline is attribution: a check that was already failing before
# anything was applied is not a regression this run caused, and rolling an
# update back does not fix it. Recording only the units left http-up,
# console-about and panel-health as absolute pass/fail, which meant a box whose
# panel was already down declared every run unhealthy, auto-rolled back its own
# security patches and left itself gated (the exact inversion of SC-055, and it
# manufactures the SC-078 stranded-version exposure). The three console/HTTP
# probes cost a few seconds once per run; being able to attribute a failure is
# worth strictly more than that.
preflight_baseline_json() {
	local units u
	units="$( { while IFS= read -r u; do
			[[ -n "$u" ]] || continue
			printf '%s\t%s\n' "$u" "$(unit_state "$u")"
		done < <(health_unit_list); } | jq -R -s '
		split("\n") | map(select(length > 0) | split("\t"))
		| map(select(length == 2)) | map({(.[0]): .[1]}) | add // {}')"
	# true / false / null, where null is "not probeable" — which is NOT the same
	# fact as "probed and failing" and must never be collapsed into it.
	local http_up console_ok panel_ok panel_live=null rc=0
	health_http_probe || rc=$?
	case $rc in 0) http_up=true ;; 1) http_up=false ;; *) http_up=null ;; esac
	# The panel-live floor's baseline (shcp-updater#181): shcpd active AND the
	# panel answered. null where the floor does not apply to this box.
	if health_panel_live_applies; then
		panel_live=false
		health_panel_live_now && panel_live=true
	fi
	rc=0; health_console_probe || rc=$?
	case $rc in 0) console_ok=true ;; 1) console_ok=false ;; *) console_ok=null ;; esac
	rc=0; health_panel_probe || rc=$?
	case $rc in 0) panel_ok=true ;; 1) panel_ok=false ;; *) panel_ok=null ;; esac
	local workers
	workers="$(preflight_workers_baseline_json)"
	[[ -n "$workers" ]] || workers=null
	jq -nc --argjson units "$units" --argjson workers "$workers" --arg v "$(panel_current_version)" \
		--argjson up "$http_up" --argjson console "$console_ok" \
		--argjson panel "$panel_ok" --argjson live "$panel_live" --arg now "$(now_utc)" \
		'{units: $units, workers: $workers,
		  panel_version: (if $v == "" then null else $v end),
		  http_up: $up, console_ok: $console, panel_health_ok: $panel,
		  panel_live: $live,
		  probed_at: $now}'
}

health_baseline_json() {
	local jf
	jf="$(journal_path "$CURRENT_RUN_ID")"
	[[ -f "$jf" ]] || return 0
	jq -c '(.stages[]? | select(.stage == "preflight") | .result.baseline) // empty' \
		"$jf" 2>/dev/null || true
}

# The baseline, read once per attempt. Cached because three checks consult it
# and health_baseline_json is a jq over the whole journal.
HEALTH_BASELINE=""

# health_baseline_field <key> — "true" / "false" / "null", or "" when this run
# has no baseline at all (a journal written before UPD-5, or a bare stage_health
# invocation). "" means UNATTRIBUTABLE and must not be read as "was fine".
health_baseline_field() {
	[[ -n "$HEALTH_BASELINE" && "$HEALTH_BASELINE" != "null" ]] || return 0
	jq -r --arg k "$1" 'if has($k) then (.[$k] | tostring) else "" end' \
		<<<"$HEALTH_BASELINE" 2>/dev/null || true
}

# health_add_diffed <check> <rc> <detail> <baseline-key> — record a probe result
# as a REGRESSION DIFF rather than an absolute verdict, the way the unit-states
# check already does.
#
# rc: 0 answered well, 1 answered badly, 2 not probeable. A check that answered
# badly AND was already answering badly at the preflight baseline is recorded
# `skip`: this run did not cause it, so rolling this run back cannot fix it, and
# an auto-rollback fired on it would revert a good update over a pre-existing
# fault (UR-2/AD-6: security patching must keep working even when the panel is
# broken). Only a probe that was healthy at baseline and is not now is a
# regression.
health_add_diffed() {
	local name="$1" rc="$2" detail="$3" key="$4"
	case "$rc" in
		0) health_check_add "$name" true "$detail"; return 0 ;;
		2) health_check_add "$name" skip "$detail"; return 0 ;;
	esac
	if [[ "$(health_baseline_field "$key")" == "false" ]]; then
		# FAILING, not skipped (shcp-updater#181). This used to record `skip`, and
		# a skip counts toward nothing — so a box whose panel was already down
		# finished every nightly run `healthy (3 checks, 2 skipped)` while shcpd
		# crash-looped. It is recorded as a pre-existing failure: the verdict is
		# unhealthy, but no rollback fires (see HEALTH_REGRESSION).
		health_check_add "$name" preexisting \
			"${detail} — and it was already failing at the preflight baseline, so this run did not cause it"
		return 0
	fi
	health_check_add "$name" false "$detail"
}

# health_panel_live_applies — the panel-live floor applies to a panel box: the
# product is the panel (or unresolved, the sourced-test path) and shcpd is a unit
# systemd knows. A DNS-only or SHMP box, or one with no shcpd unit, has no panel
# to require.
health_panel_live_applies() {
	[[ -z "${PRODUCT:-}" || "$PRODUCT" == shcp ]] || return 1
	command -v systemctl >/dev/null 2>&1 || return 1
	systemctl cat shcpd.service >/dev/null 2>&1
}

# health_panel_live_check — the absolute floor under the diffed checks
# (shcp-updater#181). Every other check is a regression diff, and a diff has a
# blind spot: a panel that is dead both before and after a run diffs to "no
# change". Twice that meant a crash-looping shcpd behind a `healthy` verdict. So
# a panel box is healthy only if shcpd is active AND the panel answered HTTP
# (any status — whether the answer is good is http-up's job). "Could not ask"
# (no curl, no URL) fails this too: nothing proved the panel alive. Attribution
# still decides whether a rollback may fire: a floor that was already failing at
# baseline (or never measured there) is a pre-existing failure.
# health_panel_live_now — 0 iff shcpd is active and the panel answered the probe
# that just ran (or that probe was deliberately disabled). Reads the globals
# health_http_probe set, so call it straight after that probe.
health_panel_live_now() {
	[[ "$(unit_state shcpd)" == active ]] || return 1
	[[ "$HEALTH_HTTP_ANSWERED" == 1 || "$HEALTH_HTTP_DISABLED" == 1 ]]
}

health_panel_live_check() {
	health_panel_live_applies || return 0
	# Preflight decided the floor did not apply to this box (baseline panel_live
	# recorded as null: a non-panel product). A later process with PRODUCT
	# unresolved — a manual `rollback` never resolves it — must not start
	# applying the panel floor to a DNS/SHMP box on its own.
	if [[ -n "$HEALTH_BASELINE" && "$HEALTH_BASELINE" != "null" ]] \
			&& jq -e 'has("panel_live") and .panel_live == null' <<<"$HEALTH_BASELINE" >/dev/null 2>&1; then
		return 0
	fi
	local st detail
	st="$(unit_state shcpd)"
	if health_panel_live_now; then
		if [[ "$HEALTH_HTTP_ANSWERED" == 1 ]]; then
			health_check_add panel-live true "shcpd active and the panel answered"
		else
			health_check_add panel-live true "shcpd active (HTTP probe disabled by SHCP_UPDATE_HEALTH_URL)"
		fi
		return 0
	fi
	if [[ "$st" != active ]]; then
		detail="shcpd is ${st}"
	elif [[ "$HEALTH_HTTP_UNPROBED" == 1 ]]; then
		# Could not ask (no curl, no temp file): not live-proven, so never
		# healthy — but a local tooling fault is not a regression this run
		# caused, and must never roll a good update back.
		health_check_add panel-live preexisting \
			"shcpd is active but the panel could not be probed (${HEALTH_HTTP_DETAIL#skipped: }) — not proven live"
		return 0
	else
		detail="shcpd is active but the panel did not answer (${HEALTH_HTTP_DETAIL:-no probe})"
	fi
	if [[ -n "$HEALTH_BASELINE" && "$HEALTH_BASELINE" != "null" \
			&& "$(health_baseline_field panel_live)" != "true" ]]; then
		health_check_add panel-live preexisting \
			"${detail} — and the panel was not live at the preflight baseline either"
		return 0
	fi
	health_check_add panel-live false "$detail"
}

# health_workers_enabled_check -- workers judged against the PREFLIGHT inventory
# (SC-757, shcp-updater#343; supersedes #258's "every enabled worker active").
# The restart stage leaves a worker the operator stopped before the run stopped
# and replays the operator's enable state, so health must agree with it:
#   - `failed` after the run is ALWAYS a failure. A regression unless the worker
#     was already failed at preflight (or the run cannot be blamed for it -- see
#     "absent" below): then pre-existing -- unhealthy, but rollback cannot fix it.
#   - active (or starting) at preflight and not active now: a regression.
#   - its enable state flipped between enabled and disabled/masked: a regression
#     (the operator's choice was not preserved).
#   - stopped at preflight and stopped now: NOT a failure; reported as
#     "stopped before run, left stopped" (or "disabled before run, left disabled").
#   - absent from the preflight inventory: a regression (failed, or enabled and
#     not active) only when THIS run installed it (.managed_workers.installed --
#     exactly the set the restart stage starts); any other unit is pre-existing.
#   - no worker baseline at all (journal from before baseline.workers): falls back
#     to baseline.units for the active state; an enabled-but-inactive worker it
#     cannot attribute stays a pre-existing failure, as under #258.
health_worker_enable_armed() { case "$1" in enabled|enabled-runtime) return 0 ;; *) return 1 ;; esac; }
health_worker_enable_comparable() { case "$1" in enabled|enabled-runtime|disabled|masked|masked-runtime) return 0 ;; *) return 1 ;; esac; }
health_workers_enabled_check() {
	command -v systemctl >/dev/null 2>&1 || { health_check_add workers-enabled skip "no systemctl"; return 0; }
	local units u n now en_now was was_en jf="" new_here
	units="$(panel_messenger_units 2>/dev/null)" || { health_check_add workers-enabled skip "worker inventory unavailable"; return 0; }
	[[ -z "${CURRENT_RUN_ID:-}" ]] || jf="$(journal_path "$CURRENT_RUN_ID")"
	local -A base_active=() base_enabled=()
	if [[ -n "$HEALTH_BASELINE" && "$HEALTH_BASELINE" != "null" ]]; then
		if jq -e '.workers | type == "object"' <<<"$HEALTH_BASELINE" >/dev/null 2>&1; then
			while IFS=$'\t' read -r n was was_en; do
				[[ -n "$n" ]] || continue
				base_active["$n"]="$was"; base_enabled["$n"]="$was_en"
			done < <(jq -r '.workers | to_entries[] | [.key, (.value.active // "" | tostring), (.value.enabled // "" | tostring)] | @tsv' <<<"$HEALTH_BASELINE" 2>/dev/null)
		else
			while IFS=$'\t' read -r n was; do
				[[ -n "$n" ]] && base_active["$n"]="$was"
			done < <(jq -r '(.units // {}) | to_entries[] | [.key, (.value | tostring)] | @tsv' <<<"$HEALTH_BASELINE" 2>/dev/null)
		fi
	fi
	local -a regressed=() preexisting=() stopped_kept=() disabled_kept=()
	while IFS= read -r u; do
		[[ -n "$u" ]] || continue
		n="${u%.service}"
		now="$(unit_state "$n")"
		en_now="$(worker_enable_state "$u")"
		was="${base_active[$n]:-}"
		was_en="${base_enabled[$n]:-}"
		new_here=false
		[[ -z "$was" && -n "$jf" ]] \
			&& jq -e --arg u "$u" '.managed_workers.installed[]? | select(.unit == $u and .removed != true)' "$jf" >/dev/null 2>&1 \
			&& new_here=true
		if [[ "$now" == failed ]]; then
			if [[ "$was" == failed || ( -z "$was" && "$new_here" != true ) ]]; then
				preexisting+=("${n}=failed")
			else
				regressed+=("${n}=failed")
			fi
			continue
		fi
		if [[ -n "$was_en" ]] && health_worker_enable_comparable "$was_en" && health_worker_enable_comparable "$en_now"; then
			if health_worker_enable_armed "$was_en"; then
				health_worker_enable_armed "$en_now" || { regressed+=("${n} enable state ${was_en}->${en_now}"); continue; }
			else
				! health_worker_enable_armed "$en_now" || { regressed+=("${n} enable state ${was_en}->${en_now}"); continue; }
			fi
		fi
		[[ "$now" == active ]] && continue
		case "$was" in
			active|activating|reloading)
				regressed+=("${n}=${now} (active before run)") ;;
			"")
				health_worker_enable_armed "$en_now" || continue
				if [[ "$new_here" == true ]]; then
					regressed+=("${n}=${now} (installed this run, not started)")
				else
					preexisting+=("${n}=${now}")
				fi ;;
			*)
				if health_worker_enable_armed "$en_now"; then
					stopped_kept+=("$n")
				elif [[ "$en_now" == disabled || "$en_now" == masked* ]]; then
					disabled_kept+=("$n")
				fi ;;
		esac
	done <<<"$units"
	local kept="" k
	for k in "${stopped_kept[@]+"${stopped_kept[@]}"}"; do HEALTH_ADVISORIES+=("${k}: stopped before run, left stopped"); done
	for k in "${disabled_kept[@]+"${disabled_kept[@]}"}"; do HEALTH_ADVISORIES+=("${k}: disabled before run, left disabled"); done
	[[ ${#stopped_kept[@]} -gt 0 ]] && kept+="; stopped before run, left stopped: ${stopped_kept[*]}"
	[[ ${#disabled_kept[@]} -gt 0 ]] && kept+="; disabled before run, left disabled: ${disabled_kept[*]}"
	if [[ ${#regressed[@]} -gt 0 ]]; then
		local extra=""
		[[ ${#preexisting[@]} -gt 0 ]] && extra="; already failing at the preflight baseline: ${preexisting[*]}"
		health_check_add workers-enabled false \
			"SHCP workers broken by this run: ${regressed[*]}${extra}${kept}"
	elif [[ ${#preexisting[@]} -gt 0 ]]; then
		health_check_add workers-enabled preexisting \
			"SHCP workers not running (already down at the preflight baseline): ${preexisting[*]}${kept}"
	else
		health_check_add workers-enabled true "every SHCP worker running at preflight is running${kept}"
	fi
}

# One attempt over all checks. Sets HEALTH_CHECKS / HEALTH_ALL_OK /
# HEALTH_PREEXISTING_FAILED / HEALTH_PERFORMED / HEALTH_SKIPPED /
# HEALTH_PANEL_REPORT.
HEALTH_CHECKS=()
HEALTH_ALL_OK=true
HEALTH_PREEXISTING_FAILED=false
HEALTH_PERFORMED=0
HEALTH_SKIPPED=0
HEALTH_PANEL_REPORT=null
HEALTH_ADVISORIES=()

# <name> <true|false|preexisting|skip> <detail>. `preexisting` is a check that
# ran and failed but was already failing at the baseline: ok:false, counted as
# performed, and it makes the verdict unhealthy WITHOUT arming a rollback.
health_check_add() {
	local name="$1" verdict="$2" detail="$3" ok=true
	case "$verdict" in
		true)  HEALTH_PERFORMED=$((HEALTH_PERFORMED + 1)) ;;
		false) ok=false; HEALTH_ALL_OK=false; HEALTH_PERFORMED=$((HEALTH_PERFORMED + 1)) ;;
		preexisting) ok=false; HEALTH_PREEXISTING_FAILED=true; HEALTH_PERFORMED=$((HEALTH_PERFORMED + 1)) ;;
		skip)  HEALTH_SKIPPED=$((HEALTH_SKIPPED + 1)); detail="skipped: ${detail#skipped: }" ;;
	esac
	# Clamped for the same reason stage_error clamps: this text is built from
	# console/sqlite3 output and jq rejects invalid UTF-8 in --arg.
	detail="$(printf '%s' "$detail" | tr -cd '\11\40-\176' | cut -c1-512)"
	HEALTH_CHECKS+=("$(jq -nc --arg c "$name" --argjson ok "$ok" --arg d "$detail" \
		'{check: $c, ok: $ok, detail: $d}')")
}

health_checks_once() {
	HEALTH_CHECKS=()
	HEALTH_ALL_OK=true
	HEALTH_PREEXISTING_FAILED=false
	HEALTH_PERFORMED=0
	HEALTH_SKIPPED=0
	HEALTH_PANEL_REPORT=null
	HEALTH_ADVISORIES=()
	HEALTH_BASELINE="$(health_baseline_json)"

	# 1. unit-states — a unit that was `active` before and is not `active` now.
	#    One that was ALREADY inactive is not a regression: the operator's own
	#    stopped apache must not make every update roll itself back.
	local baseline="$HEALTH_BASELINE"
	if [[ -z "$baseline" || "$baseline" == "null" ]]; then
		health_check_add unit-states skip "no preflight baseline — nothing to diff"
	elif ! command -v systemctl >/dev/null 2>&1; then
		health_check_add unit-states skip "no systemctl"
	else
		local -a regressed=()
		local u was now_st n=0
		while IFS=$'\t' read -r u was; do
			[[ -n "$u" ]] || continue
			[[ "$was" == "active" ]] || continue
			n=$((n + 1))
			now_st="$(unit_state "$u")"
			[[ "$now_st" == "active" ]] || regressed+=("${u}=${now_st}")
		done < <(jq -r '(.units // {}) | to_entries[] | "\(.key)\t\(.value)"' <<<"$baseline")
		if [[ ${#regressed[@]} -gt 0 ]]; then
			health_check_add unit-states false \
				"units active at baseline and not active now: ${regressed[*]}"
		else
			health_check_add unit-states true "${n} units at baseline still active"
		fi
	fi

	# 1a. PostgreSQL (shcp-base#2127). unit-states diffs its unit like MariaDB's,
	#     so only up-before/down-after is this run's regression. A server already
	#     down, or requested and never set up, before the update is the box's
	#     state: said in the result, never a verdict (the panel's watchdog warns).
	local pg_unit pg_was
	pg_unit="$(pgsql_managed_unit 2>/dev/null)"
	if [[ -n "$pg_unit" ]]; then
		pg_was="$(jq -r --arg u "$pg_unit" '(.units // {})[$u] // empty' <<<"${baseline:-null}" 2>/dev/null)"
		[[ -z "$pg_was" || "$pg_was" == active || "$(unit_state "$pg_unit")" == active ]] \
			|| HEALTH_ADVISORIES+=("postgresql: ${pg_unit} was already ${pg_was} before the update; not counted against it")
	elif [[ "$(pgsql_record_state "$PG_MARKER_FILE")" == trusted && ! -e "$PG_FACTS_FILE" && ! -L "$PG_FACTS_FILE" ]]; then
		HEALTH_ADVISORIES+=("postgresql: requested but not set up before the update; not counted against it")
	fi

	# 1b. workers-enabled -- an ENABLED SHCP worker that is inactive (shcp-updater#258).
	health_workers_enabled_check

	# 2. http-up — diffed against baseline.http_up (see health_add_diffed).
	local rc=0
	health_http_probe || rc=$?
	health_add_diffed http-up "$rc" "$HEALTH_HTTP_DETAIL" http_up

	# 2b. panel-live — the absolute floor (shcp-updater#181), read off the probe
	#     that just ran.
	health_panel_live_check

	# 3. console-about — diffed against baseline.console_ok.
	rc=0
	health_console_probe || rc=$?
	health_add_diffed console-about "$rc" "$HEALTH_CONSOLE_DETAIL" console_ok

	# 4. db-integrity
	if ! command -v sqlite3 >/dev/null 2>&1; then
		health_check_add db-integrity skip "no sqlite3"
	elif [[ ! -f "$SHCP_UPDATE_DB" ]]; then
		health_check_add db-integrity skip "no panel database at ${SHCP_UPDATE_DB}"
	else
		local integrity
		integrity="$(db_integrity "$SHCP_UPDATE_DB")"
		# A root open materializes root-owned sidecars; the panel user could then
		# never write them again.
		db_fix_ownership "$SHCP_UPDATE_DB"
		if [[ "$integrity" == "ok" ]]; then
			health_check_add db-integrity true "ok"
		else
			health_check_add db-integrity false "integrity_check: ${integrity}"
		fi
	fi

	# 5. panel-health — the delegated deep check, LAST because it is the most
	#    expensive and the most likely to be unavailable. Diffed against
	#    baseline.panel_health_ok, and its resource-only failures come back as
	#    an advisory rather than a verdict (see health_panel_probe).
	rc=0
	health_panel_probe || rc=$?
	health_add_diffed panel-health "$rc" "$HEALTH_PANEL_DETAIL" panel_health_ok
	[[ -n "$HEALTH_PANEL_ADVISORY" ]] && HEALTH_ADVISORIES+=("$HEALTH_PANEL_ADVISORY")
	return 0
}

# stage_health — RETURNS 0 ON BOTH VERDICTS. `unhealthy` is recorded, never
# signalled by a non-zero return, and the reason is structural:
#
#   - run_stages' failure path writes journal_finish "failed" unconditionally,
#     so a non-zero return here would make `unhealthy` — a status §5.1, §5.4 and
#     UpdateRunStatus::UNHEALTHY all define — unreachable from the engine.
#   - that path also replaces STAGE_RESULT with {"failed": true}, destroying the
#     {status, attempts, checks} record journal-healthy.json pins.
#   - stage_error exists so a FAILING stage can attach a diagnosis. An unhealthy
#     verdict is not a stage failure: the stage did its job and the answer was no.
#
# The rollback runs between health and finalize either way (§4.2-8), driven by
# HEALTH_STATUS, which is also recoverable from `.health.status` for a resumed
# process.
stage_health() {
	local forced="${SHCP_UPDATE_FORCE_HEALTH:-}"
	case "$forced" in healthy|unhealthy) ;; *) forced="" ;; esac

	local result attempts=0
	if [[ -n "$forced" ]]; then
		# A forced verdict is always visible in the journal — it must never be
		# indistinguishable from a measured one.
		HEALTH_STATUS="$forced"
		HEALTH_REGRESSION=true
		result="$(jq -nc --arg s "$forced" --arg now "$(now_utc)" \
			'{status: $s, attempts: 0, checked_at: $now, forced: true,
			  regression: ($s == "unhealthy"),
			  checks_performed: 0, checks_skipped: 0, checks: [], advisories: [],
			  panel_report: null}')"
	else
		HEALTH_STATUS="unhealthy"
		HEALTH_REGRESSION=true
		local a
		for (( a = 1; a <= HEALTH_ATTEMPTS; a++ )); do
			attempts=$a
			health_checks_once
			# Healthy needs BOTH: nothing this run broke, and nothing still broken
			# from before it. A pre-existing failure alone is unhealthy without a
			# regression, which run_stages reads as "do not roll back".
			if [[ "$HEALTH_ALL_OK" == true && "$HEALTH_PREEXISTING_FAILED" != true ]]; then
				HEALTH_STATUS="healthy"
				HEALTH_REGRESSION=false
				break
			fi
			HEALTH_STATUS="unhealthy"
			HEALTH_REGRESSION=true
			[[ "$HEALTH_ALL_OK" == true ]] && HEALTH_REGRESSION=false
			# Nothing THIS run broke; only faults that were already failing at the
			# baseline. Retrying cannot change that verdict (the baseline is
			# fixed, the box was already like this), so the two 45 s sleeps would
			# only hold the maintenance gate up for 90 s longer. Break out.
			if [[ "$HEALTH_ALL_OK" == true && "$HEALTH_PREEXISTING_FAILED" == true ]]; then
				engine_log "health: attempt ${a} finds only pre-existing failures (nothing this run regressed) — the verdict cannot change on retry; not retrying"
				break
			fi
			if (( a < HEALTH_ATTEMPTS )); then
				engine_log "health: attempt ${a} says unhealthy — retrying in ${HEALTH_BACKOFF_SEC}s"
				(( HEALTH_BACKOFF_SEC > 0 )) && sleep "$HEALTH_BACKOFF_SEC"
			fi
		done
		local advisories_json
		advisories_json="$(printf '%s\n' "${HEALTH_ADVISORIES[@]+"${HEALTH_ADVISORIES[@]}"}" \
			| jq -R -s -c 'split("\n") | map(select(length > 0))')"
		result="$(printf '%s\n' "${HEALTH_CHECKS[@]+"${HEALTH_CHECKS[@]}"}" \
			| jq -s -c --arg s "$HEALTH_STATUS" --argjson a "$attempts" \
				--arg now "$(now_utc)" \
				--argjson perf "$HEALTH_PERFORMED" --argjson skip "$HEALTH_SKIPPED" \
				--argjson adv "$advisories_json" \
				--argjson report "$HEALTH_PANEL_REPORT" \
				--argjson reg "$( [[ "$HEALTH_STATUS" == unhealthy && "$HEALTH_REGRESSION" == true ]] && echo true || echo false )" \
				'{status: $s, attempts: $a, checked_at: $now, regression: $reg,
				  checks_performed: $perf, checks_skipped: $skip,
				  checks: ., advisories: $adv, panel_report: $report}')"
	fi

	# UPD-14 §7.3: an INERT drift summary, attached to the health object and
	# nowhere else. Not a checks[] entry, not through health_add_diffed, not into
	# HEALTH_ALL_OK — drift is a standing property of the box, so it must not be
	# able to arm the auto-rollback of the release that introduced the
	# requirement. Computed in a command substitution so nothing it touches can
	# leak into this stage's globals, and every failure path leaves the health
	# object exactly as it was.
	local drift_summary="" drift_merged=""
	drift_summary="$(reconcile_health_summary 2>/dev/null)" || drift_summary=""
	if [[ -n "$drift_summary" ]]; then
		# Into a SECOND variable: a failed command substitution assigns the empty
		# string before `||` ever runs, so `result="$(jq …)" || result="$result"`
		# would destroy the health report it was meant to leave alone.
		drift_merged="$(jq -c --argjson d "$drift_summary" '.config_drift = $d' <<<"$result" 2>/dev/null)" || drift_merged=""
		if [[ -n "$drift_merged" ]]; then
			result="$drift_merged"
		fi
	fi

	STAGE_RESULT="$result"
	# `.health` is written here, not only through the stage record, because it is
	# the key UpdateJournalImporter stores as healthReport and it must exist even
	# on the paths where the runner replaces STAGE_RESULT.
	#
	# HEALTH_JOURNAL_KEY exists because rollback_run RE-USES this stage body to
	# re-measure the box after a rollback. Writing that second measurement to
	# `.health` overwrote the verdict that TRIGGERED the rollback — so the
	# operator's CRITICAL notification carried a health report in which every
	# check passed, with no indication of which one had said no, and a run that
	# died in stage_apt (never reaching the health stage at all) ended up
	# carrying a `healthy` report. Worse, run_stages recovers HEALTH_STATUS from
	# `.health.status` on resume: a kill between that write and the rollback's
	# journal_finish left a resumable run reading `healthy` over an interrupted
	# rollback, which then ran finalize and cleared the maintenance gate.
	# `.health` is the run's own immutable verdict; the rollback's re-measurement
	# goes to `.rollback_health` and to the rollback record's health_after_report.
	journal_update "$CURRENT_RUN_ID" '.[$k] = $h' \
		--arg k "$HEALTH_JOURNAL_KEY" --argjson h "$result"
	engine_log "health: ${HEALTH_STATUS} (${attempts} attempt(s), ${HEALTH_PERFORMED} checks performed, ${HEALTH_SKIPPED} skipped)"
	return 0
}

# --- release-dir retention (UPD-5, §4.5 "Retention: update.keep_releases") -----
# `keep_n` counts PRIOR release dirs. The active release is never counted and
# never pruned.
#
# The whole design constraint is one sentence: THE RELEASE A ROLLBACK WOULD NEED
# MUST NOT BE PRUNED BEFORE IT COULD BE USED. Three layers enforce it:
#   1. retention runs only in stage_finalize, and a failed run never reaches
#      finalize (identical reasoning to the snapshot prune above it);
#   2. .panel.previous_release_dir is protected unconditionally, above and beyond
#      keep_n — even keep_releases=1 with an odd version ordering cannot prune
#      the tree a flip-back needs;
#   3. the active target is RE-RESOLVED from the filesystem at prune time. After
#      a rollback the active release is the prior one, and a prune that trusted
#      the journal's .panel.release_dir would delete the tree being served.
# A basename that fails ver_valid is never pruned: an unrecognised directory
# under /opt/shcp-releases is not ours to delete.
# Results come back through globals rather than stdout: the caller needs three
# values (count, keep set, reason) and a command substitution would fork them
# into a subshell that cannot set them.
RELEASE_PRUNE_KEPT='[]'
RELEASE_PRUNE_WHY=""
RELEASE_PRUNE_COUNT=0
release_prune() {   # release_prune <keep_n> <protect_dir> — returns 1 when it could not look
	local keep_n="$1" protect="$2"
	RELEASE_PRUNE_KEPT='[]'
	RELEASE_PRUNE_WHY=""
	RELEASE_PRUNE_COUNT=0

	if [[ ! -d "$RELEASES_DIR" ]]; then
		RELEASE_PRUNE_WHY="no releases dir at ${RELEASES_DIR}"
		return 1
	fi
	local active
	active="$(panel_link_target)"
	if [[ -z "$active" ]]; then
		RELEASE_PRUNE_WHY="${PANEL_LINK} does not resolve — refusing to prune what may be live"
		return 1
	fi
	local protect_resolved=""
	if [[ -n "$protect" && -d "$protect" ]]; then
		protect_resolved="$(readlink -f "$protect" 2>/dev/null || printf '%s' "$protect")"
	fi

	local -a priors=()
	local d b rp
	for d in "$RELEASES_DIR"/*; do
		[[ -d "$d" ]] || continue
		b="${d##*/}"
		ver_valid "$b" || continue
		rp="$(readlink -f "$d" 2>/dev/null || printf '%s' "$d")"
		[[ "$rp" == "$active" ]] && continue
		priors+=("$b")
	done

	# Selection sort, newest first, on ver_gt — the same comparator panel_target
	# uses, rather than importing sort -V's ordering for three integers.
	local n=${#priors[@]} i j max tmp
	for (( i = 0; i < n; i++ )); do
		max=$i
		for (( j = i + 1; j < n; j++ )); do
			ver_gt "${priors[j]}" "${priors[max]}" && max=$j
		done
		if [[ $max -ne $i ]]; then
			tmp="${priors[i]}"; priors[i]="${priors[max]}"; priors[max]="$tmp"
		fi
	done

	local -a keep=() doomed=()
	local protect_base=""
	if [[ -n "$protect_resolved" ]]; then
		protect_base="${protect_resolved##*/}"
	fi
	# The rollback target goes to the head of the keep set, unconditionally.
	if [[ -n "$protect_base" ]]; then
		for b in "${priors[@]+"${priors[@]}"}"; do
			[[ "$b" == "$protect_base" ]] && { keep+=("$b"); break; }
		done
	fi
	for b in "${priors[@]+"${priors[@]}"}"; do
		local already=0 k
		for k in "${keep[@]+"${keep[@]}"}"; do [[ "$k" == "$b" ]] && already=1; done
		[[ $already -eq 1 ]] && continue
		if [[ ${#keep[@]} -lt $keep_n ]]; then keep+=("$b"); else doomed+=("$b"); fi
	done

	local pruned=0
	for b in "${doomed[@]+"${doomed[@]}"}"; do
		ver_valid "$b" || continue   # never build a path from an unchecked name
		rm -rf "${RELEASES_DIR:?}/${b}" || continue
		pruned=$((pruned + 1))
		engine_log "finalize: pruned release dir ${b}"
	done

	RELEASE_PRUNE_KEPT="$(printf '%s\n' "${active##*/}" "${keep[@]+"${keep[@]}"}" \
		| jq -R -s 'split("\n") | map(select(length > 0))')"
	RELEASE_PRUNE_COUNT="$pruned"
	return 0
}

# panel_import_run <run_id> — §4.2-9. Hands the journal to the panel, which
# persists SystemUpdateRun and fires the §4.8 notifications. It is also §4.5
# step 6's ONLY notify mechanism: the engine has no notifier, the panel does.
#
# Always returns 0. §4.2-9 is explicit that this is best-effort and that a panel
# which will not run is covered by the next shcp:update:check.
panel_import_run() {
	local run_id="$1"
	if [[ ! -x "$SHCPD_BIN" || ! -f "${PANEL_LINK}/bin/console" ]]; then
		engine_log "import-run: no panel console — the next shcp:update:check will pick ${run_id} up"
		return 0
	fi
	( cd "$PANEL_LINK" && run_bounded 120 "$SHCPD_BIN" php-cli "${PANEL_LINK}/bin/console" \
		shcp:update:import-run "$run_id" --env=prod --no-interaction ) \
		>>"${RUNS_DIR}/${run_id}/log" 2>&1 \
		|| engine_log "import-run: the panel could not import ${run_id} (reported, not fatal)"
	return 0
}

# --- run-journal retention (UPD-5, §4.5 "Retention: ... last 5 run journals") --
# The third item in §4.5's retention sentence; the other two (release dirs,
# snapshots) are handled above. Run dirs are tiny but unbounded — a journal, a
# status.json, a log, a db-update.log and a downloaded manifest per run, forever
# on a box with a daily security timer — and UPD-5 added latest_recoverable_run,
# which jq-parses run dirs newest-first on every `shcp-update check`.
#
# Four things are NEVER pruned, and each for its own reason:
#   - the current run (it is still being written);
#   - anything whose basename is not a run id (not ours to delete — the same
#     rule release_prune applies to /opt/shcp-releases);
#   - a run whose status is failed / unhealthy / rolled_back_partial, because
#     those are exactly the ones `rollback_available` still offers and the ones
#     a manual `shcp-update rollback <id>` needs the journal of;
#   - a run that never reached a terminal status — `running`, or a journal with
#     no `.status` at all (SC-451).
#     journal_init writes status:"running" at creation
#     (§5.4) and only journal_finish replaces it, so this is precisely the run
#     that was killed mid-flight: TimeoutStartSec, OOM, power loss. It is the
#     one `resume` exists for, it is still holding the maintenance gate up
#     (SC-318 — the EXIT trap deliberately does not clear it), and both
#     latest_recoverable_run and the `stale_running_journal` blocker point the
#     operator straight at it. Deleting it destroys the evidence and the
#     recovery path at once, and makes the panel's own CRITICAL text ("its
#     journal is preserved for resume or rollback") a lie. Unbounded growth is
#     not a real risk here: a crashed run leaves the panel gated, so it is
#     resolved in minutes, not accumulated.
RUN_KEEP=5
RUN_PRUNE_COUNT=0
RUN_PRUNE_WHY=""
run_prune() {   # run_prune <keep_n> <current_run_id> — returns 1 when it could not look
	local keep_n="$1" current="$2"
	RUN_PRUNE_COUNT=0
	RUN_PRUNE_WHY=""
	if [[ ! -d "$RUNS_DIR" ]]; then
		RUN_PRUNE_WHY="no runs dir at ${RUNS_DIR}"
		return 1
	fi
	local d st seen=0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		[[ "$d" == "$current" ]] && continue
		[[ -f "$(journal_path "$d")" ]] || continue
		# Webmail runs keep their own quota (webmail_run_prune): counting them here
		# would let a few webmail activations push the update run a rollback still
		# needs out of RUN_KEEP.
		journal_is_webmail "$d" && continue
		seen=$((seen + 1))
		(( seen <= keep_n )) && continue
		st="$(jq -r '.status // empty' "$(journal_path "$d")" 2>/dev/null || true)"
		case "$st" in
			failed|unhealthy|rolled_back_partial) continue ;;
			# running / no-status = killed mid-flight; awaiting_reboot = paused for
			# a deliberate reboot. Neither has reached a terminal status, so both
			# are what `resume` exists for and both hold the maintenance gate up —
			# pruning either would strip the journal resume/rollback needs (SC-451).
			running|awaiting_reboot|"") continue ;;
		esac
		# Re-validate before building an rm -rf path from a directory name
		# (SC-317): the name came off the filesystem, but the cost of being
		# wrong here is a recursive delete.
		run_id_valid "$d" || continue
		rm -rf "${RUNS_DIR:?}/${d}" 2>/dev/null || continue
		RUN_PRUNE_COUNT=$((RUN_PRUNE_COUNT + 1))
	done
	return 0
}

# --- recovery-closure re-vendor (SC-415 / SC-507) -----------------------------
# The installer vendors the AutoSSL recovery tools' dependency closure into
# SHCP_LIBEXEC_DIR at install and then PURGES its own extracted tree (SC-415), so
# the closure is frozen at install: a panel update moves the panel version (and
# can move paths or overlays) and nothing re-vendors it. installer#676 added
# drift DETECTION — a .vendored stamp plus a WARN from shcp-reconcile and
# /usr/sbin/shcp-{hostname,cert} — but could not fix it: reconcile has no source
# tree to copy from once the installer tree is gone. The update engine does have
# one — the manifest's per-family installer half — so it re-runs the vendoring
# against a freshly-fetched, signature-verified installer tree and rewrites the
# stamp, closing the SC-415 loop the installer left open.
#
# WHAT to vendor is read from the fetched tree, never hardcoded here: the file
# list is its config.sh SHCP_RECOVERY_VENDOR_FILES, the overlays are its
# os/{deb,rpm}. So a closure the installer grows never silently loses a file to a
# stale copy in this engine — the exact lockout SC-507 exists to prevent.

# revendor_closure_files <installer_root> — the closure's tree-relative paths, one
# per line: config.sh's SHCP_RECOVERY_VENDOR_FILES entries, then every
# os/{deb,rpm}/*.sh overlay present in the tree. Both families, always. Non-zero
# (no output) if the tree carries no readable config.sh.
revendor_closure_files() {
	local root="$1" cfg="$1/config.sh" fam overlay
	[[ -r "$cfg" ]] || return 1
	# Pull only the array literal — sourcing all of config.sh expects a full
	# installer environment. Entries are quoted, plain relative paths; grab every
	# quoted token between the `=(` and its closing `)`.
	sed -n '/SHCP_RECOVERY_VENDOR_FILES=(/,/)/p' "$cfg" \
		| grep -oE "['\"][^'\"]+['\"]" | tr -d "\"'"
	# Overlays are vendored by glob, exactly as the installer does, both families.
	for fam in deb rpm; do
		for overlay in "$root/os/$fam"/*.sh; do
			[[ -f "$overlay" ]] || continue
			printf 'os/%s/%s\n' "$fam" "$(basename -- "$overlay")"
		done
	done
	return 0
}

# revendor_closure_apply <installer_root> — copy the closure from a verified
# installer tree into SHCP_LIBEXEC_DIR and (re)write the .vendored stamp. The
# engine runs as root, so the copied files are root-owned 0644 — the SC-507
# posture — without an explicit chown that would fail under the non-root test
# harness. NEVER removes a file (install/overwrite only): a crash mid-apply
# therefore cannot turn a stale-but-complete closure into a broken one, and the
# stamp is written LAST + atomically, so a crash before it leaves the OLD stamp
# and drift detection simply re-fires. 1 on any failure, stamp untouched.
revendor_closure_apply() {
	local root="$1" dir="$SHCP_LIBEXEC_DIR" rel src hash
	local -a closure_paths=()
	mapfile -t closure_paths < <(revendor_closure_files "$root")
	(( ${#closure_paths[@]} > 0 )) || { engine_log "revendor: installer tree at ${root} declares no recovery closure"; return 1; }

	install -d -m 0755 "$dir" 2>/dev/null || { engine_log "revendor: cannot create ${dir}"; return 1; }
	for rel in "${closure_paths[@]}"; do
		src="$root/$rel"
		[[ -f "$src" ]] || { engine_log "revendor: closure source ${rel} missing from the installer tree"; return 1; }
		install -D -m 0644 "$src" "$dir/$rel" 2>/dev/null \
			|| { engine_log "revendor: failed to vendor ${rel}"; return 1; }
	done

	# Stamp in shcp_vendor_drift_reason's exact contract (panel_version header +
	# "<sha256>  <rel>" manifest lines). panel_version is read from the SAME
	# /opt/shcp/VERSION the drift check reads, so "no drift" holds the instant this
	# returns. Compose to a temp, install over the real stamp last.
	local panel_version installer_version stamp tmp
	panel_version="$(panel_current_version)"
	[[ -n "$panel_version" ]] || { engine_log "revendor: installed panel version unknown — refusing to stamp"; return 1; }
	installer_version="$(tr -d '[:space:]' <"$root/VERSION" 2>/dev/null || true)"
	stamp="$dir/.vendored"
	tmp="$stamp.tmp.$$"
	{
		printf '# SHCP recovery-tool vendor stamp (SC-415) — machine-read by shcp-reconcile\n'
		printf '# and /usr/sbin/shcp-{hostname,cert} to detect a closure gone stale after an\n'
		printf '# update. Rewritten by shcp-update on a panel update. Do not edit by hand.\n'
		printf 'panel_version %s\n' "$panel_version"
		printf 'installer_version %s\n' "${installer_version:-unknown}"
		printf 'vendored_at %s\n' "$(now_utc)"
		for rel in "${closure_paths[@]}"; do
			hash="$(sha256sum "$dir/$rel" 2>/dev/null)" || return 1
			printf '%s  %s\n' "${hash%% *}" "$rel"
		done
	} >"$tmp" || { rm -f "$tmp"; engine_log "revendor: failed to compose the stamp"; return 1; }
	install -m 0644 "$tmp" "$stamp" 2>/dev/null || { rm -f "$tmp"; engine_log "revendor: failed to install the stamp"; return 1; }
	rm -f "$tmp"
	return 0
}

# revendor_recovery_closure — fetch the signed installer half for the RUNNING
# series, extract it, and re-vendor the recovery closure from it. BEST-EFFORT by
# contract (see the stage_finalize call site): a stale recovery closure is a
# condition shcp-reconcile and the recovery tools already WARN on, so refreshing
# it must never fail an otherwise-healthy update — and the fetch stays out of the
# outage window because finalize runs after the maintenance gate is down. Only a
# box that ALREADY carries a vendored closure is refreshed; one that never had
# the recovery tools is left alone. Echoes a one-line JSON result; always 0.
revendor_recovery_closure() {
	if ! [[ -d "$SHCP_LIBEXEC_DIR/functions" || -f "$SHCP_LIBEXEC_DIR/.vendored" ]]; then
		printf '%s' '{"revendored": false, "reason": "no vendored recovery closure on this box to refresh"}'
		return 0
	fi
	# The extracted tree is read by path and installed as root, so it lives in a
	# private scratch dir, never the run dir under the panel-owned /var/lib/shcp.
	local scratch work
	if ! scratch="$(private_scratch_dir)"; then
		printf '%s' '{"revendored": false, "reason": "no private scratch directory for the installer artifact"}'
		return 0
	fi
	work="${scratch}/installer"
	# reconcile_fetch_templates resolves the installer half for the running series
	# (panel_current_version is the just-flipped version here), verifies it against
	# the pinned release key and extracts it — the one trust boundary, reused.
	if ! reconcile_fetch_templates "$work"; then
		rm -rf "$scratch" 2>/dev/null || true
		engine_log "revendor: installer artifact fetch/verify failed — recovery closure left as-is (drift detection still covers it)"
		printf '%s' '{"revendored": false, "reason": "installer artifact fetch/verify failed"}'
		return 0
	fi
	local result
	if revendor_closure_apply "$work"; then
		result="$(jq -nc --arg v "$(panel_current_version)" '{revendored: true, panel_version: $v}')"
	else
		engine_log "revendor: re-vendor failed after a good fetch — recovery closure left as-is"
		result='{"revendored": false, "reason": "re-vendor failed after fetch"}'
	fi
	rm -rf "$scratch" 2>/dev/null || true
	printf '%s' "$result"
	return 0
}

stage_finalize() {
	# Always clears the maintenance flag (also covered by the EXIT trap for
	# abnormal exits — SC-318).
	maintenance_clear_if_mine "$CURRENT_RUN_ID"

	# SC-064 version-floor ratchet. STRICTLY gated: only a run that ended
	# health-green AND did not roll back may advance the floor. stage_finalize is
	# REUSED after a converged auto-rollback (run_stages, `case apt|panel|db|
	# restart`), where HEALTH_STATUS is "unhealthy" and ROLLBACK_OUTCOME is set —
	# an ungated writer would bump the floor to the very version the box just
	# REJECTED (F4), then panel_target would refuse every later update below it.
	# panel_current_version here is the JUST-INSTALLED version (the panel already
	# flipped in stage_panel). Runs on every healthy run, not series runs only
	# (reviewer #1): a routine point release must advance the floor too, or the
	# SC-064 replay window stays open for the common case. A noop run never enters
	# here health-green (it skips the health stage), so it correctly does not bump.
	if [[ "$HEALTH_STATUS" == "healthy" && -z "$ROLLBACK_OUTCOME" ]]; then
		version_floor_advance "$(panel_current_version)"
	fi

	# §4.5 snapshot retention, and the reason it lives HERE rather than in the
	# snapshot stage: a failed run returns before finalize, so the snapshot its
	# rollback needs is never pruned. This run's own snapshot is excluded on top
	# of that, and the prune is idempotent (a re-entered finalize finds nothing
	# left to drop).
	local pruned
	pruned="$(snapshot_prune_apply "$CURRENT_RUN_ID")"
	[[ "$pruned" =~ ^[0-9]+$ ]] || pruned=0

	# Release-dir retention. The max(1, …) floor mirrors
	# UpdateSettings::keepReleases() so a hand-edited setting cannot make the two
	# repos disagree about how many rollback targets exist.
	local keep_n
	keep_n="$(settings_get 'update.keep_releases' '1')"
	[[ "$keep_n" =~ ^[0-9]+$ ]] || keep_n=1
	(( keep_n < 1 )) && keep_n=1

	local protect
	protect="$(jq -r '.panel.previous_release_dir // empty' \
		"$(journal_path "$CURRENT_RUN_ID")" 2>/dev/null || true)"

	local pruned_runs=null run_prune_why=""
	if run_prune "$RUN_KEEP" "$CURRENT_RUN_ID"; then
		pruned_runs="$RUN_PRUNE_COUNT"
	else
		run_prune_why="$RUN_PRUNE_WHY"
	fi

	if release_prune "$keep_n" "$protect"; then
		STAGE_RESULT="$(jq -nc --argjson r "$RELEASE_PRUNE_COUNT" --argjson s "$pruned" \
			--argjson kept "$RELEASE_PRUNE_KEPT" --arg active "$(panel_link_target)" \
			--argjson keep_n "$keep_n" --argjson pr "$pruned_runs" \
			--arg prwhy "$run_prune_why" --argjson rk "$RUN_KEEP" \
			'{pruned_releases: $r, retained_releases: $kept,
			  active_release: (if $active == "" then null else $active end),
			  keep_releases: $keep_n, pruned_snapshots: $s,
			  pruned_runs: $pr, keep_runs: $rk,
			  run_prune_skipped: (if $prwhy == "" then null else $prwhy end)}')"
	else
		# null, never 0. Zero means "I looked and there was nothing to remove";
		# null means "I could not look", and the two must not be confused by
		# anyone reading the journal.
		STAGE_RESULT="$(jq -nc --argjson s "$pruned" --arg why "$RELEASE_PRUNE_WHY" \
			--argjson keep_n "$keep_n" --argjson pr "$pruned_runs" \
			--arg prwhy "$run_prune_why" --argjson rk "$RUN_KEEP" \
			'{pruned_releases: null, prune_skipped: $why, retained_releases: null,
			  active_release: null, keep_releases: $keep_n, pruned_snapshots: $s,
			  pruned_runs: $pr, keep_runs: $rk,
			  run_prune_skipped: (if $prwhy == "" then null else $prwhy end)}')"
	fi

	# SC-415 / SC-507: track the recovery-tool closure to the panel THIS run
	# installed. ONLY on a health-green run that actually flipped the panel: this
	# stage is REUSED after a converged auto-rollback (ROLLBACK_OUTCOME set), and
	# the same gate version_floor_advance uses keeps re-vendor off that path — so a
	# rolled-back box keeps the closure matching the release it rolled back TO, and
	# no closure restore is needed anywhere. Apt-only / noop runs never flip, so
	# their closure is already correct. Best-effort: never fails finalize.
	if [[ "$HEALTH_STATUS" == "healthy" && -z "$ROLLBACK_OUTCOME" ]] \
			&& [[ "$(jq -r '.panel.flipped // false' "$(journal_path "$CURRENT_RUN_ID")" 2>/dev/null || echo false)" == "true" ]]; then
		local revendor_json
		revendor_json="$(revendor_recovery_closure)"
		journal_update "$CURRENT_RUN_ID" '.revendor = $r' --argjson r "$revendor_json" 2>/dev/null || true
		# Installer converge runs as its own verb from shcp-update-converge.timer,
		# never here (SC-432); finalize only says a new release is waiting for it.
		converge_mark_pending "$(panel_current_version)" "$CURRENT_RUN_ID" \
			|| engine_log "converge: could not mark ${CONVERGE_PENDING}; run 'shcp-update converge-installer --apply' by hand"
		converge_paired_record "${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json"
	fi

	return 0
}

# --- rollback (UPD-5, §4.5) -------------------------------------------------------
# ONE body, shared by the manual verb and the auto path (§4.2-8: "the same
# rollback code path as the manual verb"). The manual verb owns the lock, the
# trap and the preconditions; this owns the six steps and the journal.
#
# SC-376 is the rule the whole function is shaped by: a
# rollback journals `rolled_back` ONLY when every applicable step verifiably
# succeeded. Anything short — one package left on the new version, a flip that
# could not be undone, a restore that could not be verified, a worker that would
# not start, a health check that still says no — journals `rolled_back_partial`,
# names the step and the reason, LEAVES the maintenance gate up and notifies
# CRITICAL. A step that did not APPLY records null, which is not a shortfall.
ROLLBACK_RESULT='{}'

# The last COMPLETED rollback stage record, or empty. This is the idempotence
# latch that survives a kill -9, a systemd restart and a separately-invoked
# manual verb — the process-local AUTO_ROLLBACK_FIRED does not.
rollback_last_record() {
	local run_id="$1" jf
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" ]] || return 0
	jq -c '[.stages[]? | select(.stage == "rollback") | select((.done_at // null) != null)]
		| last // empty' "$jf" 2>/dev/null || true
}

rollback_db_restore_proof_path() {
	printf '%s/%s/db-restore.json' "$RUNS_DIR" "$1"
}

db_snapshot_identity() {
	local snap="$1" real
	real="$(realpath -e -- "$snap" 2>/dev/null)" || return 1
	stat -Lc '%d:%i:%s:%Y' -- "$real" 2>/dev/null || return 1
}

# A completed rollback record remains compatible, but the dedicated proof closes
# the kill window between a verified restore and that much later record. It is
# trusted only for the same run and the same immutable snapshot bytes.
rollback_db_restore_proof_valid() {
	local run_id="$1" snap="$2" proof real ident want
	proof="$(rollback_db_restore_proof_path "$run_id")"
	[[ -f "$proof" ]] && path_no_symlink "$proof" || return 1
	real="$(realpath -e -- "$snap" 2>/dev/null)" || return 1
	ident="$(db_snapshot_identity "$snap")" || return 1
	want="$(db_logical_sha "$snap" 2>/dev/null)" || return 1
	jq -e --arg r "$run_id" --arg s "$real" --arg i "$ident" --arg w "$want" '
		.schema == 1 and .run_id == $r and .snapshot == $s
		and .snapshot_identity == $i and .expected_fingerprint == $w
		and .restored_fingerprint == $w and (.verified_at | type == "string")
	' "$proof" >/dev/null 2>&1
}

rollback_db_restore_proof_write() {
	local run_id="$1" snap="$2" result="$3" proof real ident expected restored
	proof="$(rollback_db_restore_proof_path "$run_id")"
	real="$(realpath -e -- "$snap" 2>/dev/null)" || return 1
	ident="$(db_snapshot_identity "$snap")" || return 1
	expected="$(jq -r '.expected_fingerprint // empty' <<<"$result")"
	restored="$(jq -r '.restored_fingerprint // empty' <<<"$result")"
	[[ -n "$expected" && "$expected" == "$restored" ]] || return 1
	jq -nc --arg r "$run_id" --arg s "$real" --arg i "$ident" \
		--arg w "$expected" --arg g "$restored" --arg at "$(now_utc)" \
		'{schema:1,run_id:$r,snapshot:$s,snapshot_identity:$i,
		  expected_fingerprint:$w,restored_fingerprint:$g,verified_at:$at}' \
		| atomic_write_durable "$proof"
}

# Is there anything this run actually applied? Used ONLY by the auto path, so an
# unhealthy run that changed nothing journals `unhealthy` rather than a rollback
# of nothing. The manual verb always proceeds — an operator asking for a rollback
# gets a journaled answer either way.
rollback_has_work() {
	local run_id="$1" jf
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" ]] || return 1
	[[ -e "${RUNS_DIR}/${run_id}/db-update.log" ]] && return 0
	jq -e '(.packages.changed // []) | length > 0' "$jf" >/dev/null 2>&1 && return 0
	# A series run that re-pointed the package manager (or was about to) changed
	# the box even when no package moved yet: a refresh or download failure right
	# after the flip leaves packages.changed empty, and "nothing to roll back"
	# would strand the box on the new series' repo (updater#159; both families).
	series_repoint_latched "$jf" && return 0
	local dir
	dir="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	[[ -n "$dir" ]] && panel_link_points_at "$dir" && return 0
	return 1
}

# run_superseded_by <run_id> — the newest run NEWER than <run_id> that actually
# applied something, or empty. Run ids sort chronologically byte-wise (LC_ALL=C
# is exported at the top), so the id comparison IS the time comparison.
#
# This is the gate that stops the single most destructive thing this engine can
# do. Rolling back run A after run B has landed on top of it restores A's
# pre-update snapshot over the live database — days of tenant data — while
# leaving B's code active, and every mechanical step reports success. Measured
# on the demo box: three days of accounts, one left afterwards, exit 0, journal
# `rolled_back`. `latest_recoverable_run` has always known this hazard ("a later
# converged run supersedes an older bad one") but it only ever guarded the
# ADVISORY surface, never the verb.
#
# "Applied something" is rollback_has_work, not merely "has a terminal status":
# the daily security timer produces a noop run most nights, and letting a noop
# supersede would make yesterday's bad run permanently unrecoverable.
run_superseded_by() {
	local run_id="$1" d
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		[[ "$d" > "$run_id" ]] || break
		[[ -f "$(journal_path "$d")" ]] || continue
		journal_is_webmail "$d" && continue
		if rollback_has_work "$d"; then
			printf '%s' "$d"
			return 0
		fi
	done
	return 0
}

# rollback_stage_start <run_id> — append a NEW rollback record unless one is
# still open. A retry over a rolled_back_partial run must APPEND rather than
# rewrite the first attempt: the history of what was tried is not ours to edit
# (SC-377). journal_stage_start cannot be used, because it
# returns early when ANY record for the stage exists.
rollback_stage_start() {
	local run_id="$1" jf
	jf="$(journal_path "$run_id")"
	if jq -e '[.stages[]? | select(.stage == "rollback") | select((.done_at // null) == null)]
			| length > 0' "$jf" >/dev/null 2>&1; then
		return 0   # a crashed attempt left one open — reuse it
	fi
	journal_update "$run_id" '.stages += [{stage: "rollback", started_at: $now}]' \
		--arg now "$(now_utc)"
}

# rollback_run <run_id> <reason> <trigger:auto|manual>
# Never dies for anything short of a programming error: a rollback failure must
# not be more destructive than the failure it is answering (the principle
# stage_error is built on). Every step reports through ROLLBACK_RESULT.
# pkg_identity_in <baseline_json> <pkg> — that package's installed version (deb)
# or NEVRAs (rpm) in a baseline_json listing; "" when it is not installed.
pkg_identity_in() {
	jq -r --arg p "$2" 'if type == "array" then [.[] | select(.name == $p) | .nevra] | join(" ") else (.[$p] // "") end' \
		<<<"$1" 2>/dev/null || true
}

rollback_run() {
	local run_id="$1" reason="$2" trigger="${3:-manual}"
	local jf
	jf="$(journal_path "$run_id")"
	ROLLBACK_RESULT='{}'
	ROLLBACK_OUTCOME=""
	CURRENT_RUN_ID="$run_id"
	PANEL_WORKER_SNAPSHOT=()

	# --- step 0: EVERY GATE IS COMPUTED BEFORE ANYTHING IS MUTATED ---------------
	# Step 3 flips the symlink back, which destroys the evidence step 4's gate
	# would read. Computing the gates up front removes the ordering trap entirely.
	local panel_release_dir panel_previous_dir snapshot changed db_stage_result
	release_trees_recover || true
	panel_release_dir="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	panel_previous_dir="$(jq -r '.panel.previous_release_dir // empty' "$jf" 2>/dev/null || true)"
	snapshot="$(snapshot_path_of_run "$run_id")"
	# A schema-aware wrapper cannot enter an older release without its resolver.
	# Refuse before ownership changes, stopping consumers or downgrading packages.
	if [[ -n "$panel_previous_dir" ]] && ! console_identity_release_gate "$panel_previous_dir"; then
		ROLLBACK_OUTCOME="failed"
		ROLLBACK_RESULT='{"outcome":"failed","shortfalls":["rollback target is incompatible with the active console identity wrapper"]}'
		return 1
	fi
	if console_identity_required && ! console_environment_install; then
		ROLLBACK_OUTCOME="failed"
		ROLLBACK_RESULT='{"outcome":"failed","shortfalls":["console identity environment prerequisites are missing or unsafe"]}'
		return 1
	fi
	# SC-355: require provenance minted after authenticated staging before
	# reconverging a rollback target or inspecting its unit/config artifacts.
	local release_ownership_ready=true
	if [[ -n "$panel_previous_dir" && -d "$panel_previous_dir" ]]; then
		if release_trust_stamp_valid "$panel_previous_dir"; then
			release_secure_ownership "$panel_previous_dir" || release_ownership_ready=false
		else
			release_ownership_ready=false
		fi
	fi
	# SC-531 (#917): rollback re-installs the previous release's
	# root-run systemd units (helpers_redeploy / verify_worker_redeploy below), but a
	# An unstamped pre-SC tree may have been rewritten while panel-owned; metadata
	# cannot authenticate those bytes. Refuse it before any redeploy reads it.
	if [[ "$release_ownership_ready" != true ]]; then
		engine_log "rollback: release ownership hardening of ${panel_previous_dir} failed"
	fi
	local panel_flip_happened=false
	if [[ -n "$panel_release_dir" ]] && panel_link_points_at "$panel_release_dir"; then
		panel_flip_happened=true
	fi
	# HAS SOMETHING ELSE MOVED THE PANEL SINCE? The live symlink is on neither
	# this run's release nor the one it replaced, so a LATER run put it there.
	# This matters for step 4 and nothing else: step 3 already declines to flip
	# (there is nothing of ours to undo), but step 4's gate is the db-update.log
	# transcript, which is independent of the symlink — so a superseded run
	# would happily restore its own pre-update snapshot underneath newer code,
	# destroying every write since and leaving the newer release serving an
	# older schema. Measured, not assumed: cmd_rollback's supersession refusal
	# is the other half, and neither is sufficient alone (a security-scope run
	# never records a release dir at all).
	local panel_superseded=false
	if [[ -n "$panel_release_dir" ]] && [[ "$panel_flip_happened" != true ]] \
			&& { [[ -z "$panel_previous_dir" ]] \
				|| ! panel_link_points_at "$panel_previous_dir"; }; then
		panel_superseded=true
	fi
	local verify_source_state='' verify_target_state='' verify_artifacts_proven=true verify_transition_needed=false verify_contract_relevant=false
	if [[ "$release_ownership_ready" == true ]] && { jq -e '.verify_worker != null' "$jf" >/dev/null 2>&1 \
			|| [[ -e "$VERIFY_WORKER_UNIT_PATH" || -L "$VERIFY_WORKER_UNIT_PATH" ]] \
			|| grep -Rqs 'VerifySmarthostMessage' "${panel_release_dir}/config/packages" "${panel_previous_dir}/config/packages" 2>/dev/null; }; then
		verify_contract_relevant=true
	fi
	if [[ "$verify_contract_relevant" == true && -n "$panel_release_dir" ]]; then
		verify_source_state="$(verify_worker_release_state "$panel_release_dir")" || verify_artifacts_proven=false
	fi
	if [[ "$verify_contract_relevant" == true && -n "$panel_previous_dir" ]]; then
		verify_target_state="$(verify_worker_release_state "$panel_previous_dir")" || verify_artifacts_proven=false
	fi
	if [[ "$verify_source_state" == verify && "$verify_target_state" == privileged ]]; then
		verify_transition_needed=true
	elif [[ -n "$verify_source_state" && -n "$verify_target_state" \
			&& "$verify_source_state" != "$verify_target_state" ]]; then
		verify_artifacts_proven=false
	fi
	# "The db stage had run" is decided by the TRANSCRIPT existing, because the
	# shell redirection that creates db-update.log happens at the exact instant
	# stage_db execs the new release's console. Its presence is ground truth for
	# "a migration command actually ran"; its absence for "nothing migrated".
	# Explicitly NOT the gate, for stage_db's own reasons:
	#   .panel != null       — means only that a flip was ATTEMPTED;
	#   .panel.flipped       — a kill during helpers_redeploy leaves it false;
	#   stages[db].result    — {"failed": true} cannot distinguish "the console
	#                          exited non-zero" from "stage_db returned 1 before
	#                          it ever reached the console". The first needs a
	#                          restore; the second must NOT have one, because a
	#                          restore destroys every panel write since the
	#                          snapshot.
	local db_migration_ran=false
	[[ -e "${RUNS_DIR}/${run_id}/db-update.log" ]] && db_migration_ran=true
	# A security rollback may bypass panel transition proof only when its own
	# journal shows that the package stage was the last mutating forward stage.
	# Re-read this on every retry; an earlier rollback verdict is not proof.
	local security_os_only=false security_scope_unproven=false security_apt_failure=false
	if [[ "$reason" == *"stage apt failed"* ]] \
			|| jq -e 'any(.stages[]?; .stage == "apt" and .result.failed == true)' "$jf" >/dev/null 2>&1; then
		security_apt_failure=true
	fi
	if [[ "$security_apt_failure" == true ]] \
			&& jq -e '.scope == "security" and .request.scope == "security"
			and .panel == null and (.stages | type == "array")
			and ([.stages[] | select(.stage == "apt")] | length == 1)
			and all(.stages[]; (.stage == "self-update" or .stage == "preflight" or .stage == "snapshot"
				or .stage == "apt" or .stage == "rollback"))' "$jf" >/dev/null 2>&1 \
			&& [[ -z "$panel_release_dir" && -z "$panel_previous_dir" \
				&& "$db_migration_ran" != true ]]; then
		security_os_only=true
	fi
	if [[ "$security_apt_failure" == true ]] \
			&& { ! jq empty "$jf" >/dev/null 2>&1 \
				|| { { [[ "$(jq -r '.scope // empty' "$jf" 2>/dev/null)" == security ]] \
				|| [[ "$(jq -r '.request.scope // empty' "$jf" 2>/dev/null)" == security ]]; } \
				&& [[ "$security_os_only" != true ]]; }; }; then
		security_scope_unproven=true
	fi
	# A RETRY of a partial rollback must not restore the snapshot a second time: the
	# first attempt already put it back and the panel has been writing since (its
	# workers restart whenever the database converged). db-update.log still exists,
	# so without this every retry discarded those writes.
	local db_restored_before=false
	if [[ -n "$snapshot" ]] && rollback_db_restore_proof_valid "$run_id" "$snapshot"; then
		db_restored_before=true
	elif jq -e '.result.db_restored == true' <<<"$(rollback_last_record "$run_id")" >/dev/null 2>&1; then
		db_restored_before=true
	fi
	# SC-710 (#198): a previous attempt that could not un-gate the pools (or un-gated
	# them but never flipped back) latches here. Its own flip moved the link, so the
	# retry cannot rediscover the need from the symlink; without this it skipped the
	# un-gate and reported rolled_back over still-gated pools.
	local prior_pool_gate_ungated="" prior_pool_gate_regate=false
	prior_pool_gate_ungated="$(jq -r 'if .result.pool_gate.ungated == false then "false" else "" end' \
		<<<"$(rollback_last_record "$run_id")" 2>/dev/null || true)"
	if jq -e '.result.pool_gate.regate_required == true' <<<"$(rollback_last_record "$run_id")" >/dev/null 2>&1; then
		prior_pool_gate_regate=true
	fi
	changed="$(jq -c '.packages.changed // []' "$jf" 2>/dev/null || true)"
	[[ -n "$changed" ]] || changed='[]'
	# Kept as a CORROBORATING signal only, so an operator can see both readings.
	db_stage_result="$(jq -c '(.stages[]? | select(.stage == "db") | .result) // null' \
		"$jf" 2>/dev/null || true)"
	[[ -n "$db_stage_result" ]] || db_stage_result='null'
	if [[ "$db_migration_ran" != true && "$db_stage_result" != "null" ]] \
			&& jq -e 'has("strategy")' <<<"$db_stage_result" >/dev/null 2>&1; then
		engine_log "rollback: the db stage recorded a strategy but no transcript exists — trusting the filesystem"
	fi

	# Is this a CROSS-SERIES rollback? The run latches .series.repoint_started just
	# before the pointer write and .series.suite_rewritten once stage_apt's re-point
	# completed (series_repoint_latched reads either), so this reads the journal
	# rather than re-deriving the series relationship (REQ_SERIES is not populated
	# in the manual verb). A restore of a pointer that never actually moved puts the
	# snapshot's identical bytes back — harmless. It changes two things below: step
	# 1.5 reverts the suite+pins from the
	# config snapshot before the downgrades, and the outcome predicate treats an apt
	# exact-version shortfall as a WARNING, not a gate-blocking rolled_back_partial
	# (SC-476).
	local series_rollback=false series_ctar=""
	if series_repoint_latched "$jf"; then
		series_rollback=true
		series_ctar="$(config_tar_path_of_run "$run_id")"
	fi

	rollback_stage_start "$run_id"
	engine_log "rollback of ${run_id} (${trigger}): ${reason}"

	local -a shortfalls=()
	if [[ "$security_scope_unproven" == true ]]; then
		shortfalls+=("security scope: journal does not prove an OS-package-only run")
	fi
	# Cross-series apt shortfalls (an exact prior deb aged out of the reverted
	# suite) land HERE, not in shortfalls: they are advisory, not gate-blocking.
	local -a warnings=()

	# --- step 1: maintenance flag + workers --------------------------------------
	# The owner check exists because maintenance_clear_if_mine compares run ids:
	# overwriting a flag the OPERATOR raised (owner "operator") would let a
	# successful rollback drop a gate put up for an unrelated reason.
	local maint_foreign=false maint_owner=""
	if [[ -f "$MAINT_FLAG" ]]; then
		maint_owner="$(jq -r '.run_id // empty' "$MAINT_FLAG" 2>/dev/null || true)"
	fi
	if [[ -n "$maint_owner" && "$maint_owner" != "$run_id" ]]; then
		maint_foreign=true
		engine_log "rollback: the maintenance flag belongs to '${maint_owner}' — leaving it alone"
	else
		mkdir -p "$SHCP_UPDATE_STATE_DIR" 2>/dev/null || true
		# No schema-pending marker: rollback restores the OLD code, and the DB is
		# equal-or-ahead (never behind), so no read can 500 on a missing column.
		maintenance_write "$run_id" ""
	fi
	local workers_quiesced=true cleanup_rollback_ready=true cleanup_transition_needed=false verify_rollback_ready=true
	if [[ "$release_ownership_ready" != true ]]; then
		cleanup_rollback_ready=false
		verify_rollback_ready=false
		shortfalls+=("panel: previous release application ownership could not be secured (SC-355)")
	fi
	if [[ "$verify_artifacts_proven" != true ]]; then
		verify_rollback_ready=false
		shortfalls+=("verify worker: source/target exact route and unit agreement could not be proven")
	fi
	# Held until this process exits (on_engine_exit), and past that if the
	# rollback does not converge: a job started now would write to a database
	# the restore below replaces.
	panel_timers_hold "$run_id"
	if ! panel_workers_stop; then
		workers_quiesced=false
		cleanup_rollback_ready=false
		verify_rollback_ready=false
		shortfalls+=("workers: every shared Messenger consumer could not be verified inactive")
	fi

	# A target without upload_cleanup cannot understand rows in that receiver.
	# Requeue them while both possible consumers are stopped, then remove only a
	# release-hash-proven managed unit. Any uncertainty blocks the flip and keeps
	# maintenance up; exposing old code with stranded rows is forbidden.
	local rollback_target_has_cleanup=false
	if [[ -n "$panel_previous_dir" ]] && upload_cleanup_release_declares "$panel_previous_dir"; then
		rollback_target_has_cleanup=true
	fi
	# A run whose panel stage completed as skipped, and that recorded no panel and
	# migrated nothing, never touched the release: it has no cleanup transition to
	# undo. Without this the installer's own unit read as the run's, its removal
	# was refused, and every worker stayed stopped after a manual rollback of a
	# healthy security run (shcp-master#3224). Only that positive record counts: a
	# journal with no panel stage may be a run that died inside it.
	local panel_untouched=false
	if [[ -z "$panel_release_dir" && -z "$panel_previous_dir" && "$db_migration_ran" != true ]] \
			&& jq -e '.panel == null
				and any(.stages[]?; .stage == "panel" and .done_at != null and .result.skipped != null)
				and all(.stages[]?; .stage != "panel" or .result.skipped != null)' "$jf" >/dev/null 2>&1; then
		panel_untouched=true
	fi
	local rollback_source_has_cleanup=false
	if [[ "$security_os_only" != true && "$panel_untouched" != true ]] \
		&& { { [[ -n "$panel_release_dir" ]] && upload_cleanup_release_declares "$panel_release_dir"; } \
			|| [[ -e "$UPLOAD_CLEANUP_UNIT_PATH" || -L "$UPLOAD_CLEANUP_UNIT_PATH" ]]; }; then
		rollback_source_has_cleanup=true
	fi
	if [[ "$rollback_source_has_cleanup" == true && "$rollback_target_has_cleanup" != true ]]; then
		if [[ "$workers_quiesced" != true ]]; then
			cleanup_rollback_ready=false
		else
			cleanup_transition_needed=true
		fi
	fi

	# --- step 1.5: cross-series repo revert (the load-bearing safety net) ----------
	# Reverting the series pointer from the config snapshot BEFORE the package
	# downgrade is what makes the reverted series' packages fetchable at all (the repo
	# serves only a series' current versions). Family-dispatched — deb reverts the apt
	# suite pointer + Priority-1001 pins, rpm reverts the /etc/dnf/vars series pointers
	# — and no OS_FAMILY is left without an arm. Selective either way: never clobbers
	# /etc/shcp* or /etc/systemd/system (SC-476). A failure here is a warning, not a
	# gate-blocker: the maintenance gate is governed by the panel/DB/health steps
	# below, not by the repo revert half.
	# rpm first proves the pre-upgrade leaf and primes its repo key
	# (series_rpm_snapshot_url + series_dnf_prime): dnf keeps repo_gpgcheck keys per
	# cache dir, so a wiped /var/cache/dnf would fail the refresh below exactly as an
	# unprimed forward flip does, and an unreachable leaf must never become the
	# pointer (shcp-updater#159). A failed proof is a warning and disables settle.
	local apt_reverted=null apt_revert_json=null series_prime_ok=true
	if [[ "$series_rollback" == true ]]; then
		local revert_ok=false revert_what="series repo"
		local revert_json='{"restored": false, "reason": "no revert handler for this OS family"}'
		case "$OS_FAMILY" in
			deb)
				revert_what="apt suite/pin"
				series_rollback_restore_apt "$series_ctar" && revert_ok=true
				revert_json="$SERIES_APT_REVERT_RESULT" ;;
			rpm)
				revert_what="dnf series-var"
				# Prove the PRE-upgrade leaf (and prime its key) BEFORE the var moves.
				# Restoring first pointed a box whose packages are still on the new
				# series at a withdrawn or unreachable leaf: every later refresh failed
				# and SHCP packages silently stopped patching, under a green
				# rolled_back (break-it finding, updater#178). Unprovable ⇒ the pointer
				# stays on the new series, which its packages match.
				local prior_url=""
				if prior_url="$(series_rpm_snapshot_url "$series_ctar")" \
						&& series_dnf_prime "$prior_url"; then
					series_rollback_restore_rpm "$series_ctar" && revert_ok=true
					revert_json="$(jq -c '. + {prime: true}' <<<"$SERIES_RPM_REVERT_RESULT")"
				else
					series_prime_ok=false
					revert_json="$(jq -nc --arg u "${prior_url:-(unresolvable)}" --arg d "${SERIES_DNF_PRIME_DETAIL:-binding, key or snapshot refused}" \
						'{restored: false, prime: false, reason: ("the pre-upgrade series leaf " + $u + " did not fetch/verify (" + $d + ") — the pointer stays on the new series")}')"
				fi ;;
		esac
		apt_revert_json="$revert_json"
		if [[ "$revert_ok" == true ]]; then
			apt_reverted=true
			if [[ "$OS_FAMILY" == deb ]] && ! osf_rollback_source_ensure; then
				engine_log "rollback: signed rollback source could not follow the restored suite"
			fi
			# The index still reflects the NEW series from the failed run; refresh it
			# so the downgrades below resolve against the reverted series' repo.
			osf_refresh_index || engine_log "rollback: package index refresh after the ${revert_what} revert reported a problem (continuing)"
		else
			apt_reverted=false
			local revert_why
			revert_why="$(jq -r --arg w "$revert_what" '.reason // ("the " + $w + " revert failed")' <<<"$revert_json")"
			engine_log "rollback: cross-series ${revert_what} revert FAILED — ${revert_why} (packages may not settle to the reverted series)"
			warnings+=("${revert_what}: ${revert_why}; packages may not settle to the reverted series")
		fi
	fi
	if [[ "$OS_FAMILY" == deb && "$series_rollback" != true ]] && osf_rollback_source_ensure; then
		osf_refresh_index || engine_log "rollback: rollback suite index refresh failed"
	fi
	if ! osf_withdrawal_load; then
		engine_log "rollback: signed withdrawal list unavailable or invalid; refusing daemon downgrades"
	fi

	# --- step 1.7: un-gate tenant PHP-FPM pools (SC-710, shcp-updater#198) ------------
	# Before the flip-back AND before the package downgrades, while the source release
	# is still live, its runtime still installed and the database still on its
	# schema: the source console is the one that renders pools correctly for this
	# family. Only when the flip-back will be attempted (now or deferred past the DB
	# restore), and only when the target's pool tokens differ from the source's —
	# rolling back between two releases with the same gate leaves every pool valid,
	# and un-gating then would reopen the cross-pool reach for nothing. Un-gating is
	# availability-safe for any vhost; a gate-lacking target has that reach anyway.
	# A failure is a shortfall: partial, the maintenance gate stays up AND the panel
	# workers are left stopped (step 5), because maintenance only blocks HTTP and a
	# queued vhost render under the old code would 403 its site over a still-gated
	# pool. It does not block the flip: the pools and on-disk vhosts agree right now.
	# A retry of such an attempt re-runs it (the latch in step 0).
	local pool_gate_source="" pool_gate_target="" pool_gate_ungated=null pool_gate_detail=""
	local pool_gate_attempt=false pool_gate_retry=false pool_gate_regate_required=false
	if [[ -n "$panel_previous_dir" && -d "$panel_previous_dir" \
			&& "$verify_rollback_ready" == true && "$cleanup_rollback_ready" == true ]]; then
		if [[ "$panel_flip_happened" == true ]]; then
			pool_gate_attempt=true
		elif [[ "$prior_pool_gate_ungated" == "false" ]] && panel_link_points_at "$panel_previous_dir"; then
			pool_gate_attempt=true
			pool_gate_retry=true
		fi
	fi
	if [[ "$pool_gate_attempt" == true ]]; then
		pool_gate_source="$(pool_gate_identity "$panel_release_dir")"
		pool_gate_target="$(pool_gate_identity "$panel_previous_dir")"
		if [[ "$pool_gate_retry" == true && ( -z "$panel_release_dir" || ! -d "$panel_release_dir" ) ]]; then
			pool_gate_ungated=false
			pool_gate_detail="a previous attempt left the pools gated and the source release ${panel_release_dir:-(unrecorded)} is gone, so they cannot be un-gated automatically"
			engine_log "rollback: ${pool_gate_detail}"
			shortfalls+=("pool gate: ${pool_gate_detail}; remove the env[SHCP_POOL_EXPECT] and fpm-pool-guard.php auto_prepend_file lines from each tenant pool and reload PHP-FPM")
		elif [[ "$pool_gate_source" == "$pool_gate_target" ]]; then
			pool_gate_detail="the target release derives the same pool tokens (${pool_gate_source})"
		elif ! pool_gate_can_ungate "$panel_release_dir"; then
			pool_gate_ungated=false
			pool_gate_detail="the source release cannot un-gate pools (no shcp:php:republish-pools --ungate)"
			engine_log "rollback: pools stay GATED (${pool_gate_source}) while the target has ${pool_gate_target:-no gate}, and ${pool_gate_detail}; a site re-rendered by the old panel will 403"
			shortfalls+=("pool gate: ${pool_gate_detail}; re-render the pools before the old panel re-renders a vhost")
		elif rollback_ungate_pools "$run_id" "$panel_release_dir"; then
			pool_gate_ungated=true
			pool_gate_detail="every pool re-rendered without the gate (source ${pool_gate_source}, target ${pool_gate_target:-no gate})"
			if [[ -n "$pool_gate_target" ]]; then
				# The target has a gate of its own, but nothing here re-applies it (its
				# vhosts must present its tokens first): pools stay un-gated until a
				# forward update runs republish-pools.
				pool_gate_detail="${pool_gate_detail}; the target's own gate is NOT re-applied — pools stay un-gated until the next update runs republish-pools"
			fi
			engine_log "rollback: ${pool_gate_detail}"
		else
			pool_gate_ungated=false
			pool_gate_detail="the un-gate did not complete (${POOL_GATE_UNGATE_DETAIL})"
			engine_log "rollback: ${pool_gate_detail} — pools may still be gated; see runs/${run_id}/rollback-ungate-pools.log"
			shortfalls+=("pool gate: ${pool_gate_detail}; retry the rollback, or remove the env[SHCP_POOL_EXPECT] and fpm-pool-guard.php auto_prepend_file lines from each tenant pool and reload PHP-FPM")
		fi
	fi

	# --- step 2: packages ---------------------------------------------------------
	# Errors are HELD, not fatal (§4.5 step 2). osf_apply is never called here.
	# ORDER: packages.changed follows the package database's install order, so a
	# dependency the run pulled in precedes the package that needed it. Restore
	# every package that existed before FIRST, then remove what the run installed
	# in REVERSE install order (dependents before their dependencies). Removing a
	# new dependency first takes its dependents with it — on rpm that erases a
	# daemon and moves its edited %config to .rpmsave (measured, updater#159).
	# Settling to the reverted series' current version is only meaningful once the
	# pointer is back AND (rpm) the restored leaf proved reachable: with the shcp
	# leaf unreachable, "what the enabled repos offer" is some other repo's build.
	local settle_allowed=false
	if [[ "$series_rollback" == true && "$apt_reverted" == true && "$series_prime_ok" == true ]]; then
		settle_allowed=true
	fi
	local -a pkg_results=() failed_restores=()
	# RPM source-build groups are attempted lazily when the first member needs the
	# series settle path.  The cache makes later members consume their own re-query
	# result without starting a second transaction on resume through this loop.
	local -A settle_group_attempted=() settle_member_ok=() settle_member_version=()
	local restored=0 total=0
	local entry pkg pkg_label from to arch from_nevra to_nevra source_rpm rpm_force_group now_ver ok why
	# What is installed NOW, once (shcp-updater#179 break-it round 3). A package
	# whose installed version is neither what this run left (`to`) nor what it
	# found (`from`) was moved by something after this run — a later security run
	# patching openssl, typically — and restoring the run's `from` would walk it
	# back below that fix. A retry of a partial rollback reaches exactly that state.
	local now_baseline
	now_baseline="$(baseline_json 2>/dev/null || printf '{}')"
	[[ -n "$now_baseline" ]] || now_baseline='{}'
	# The essential packages on the box before the run, or before this rollback,
	# must all be on it afterwards (#321). Read now, so a product that never had
	# one (a DNS-only box has no web server, a source-tree engine no package) is
	# not held to it.
	local -a essential_before=()
	local epkg
	while IFS= read -r epkg; do
		[[ -n "$epkg" ]] || continue
		if osf_pkg_installed "$epkg" \
				|| jq -e --arg p "$epkg" 'any(.[]; .pkg == $p and .from != null)' <<<"$changed" >/dev/null 2>&1; then
			essential_before+=("$epkg")
		fi
	done < <(osf_essential_pkgs)
	# deb: the run's exact downgrades go back in one transaction first, so lockstep
	# pairs can move together (osf_deb_downgrade_group). A member the group did not
	# restore falls through to the one-package path below.
	OSF_DEB_GROUP_OK=()
	if [[ "$OS_FAMILY" == deb ]]; then
		local -a group_specs=()
		local gpkg gfrom gto
		while IFS=$'\t' read -r gpkg gfrom gto; do
			[[ "$(jq -r --arg p "$gpkg" '.[$p] // ""' <<<"$now_baseline" 2>/dev/null)" == "$gto" ]] || continue
			osf_version_available "$gpkg" "$gfrom" || continue
			group_specs+=("${gpkg}=${gfrom}")
		done < <(jq -r '.[] | select(.from != null and .to != null) | [.pkg, .from, .to] | @tsv' <<<"$changed")
		if (( ${#group_specs[@]} > 1 )); then
			osf_deb_downgrade_group "${group_specs[@]}" \
				|| engine_log "rollback: the grouped downgrade did not apply (${OSF_TRANSACTION_DETAIL:-${APT_APPLY_DETAIL:-apt failed}}); restoring one package at a time"
		fi
	fi
	while IFS= read -r entry; do
		[[ -n "$entry" ]] || continue
		total=$((total + 1))
		pkg="$(jq -r '.pkg // empty' <<<"$entry")"
		from="$(jq -r 'if .from == null then "" else .from end' <<<"$entry")"
		to="$(jq -r 'if .to == null then "" else .to end' <<<"$entry")"
		arch="$(jq -r '.arch // empty' <<<"$entry")"
		pkg_label="${pkg}${arch:+.${arch}}"
		from_nevra="$(jq -r '.from_instance.nevra // empty' <<<"$entry")"
		to_nevra="$(jq -r '.to_instance.nevra // empty' <<<"$entry")"
		source_rpm="$(jq -r '.from_instance.source_rpm // .to_instance.source_rpm // empty' <<<"$entry")"
		rpm_force_group=false
		if [[ "$OS_FAMILY" == "rpm" && "$settle_allowed" == true && -n "$source_rpm" \
				&& "$(jq -r '.installonly // false' <<<"$entry")" != true ]]; then
			rpm_force_group=true
		fi
		if [[ "$OS_FAMILY" == "rpm" && "$(jq -r type <<<"$now_baseline" 2>/dev/null)" == "array" ]]; then
			now_ver="$(jq -r --arg p "$pkg" --arg a "$arch" --arg n "$to_nevra" 'def evr: (if .epoch==0 then "" else (.epoch|tostring)+":" end)+.version+"-"+.release; ([.[]|select(.nevra==$n and .arch==$a)]|if length==1 then (.[0]|evr) else "" end) as $exact | if $n != "" and $exact != "" then $exact else ([.[]|select(.name==$p and .arch==$a)]|if length==1 then (.[0]|evr) else "" end) end' <<<"$now_baseline" 2>/dev/null || true)"
		else
			now_ver="$(jq -r --arg p "$pkg" '.[$p] // ""' <<<"$now_baseline" 2>/dev/null || true)"
		fi
		ok=false
		if ! osf_valid_pkg "$pkg"; then
			why="malformed package name in the journal"
		elif [[ -n "$from" && -n "$now_ver" && "$now_ver" == "$from" ]]; then
			# Already at the version the run found (an earlier attempt restored it).
			ok=true; why="already at ${from}"
		elif [[ -n "$now_ver" && "$now_ver" != "$to" ]]; then
			# Moved by a later run: never downgraded or removed by this rollback.
			ok=true; why="left at ${now_ver}: changed after this run (it left ${to:-nothing}); not rolled back over a later update"
			warnings+=("${pkg_label}: ${why}")
		elif [[ -z "$from" && "$series_rollback" == true && "$apt_reverted" != true ]]; then
			# The pointer stays on the new series (its revert did not run): the new
			# series' packages are what this box can still resolve and patch, so a
			# package the run installed stays — removing it only strands the box.
			why="kept: the series pointer was not reverted"
		elif [[ -z "$from" ]] && rollback_replaces_unrestored "$pkg" "$settle_allowed" "${failed_restores[@]+"${failed_restores[@]}"}"; then
			# A new-series package that replaces (Obsoletes/Provides) a prior package
			# which could not be restored is NOT removed: removing it would leave
			# neither (updater#178 break-it round 3, measured on EL10).
			why="kept: it replaces ${ROLLBACK_REPLACED:-a prior package} that could not be restored"
		elif [[ -z "$from" && -z "$now_ver" ]]; then
			ok=true; why="already absent (${to_nevra:-the run-installed instance})"
		elif [[ -z "$from" ]]; then
			# from: null ⇒ the run INSTALLED it; undo by removing it.
			local remove_rc=0
			osf_remove_one "$pkg" "$to_nevra" || remove_rc=$?
			if (( remove_rc == 0 )); then
				ok=true; why="removed (the run installed it)"
			elif (( remove_rc == 3 )); then
				ok=true; why="kept: ${OSF_TRANSACTION_DETAIL}"
				warnings+=("${pkg_label}: ${why}")
			else
				why="removal failed${OSF_TRANSACTION_DETAIL:+: ${OSF_TRANSACTION_DETAIL}}"
			fi
		elif ! osf_valid_pkg_version "$from"; then
			why="malformed version in the journal"
		elif [[ "${OSF_DEB_GROUP_OK[$pkg]:-}" == true ]]; then
			ok=true; why="restored ${from} (in one transaction with the run's other downgrades)"
		elif [[ "$rpm_force_group" == true ]] || ! osf_version_available "$pkg" "$from"; then
			if [[ "$settle_allowed" == true ]] && osf_version_permitted "$pkg" "$from" "$arch"; then
				# SC-476: the byte-exact prior deb aged
				# out of the reverted suite. reprepro serves only a suite's CURRENT
				# versions, so requiring the exact prior would strand the box. Settle
				# to the reverted series' current (security-maintained) version — the
				# suite+pins were just reverted, so that IS what the repo now offers.
				# The exact-version miss is a WARNING, never a gate-blocking shortfall.
				local settle_ok=false settled="" group_key="" member member_key
				if [[ "$OS_FAMILY" == "rpm" ]]; then
					# installonly instances are intentionally singleton: source grouping a
					# historical kernel with its tools package would collapse the parallel
					# instance semantics schema v2 was introduced to preserve.
					group_key="$(rollback_settle_group_key "$entry")"
					if [[ -z "${settle_group_attempted[$group_key]+x}" ]]; then
						local -a settle_members=()
						local candidate centry carch cto cnevra cnow
						while IFS= read -r centry; do
							[[ -n "$centry" ]] || continue
							candidate="$(jq -r '.pkg' <<<"$centry")" carch="$(jq -r '.arch // empty' <<<"$centry")"
							cto="$(jq -r '.to // empty' <<<"$centry")" cnevra="$(jq -r '.to_instance.nevra // empty' <<<"$centry")"
							cnow="$(jq -r --arg p "$candidate" --arg a "$carch" --arg n "$cnevra" 'def evr: (if .epoch==0 then "" else (.epoch|tostring)+":" end)+.version+"-"+.release; ([.[]|select(.nevra==$n and .arch==$a)]|if length==1 then (.[0]|evr) else "" end) as $exact | if $n!="" and $exact!="" then $exact else ([.[]|select(.name==$p and .arch==$a)]|if length==1 then (.[0]|evr) else "" end) end' <<<"$now_baseline")"
							# Never group a member changed by a later run, and only group members
							# that actually require settle rather than an exact restore.
							[[ "$cnow" == "$cto" ]] || continue
							[[ -n "$carch" ]] || continue
							settle_members+=("${candidate}|${carch}")
						done < <(jq -c --arg s "$source_rpm" --arg p "$pkg" --arg k "$group_key" '
								.[] | select(.from != null)
								| select(if ($k|startswith("source:")) then ((.installonly//false)|not) and ((.from_instance.source_rpm//.to_instance.source_rpm//"")==$s) else .pkg==$p end)
								' <<<"$changed")
						settle_group_attempted["$group_key"]=true
						osf_settle_group "${settle_members[@]}" || true
						for member in "${settle_members[@]}"; do
							settle_member_ok["$member"]="${OSF_SETTLE_GROUP_OK[$member]:-false}"
							settle_member_version["$member"]="${OSF_SETTLE_GROUP_VERSION[$member]:-}"
						done
					fi
					member_key="${pkg}|${arch}"
					settle_ok="${settle_member_ok[$member_key]:-false}"
					settled="${settle_member_version[$member_key]:-}"
				elif osf_settle_one "$pkg"; then
					settle_ok=true; settled="${OSF_SETTLED_VERSION:-}"
				fi
				if [[ "$settle_ok" == true ]]; then
					ok=true
					settled="${settled:-the reverted series current version}"
					if [[ "$rpm_force_group" == true ]]; then
						why="settled source-RPM group coherently to the reverted series' current version ${settled}"
						warnings+=("${pkg_label}: source-RPM group settled coherently to the reverted series' current version ${settled}")
					else
						why="settled to the reverted series' current version ${settled} (exact ${from} aged out of the repo)"
						warnings+=("${pkg_label}: rolled to the reverted series' current version ${settled}; exact ${from} is no longer in the repo")
					fi
				else
					why="exact ${from} aged out and the reverted series' current version could not be installed${OSF_TRANSACTION_DETAIL:+: ${OSF_TRANSACTION_DETAIL}}"
				fi
			elif [[ "$series_rollback" == true && "$apt_reverted" == true ]]; then
				# The pointer is back but the restored leaf did not prove reachable
				# (rpm re-prime failed, already warned): nothing trustworthy to settle to.
				why="prior version not available and the reverted series' repo is unreachable"
			elif [[ "$series_rollback" == true ]]; then
				# A series rollback whose suite/pin revert did NOT run (step 1.5
				# failed, already warned). Settling now would resolve against the
				# STILL-NEW suite and leave the box on the new series, so there is
				# nothing to settle to — the exact prior is simply not available.
				why="prior version not available and the suite revert did not run"
			else
				# THE SC-078 CASE (routine run): the two-suite window was supposed to
				# keep the prior installable and did not. Name the package and reason.
				why="prior version not available in the repo"
			fi
		elif osf_downgrade_one "$pkg" "$from" "$arch"; then
			ok=true; why="restored ${from}"
		elif [[ "$settle_allowed" == true ]] && osf_settle_one "$pkg"; then
			# The index said the exact prior was there but the fetch failed (a leaf
			# pruned since the metadata was cached): same SC-476 settle as above.
			ok=true
			local settled_after="${OSF_SETTLED_VERSION:-the reverted series current version}"
			why="settled to the reverted series' current version ${settled_after} (the downgrade to ${from} failed)"
			warnings+=("${pkg_label}: rolled to the reverted series' current version ${settled_after}; the downgrade to ${from} failed")
		else
			why="the package manager refused the downgrade to ${from}${OSF_TRANSACTION_DETAIL:+: ${OSF_TRANSACTION_DETAIL}}"
		fi
		if [[ "$ok" != true && -n "$from" ]] && osf_valid_pkg "$pkg" && osf_valid_pkg_version "$from"; then
			failed_restores+=("${pkg}${arch:+|${arch}}"$'\t'"${from}")
		fi
		if [[ "$ok" == true ]]; then
			restored=$((restored + 1))
		elif [[ "$series_rollback" == true ]]; then
			# Cross-series: an apt package the rollback could not put back is the
			# least security-critical part of the recovery (§F1) — advisory, not a
			# reason to jam the whole rollback in maintenance. Recorded as a warning.
			engine_log "rollback: ${pkg} NOT restored to the reverted series — ${why} (warning, not gate-blocking)"
			warnings+=("${pkg_label}: ${why}")
		else
			engine_log "rollback: ${pkg} NOT restored — ${why}"
			shortfalls+=("${pkg_label}: ${why}")
		fi
		pkg_results+=("$(jq -nc --arg p "$pkg" --arg a "$arch" --arg w "$from" --argjson ok "$ok" --arg r "$why" \
			'{pkg: $p, arch: (if $a == "" then null else $a end), want: (if $w == "" then null else $w end), ok: $ok, reason: $r}')")
	done < <(jq -c '(map(select(.from != null)) + (map(select(.from == null)) | reverse)) | .[]' <<<"$changed")

	# Retry the restores that failed, now that the run's own installs are gone: a
	# new-series package that Obsoletes the prior name blocks reinstalling it until
	# it has been removed (break-it finding, updater#178). A retry that succeeds
	# replaces that package's earlier result, warning and shortfall.
	local fr ridentity rpkg rarch rlabel rfrom rwhy retry_source retry_key retry_member retry_identity retry_name retry_arch
	local -A retry_group_attempted=() retry_member_ok=() retry_member_version=()
	for fr in "${failed_restores[@]+"${failed_restores[@]}"}"; do
		ridentity="${fr%%$'\t'*}"; rpkg="${ridentity%%|*}"; rarch=""
		[[ "$ridentity" == *'|'* ]] && rarch="${ridentity#*|}"
		rlabel="${rpkg}${rarch:+.${rarch}}"
		rfrom="${fr#*$'\t'}" rwhy=""
		if osf_version_available "$rpkg" "$rfrom" && osf_downgrade_one "$rpkg" "$rfrom" "$rarch"; then
			rwhy="restored ${rfrom} (on retry, after the run's installs were removed)"
		elif [[ "$settle_allowed" == true && "$OS_FAMILY" == "rpm" ]]; then
			retry_source="$(jq -r --arg p "$rpkg" --arg a "$rarch" '[.[]|select(.pkg==$p and (.arch//"")==$a)][0] | select((.installonly//false)|not) | .from_instance.source_rpm // .to_instance.source_rpm // empty' <<<"$changed")"
			retry_key="${retry_source:+source:${retry_source}}"; retry_key="${retry_key:-instance:${ridentity}}"
			if [[ -z "${retry_group_attempted[$retry_key]+x}" ]]; then
				local -a retry_members=()
				for retry_member in "${failed_restores[@]+"${failed_restores[@]}"}"; do
					retry_identity="${retry_member%%$'\t'*}"; retry_name="${retry_identity%%|*}"; retry_arch=""
					[[ "$retry_identity" == *'|'* ]] && retry_arch="${retry_identity#*|}"
					if [[ -n "$retry_source" ]]; then
						[[ "$(jq -r --arg p "$retry_name" --arg a "$retry_arch" '[.[]|select(.pkg==$p and (.arch//"")==$a)][0] | select((.installonly//false)|not) | .from_instance.source_rpm // .to_instance.source_rpm // empty' <<<"$changed")" == "$retry_source" ]] || continue
					else
						[[ "$retry_identity" == "$ridentity" ]] || continue
					fi
					[[ -n "$retry_arch" ]] || continue
					retry_members+=("${retry_name}|${retry_arch}")
				done
				retry_group_attempted["$retry_key"]=true
				osf_settle_group "${retry_members[@]}" || true
				for retry_identity in "${retry_members[@]}"; do
					retry_member_ok["$retry_identity"]="${OSF_SETTLE_GROUP_OK[$retry_identity]:-false}"
					retry_member_version["$retry_identity"]="${OSF_SETTLE_GROUP_VERSION[$retry_identity]:-}"
				done
			fi
			if [[ "${retry_member_ok[$ridentity]:-false}" == true ]]; then
				rwhy="settled to the reverted series' current version ${retry_member_version[$ridentity]:-?} (on retry, after the run's installs were removed)"
			fi
		elif [[ "$settle_allowed" == true ]] && osf_settle_one "$rpkg"; then
			rwhy="settled to the reverted series' current version ${OSF_SETTLED_VERSION:-?} (on retry, after the run's installs were removed)"
		fi
		[[ -n "$rwhy" ]] || continue
		restored=$((restored + 1))
		local -a keep=()
		local x
		for x in "${warnings[@]+"${warnings[@]}"}"; do [[ "$x" == "${rlabel}: "* ]] || keep+=("$x"); done
		warnings=("${keep[@]+"${keep[@]}"}")
		keep=()
		for x in "${shortfalls[@]+"${shortfalls[@]}"}"; do [[ "$x" == "${rlabel}: "* ]] || keep+=("$x"); done
		shortfalls=("${keep[@]+"${keep[@]}"}")
		keep=()
		for x in "${pkg_results[@]+"${pkg_results[@]}"}"; do
			[[ "$(jq -r '.pkg' <<<"$x")" == "$rpkg" && "$(jq -r '.arch // empty' <<<"$x")" == "$rarch" ]] || keep+=("$x")
		done
		keep+=("$(jq -nc --arg p "$rpkg" --arg a "$rarch" --arg w "$rfrom" --arg r "$rwhy" '{pkg: $p, arch: (if $a == "" then null else $a end), want: $w, ok: true, reason: $r}')")
		pkg_results=("${keep[@]}")
		[[ "$rwhy" == settled* ]] && warnings+=("${rlabel}: ${rwhy}")
	done

	# Did step 2 move the shcpd package? Measured in the package database on both
	# sides, never inferred from the journal's intent: step 5 restarts on it.
	local shcpd_pkg_moved=false
	if jq -e 'any(.[]; .pkg == "shcpd")' <<<"$changed" >/dev/null 2>&1; then
		[[ "$(pkg_identity_in "$now_baseline" shcpd)" != "$(pkg_identity_in "$(baseline_json 2>/dev/null || printf '{}')" shcpd)" ]] \
			&& shcpd_pkg_moved=true
	fi

	# rpm: a package that was installed BEFORE the run and is absent now was lost
	# by the rollback itself (an Obsoletes swap, a removal cascade) — that is never
	# an advisory "could not reach the exact version", so it keeps the gate up on
	# either kind of rollback.
	local pkgs_lost=false
	if [[ "$OS_FAMILY" == "rpm" ]]; then
		local lpkg larch lspec
		while IFS=$'\t' read -r lpkg larch; do
			[[ -n "$lpkg" ]] && osf_valid_pkg "$lpkg" || continue
			lspec="${lpkg}${larch:+.${larch}}"
			if ! osf_pkg_installed "$lspec"; then
				pkgs_lost=true
				engine_log "rollback: ${lspec} was installed before the run and is not installed now"
				shortfalls+=("${lspec}: not installed after the rollback (it was installed before the run)")
			fi
		done < <(jq -r '.[] | select(.from != null) | [.pkg, (.arch // "")] | @tsv' <<<"$changed")
	fi

	# #321: nothing a rollback does may leave the box without an essential package
	# it had. This is the check that would have caught the 14 h outage: the panel
	# that would announce it is one of the things it finds missing, so it is said
	# here, at CRITICAL, and the gate stays up.
	for epkg in "${essential_before[@]+"${essential_before[@]}"}"; do
		osf_pkg_installed "$epkg" && continue
		pkgs_lost=true
		engine_log "CRITICAL: rollback: essential package ${epkg} is not installed after the rollback; reinstall it from the package manager"
		shortfalls+=("${epkg}: essential package not installed after the rollback")
	done

	# Package restoration is the final part of the OS-only proof. A failed
	# downgrade cannot authorize worker activation or gate removal.
	if [[ "$security_os_only" == true ]] && { (( restored != total )) || [[ "$pkgs_lost" == true ]]; }; then
		security_os_only=false
		security_scope_unproven=true
		shortfalls+=("security scope: OS-package rollback did not complete")
	fi

	# --- step 3: panel flip-back ---------------------------------------------------
	local panel_flipped_back=null flip_reason="" helpers_restored=null
	if [[ "$cleanup_transition_needed" == true || "$verify_transition_needed" == true ]]; then
		flip_reason="deferred until database restoration and queue reassignment complete"
	elif [[ "$verify_rollback_ready" != true ]]; then
		panel_flipped_back=false
		flip_reason="refused: verify worker compatibility transition did not converge"
		shortfalls+=("panel: ${flip_reason}")
	elif [[ "$cleanup_rollback_ready" != true ]]; then
		panel_flipped_back=false
		flip_reason="refused: upload cleanup compatibility transition did not converge"
		shortfalls+=("panel: ${flip_reason}")
	elif [[ "$panel_flip_happened" != true ]]; then
		flip_reason="the run never flipped — nothing to undo"
	elif [[ -z "$panel_previous_dir" ]]; then
		panel_flipped_back=false
		flip_reason="the run recorded no previous release dir — there is nothing to flip back to"
		shortfalls+=("panel: ${flip_reason}")
	elif [[ ! -d "$panel_previous_dir" ]]; then
		# Exactly the retention failure the release_prune ordering exists to
		# prevent. Report it as partial and name it.
		panel_flipped_back=false
		flip_reason="prior release dir ${panel_previous_dir} is gone"
		shortfalls+=("panel: ${flip_reason}")
	elif panel_flip "$panel_previous_dir" && panel_link_points_at "$panel_previous_dir"; then
		panel_flipped_back=true
		flip_reason="${PANEL_LINK} is back on ${panel_previous_dir}"
		# The flip is only half of what stage_panel did. It also installs the
		# release's OUT-OF-PROCESS helpers — shcp-backup-stream and
		# shcp-file-broker binaries into /usr/sbin plus their unit files — and
		# those do not travel with the symlink. Leaving the new release's
		# helpers installed over old panel code means a wire-protocol mismatch
		# the file manager and backup streaming break on, and NOTHING in the
		# health stage or shcp:update:health looks at helper binaries: the box
		# would report healthy with the maintenance gate dropped.
		if helpers_redeploy "$panel_previous_dir"; then
			helpers_restored=true
		else
			helpers_restored=false
			flip_reason="${flip_reason}, but the prior helper binaries could not be reinstalled"
			shortfalls+=("panel: the prior release's helper binaries could not be reinstalled")
		fi
		# Realign the verify worker to the reverted release. Best-effort, NOT a
		# shortfall: if the prior release predates the verify worker it has no unit
		# (nothing to do) and that release routes verify -> privileged anyway; if it
		# is a same-feature release the reinstall is idempotent. Either way the
		# reverted panel has a working verify path without this, so a failure here
		# must not deepen a rollback that is already the recovery path.
		verify_worker_redeploy "$panel_previous_dir" \
			|| engine_log "rollback: verify worker realign reported a failure (not fatal)"
		if ! license_recover_rollback_if_recorded; then
			helpers_restored=false
			shortfalls+=("license recovery: prior unit bytes/state could not be restored")
		fi
		if ! managed_workers_install "$panel_previous_dir"; then
			helpers_restored=false
			shortfalls+=("managed workers: target inventory could not be restored or exact removal was refused")
		fi
		if [[ "$rollback_target_has_cleanup" == true ]] \
			&& ! upload_cleanup_install_for_release "$panel_previous_dir"; then
			helpers_restored=false
			flip_reason="${flip_reason}, but the target cleanup unit could not be restored"
			shortfalls+=("upload cleanup: target managed unit could not be restored")
		fi
	else
		panel_flipped_back=false
		flip_reason="the flip back to ${panel_previous_dir} failed"
		shortfalls+=("panel: ${flip_reason}")
	fi

	# --- step 4: DB restore, only if the migration ran -----------------------------
	local db_restored=null db_reason="" db_restore_json=null
	local snapshot_at=null loss_window=null
	if [[ -n "$snapshot" && -f "$snapshot" ]]; then
		# §4.5 step 4: the journal records the data-loss window. A restore
		# discards everything the panel committed after the snapshot was taken,
		# and the operator is entitled to be told how wide that is rather than
		# having to infer it from timestamps.
		local snap_epoch now_epoch
		snap_epoch="$(date -u -r "$snapshot" +%s 2>/dev/null || true)"
		if [[ "$snap_epoch" =~ ^[0-9]+$ ]]; then
			snapshot_at="\"$(date -u -d "@${snap_epoch}" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
				|| printf '%s' "$snap_epoch")\""
			now_epoch="$(date -u +%s)"
			loss_window=$(( now_epoch - snap_epoch ))
			(( loss_window < 0 )) && loss_window=0
		fi
	fi
	if [[ "$db_migration_ran" != true ]]; then
		db_reason="no schema change to undo"
	elif [[ "$panel_superseded" == true ]]; then
		# REFUSED, and the refusal is the safe answer: the code being served is
		# not the code this snapshot's schema belongs to.
		db_restored=false
		db_reason="refused: ${PANEL_LINK} now resolves to $(panel_link_target), which is neither this run's release nor the one it replaced — a later run superseded this one, so restoring this snapshot would discard every write since and leave newer code on an older schema"
		shortfalls+=("database: ${db_reason}")
	elif [[ "$db_restored_before" == true ]]; then
		db_restored=true
		db_reason="already restored and verified by an earlier rollback attempt of this run — not restored again (that would discard every write since)"
	elif [[ -z "$snapshot" || ! -f "$snapshot" ]]; then
		db_restored=false
		db_reason="the schema was migrated and there is no snapshot to restore${snapshot:+ (${snapshot} is gone)}"
		shortfalls+=("database: ${db_reason}")
	else
		db_restore_from_snapshot "$snapshot" "$SHCP_UPDATE_DB" || true
		db_restore_json="$DB_RESTORE_RESULT"
		# `.ok` is the ONLY field a caller may branch on: `.restored` is true even
		# for a blend of the snapshot and post-snapshot state.
		if [[ "$(jq -r '.ok // false' <<<"$db_restore_json")" == "true" ]]; then
			if rollback_db_restore_proof_write "$run_id" "$snapshot" "$db_restore_json"; then
				db_restored=true
				db_reason="restored, verified, and durably journaled against the snapshot's logical fingerprint"
				if [[ "$loss_window" != null ]]; then
					db_reason="${db_reason}; every panel write of the last ${loss_window}s was discarded with it"
				fi
			else
				db_restored=false
				db_reason="the restored database verified, but its one-time recovery proof was not durable; all database writers remain stopped"
				shortfalls+=("database: ${db_reason}")
			fi
		else
			db_restored=false
			db_reason="$(jq -r '.refused // "the restore could not be verified as the snapshot"' \
				<<<"$db_restore_json")"
			shortfalls+=("database: ${db_reason}")
		fi
	fi

	# Verify-to-privileged rollback is authoritative only after snapshot restore.
	# Re-prove every Messenger consumer inactive, transactionally move only the
	# queue_name column, and remove only the exact journal-owned unit.
	if [[ "$verify_transition_needed" == true ]]; then
		if [[ "$db_restored" == false ]]; then
			verify_rollback_ready=false
			shortfalls+=("verify worker: database restore did not converge; queue reassignment not attempted")
		elif ! verify_worker_checkpoint post-db-restore; then
			verify_rollback_ready=false
			shortfalls+=("verify worker: interrupted after database restore")
		elif ! panel_workers_stop; then
			verify_rollback_ready=false
			shortfalls+=("verify worker: consumers could not be re-proven inactive after database restore")
		elif ! verify_worker_reassign_pending; then
			verify_rollback_ready=false
			shortfalls+=("verify worker: pending rows could not be transactionally reassigned to privileged")
		elif ! verify_worker_checkpoint queue-proof; then
			verify_rollback_ready=false
			shortfalls+=("verify worker: interrupted after queue proof")
		elif ! verify_worker_remove_managed "$run_id"; then
			verify_rollback_ready=false
			shortfalls+=("verify worker: exact journaled unit ownership/removal could not be proven")
		elif ! verify_worker_checkpoint unit-removed; then
			verify_rollback_ready=false
			shortfalls+=("verify worker: interrupted after managed unit removal")
		fi
	fi

	# Cleanup-to-legacy rollback is ordered after the snapshot restore. Restoring
	# SQLite can put pre-rollback cleanup rows back; only this post-restore pass is
	# authoritative. Old code is not exposed until the count proof and owned-unit
	# removal have both converged.
	if [[ "$cleanup_transition_needed" == true ]]; then
		if [[ "$db_restored" == false ]]; then
			cleanup_rollback_ready=false
			shortfalls+=("upload cleanup: database restore did not converge; queue reassignment not attempted")
		elif ! panel_workers_stop; then
			cleanup_rollback_ready=false
			shortfalls+=("upload cleanup: consumers could not be re-proven inactive after database restore")
		elif ! upload_cleanup_reassign_pending; then
			cleanup_rollback_ready=false
			shortfalls+=("upload cleanup: pending rows could not be transactionally reassigned to backup after database restore")
		elif ! upload_cleanup_remove_managed "$run_id"; then
			cleanup_rollback_ready=false
			shortfalls+=("upload cleanup: exact journaled unit ownership/removal could not be proven")
		elif [[ "$verify_rollback_ready" != true ]]; then
			cleanup_rollback_ready=false
			shortfalls+=("panel: deferred flip refused because verify transition did not converge")
		elif [[ -z "$panel_previous_dir" || ! -d "$panel_previous_dir" ]]; then
			cleanup_rollback_ready=false
			shortfalls+=("panel: cleanup-compatible rollback target is unavailable")
		elif panel_flip "$panel_previous_dir" && panel_link_points_at "$panel_previous_dir"; then
			panel_flipped_back=true
			flip_reason="${PANEL_LINK} is back on ${panel_previous_dir} after post-restore queue reassignment"
			helpers_redeploy "$panel_previous_dir" || { helpers_restored=false; shortfalls+=("panel: prior helpers could not be reinstalled"); }
			verify_worker_redeploy "$panel_previous_dir" || true
			license_recover_rollback_if_recorded || { helpers_restored=false; shortfalls+=("license recovery: prior unit bytes/state could not be restored"); }
			managed_workers_install "$panel_previous_dir" || { helpers_restored=false; shortfalls+=("managed workers: target inventory could not be restored or exact removal was refused"); }
		else
			cleanup_rollback_ready=false
			panel_flipped_back=false
			shortfalls+=("panel: deferred flip to legacy target failed")
		fi
	fi
	if [[ "$verify_transition_needed" == true && "$cleanup_transition_needed" != true ]]; then
		if [[ "$verify_rollback_ready" != true ]]; then
			panel_flipped_back=false
			shortfalls+=("panel: deferred flip refused because verify transition did not converge")
		elif [[ -z "$panel_previous_dir" || ! -d "$panel_previous_dir" ]]; then
			verify_rollback_ready=false
			panel_flipped_back=false
			shortfalls+=("panel: verify-compatible rollback target is unavailable")
		elif panel_flip "$panel_previous_dir" && panel_link_points_at "$panel_previous_dir"; then
			panel_flipped_back=true
			flip_reason="${PANEL_LINK} is back on ${panel_previous_dir} after post-restore verify queue reassignment"
			helpers_redeploy "$panel_previous_dir" || { helpers_restored=false; shortfalls+=("panel: prior helpers could not be reinstalled"); }
			license_recover_rollback_if_recorded || { helpers_restored=false; shortfalls+=("license recovery: prior unit bytes/state could not be restored"); }
			managed_workers_install "$panel_previous_dir" || { helpers_restored=false; shortfalls+=("managed workers: target inventory could not be restored or exact removal was refused"); }
			if ! verify_worker_checkpoint target-flipped; then
				verify_rollback_ready=false
				shortfalls+=("verify worker: interrupted after target flip")
			fi
		else
			verify_rollback_ready=false
			panel_flipped_back=false
			shortfalls+=("panel: deferred flip to privileged-routing target failed")
		fi
	fi
	# Prove the canonical shared table against the now-active target before any
	# consumer can restart. A legacy target additionally requires zero verify rows.
	if [[ "$verify_contract_relevant" == true && "$panel_flipped_back" == true ]] \
			&& ! verify_worker_target_release_db_ok "$panel_previous_dir" "$verify_target_state" "$run_id"; then
		verify_rollback_ready=false
		shortfalls+=("verify worker: target release schema/shared-table verification failed after panel flip${VERIFY_WORKER_TARGET_DB_REASON:+: ${VERIFY_WORKER_TARGET_DB_REASON}}")
	elif [[ "$verify_contract_relevant" == true && "$panel_flipped_back" == true ]] \
			&& ! verify_worker_checkpoint target-schema-shared-proved; then
		verify_rollback_ready=false
		shortfalls+=("verify worker: interrupted after target release schema/shared-table proof")
	fi

	# SC-710 (#198): step 1.7 un-gated the pools for a flip that then did not happen
	# (refused, failed, or deferred and not completed). The gated source release
	# still serves, now with every pool un-gated: the cross-pool reach SC-710 closes
	# is open on a release meant to close it. Nothing here can safely re-gate (the
	# DB may already be restored under it), so it is a shortfall that survives a
	# retry until the pools are re-gated or the flip completes.
	if { [[ "$pool_gate_ungated" == "true" ]] || [[ "$prior_pool_gate_regate" == true ]]; } \
			&& ! { [[ -n "$panel_previous_dir" ]] && panel_link_points_at "$panel_previous_dir"; }; then
		pool_gate_regate_required=true
		engine_log "rollback: pools were un-gated but ${PANEL_LINK} did not flip back — the gated release serves with SC-710 off; run shcp:php:republish-pools once the panel is sound"
		shortfalls+=("pool gate: pools are un-gated but the panel did not flip back; run shcp:php:republish-pools to re-gate them")
	fi

	# --- step 5: restart, then health ----------------------------------------------
	# Starting the root worker against a schema that is half-migrated and could
	# not be restored is worse than a stopped queue: a stopped queue is visible
	# and recoverable, handlers executing against an unknown schema are not.
	# true / false / null, and the three are different facts — the same
	# convention panel_flipped_back and db_restored use. `false` is "a unit was
	# asked to start and would not"; `null` is "this step deliberately did not
	# apply", which is NOT a shortfall of its own and must not be counted as one
	# (the upstream failure that caused it already is). Collapsing the two made
	# the outcome predicate's db and worker terms logically equivalent, so each
	# could be deleted with the suite green because the other masked it.
	local workers_started=null workers_reason=""
	if [[ "$security_scope_unproven" == true || "$db_restored" == "false" || "$verify_rollback_ready" != true || "$cleanup_rollback_ready" != true ]]; then
		if [[ "$security_scope_unproven" == true ]]; then
			workers_reason="left stopped deliberately: OS-package-only recovery is unproven"
		else
			workers_reason="left stopped deliberately: the database state is unknown"
		fi
		engine_log "rollback: workers LEFT STOPPED — ${workers_reason#left stopped deliberately: }"
		journal_update "$run_id" '.restart_required = ((.restart_required // []) + $s | unique)' \
			--argjson s '["shcp-worker", "shcp-backup-worker", "shcp-upload-cleanup", "shcp-verify-worker", "shcp-webhook-worker", "shcp-mailhealth-worker", "shcp-transfer-worker", "shcp-audit-forward-worker", "shcp-dbio-worker", "shcp-privileged-slow-worker", "shcp-cron-worker@*.service"]'
	elif [[ "$pool_gate_ungated" == "false" ]]; then
		# SC-710 (#198): maintenance blocks HTTP only. A queued UpdateWebHost /
		# MigrateUserPhpVersion message drained by the OLD code re-renders a vhost that
		# presents no (or another) token over a pool that is still gated — that site
		# 403s. Stopped until the pools are un-gated.
		engine_log "rollback: workers LEFT STOPPED — tenant pools may still be gated (SC-710)"
		workers_reason="left stopped deliberately: tenant pools may still be gated (SC-710), and a queued vhost render would 403 its site"
		journal_update "$run_id" '.restart_required = ((.restart_required // []) + $s | unique)' \
			--argjson s '["shcp-worker", "shcp-backup-worker", "shcp-upload-cleanup", "shcp-verify-worker", "shcp-webhook-worker", "shcp-mailhealth-worker", "shcp-transfer-worker", "shcp-audit-forward-worker", "shcp-dbio-worker", "shcp-privileged-slow-worker", "shcp-cron-worker@*.service"]'
	elif ! verify_worker_checkpoint before-worker-restart; then
		workers_started=false
		workers_reason="worker restoration interrupted at the verified restart boundary"
		shortfalls+=("workers: restoration interrupted at the verified restart boundary")
	elif panel_workers_start "$run_id" "$security_os_only"; then
		workers_started=true
		workers_reason="the target worker set and cleanup journaled state were restored"
	else
		# MEASURED, not asserted. A rollback that leaves the root Messenger
		# worker dead has not put the box back, however clean the packages and
		# the symlink look — the panel's privileged queue is where every
		# system-modifying operation runs.
		workers_started=false
		workers_reason="${PANEL_WORKERS_FAILED[*]} would not start"
		engine_log "rollback: workers that would NOT start: ${PANEL_WORKERS_FAILED[*]}"
		shortfalls+=("workers: ${PANEL_WORKERS_FAILED[*]} would not start")
		journal_update "$run_id" '.restart_required = ((.restart_required // []) + $s | unique)' \
			--argjson s "$(printf '%s\n' "${PANEL_WORKERS_FAILED[@]}" \
				| jq -R -s -c 'split("\n") | map(select(length > 0))')"
	fi

	local shcpd_restarted=null shcpd_needed=false
	# New code → old code demands a restart; and db_restore_from_snapshot reports
	# when it stopped shcpd for the swap and could not bring it back.
	[[ "$panel_flipped_back" == "true" ]] && shcpd_needed=true
	if [[ "$(jq -r '.shcpd_restart_required // false' <<<"$db_restore_json")" == "true" ]]; then
		shcpd_needed=true
	fi
	# A package move swapped the binary under the daemon, and an older shcpd
	# package's scripts neither keep it running nor restart it (shcp-build#568):
	# restart a shcpd that was running before the update (the preflight baseline).
	if [[ "$shcpd_pkg_moved" == true && "$(jq -r '(.stages[]? | select(.stage == "preflight") | .result.baseline.units.shcpd) // empty' "$jf" 2>/dev/null || true)" == active ]]; then
		shcpd_needed=true
	fi
	if [[ "$shcpd_needed" == true && "$db_restored" != false ]]; then
		if unit_ctl_bounded restart shcpd.service; then
			shcpd_restarted=true
		else
			shcpd_restarted=false
			engine_log "rollback: shcpd FAILED to restart — the panel is offline, start it by hand"
			shortfalls+=("shcpd: failed to restart")
			journal_update "$run_id" '.restart_required = ((.restart_required // []) + ["shcpd"] | unique)'
		fi
	elif [[ "$shcpd_needed" == true ]]; then
		shcpd_restarted=false
		engine_log "rollback: shcpd LEFT STOPPED — the database restore or durability proof failed"
		journal_update "$run_id" '.restart_required = ((.restart_required // []) + ["shcpd"] | unique)'
	fi

	# The SC-236 outbound policy socket is the OTHER by-path DB opener that
	# db_restore_from_snapshot stopped for the swap. Bring it back on the SAME gate as
	# shcpd, and for the same reason it is resumed HERE and not inside db_restore: a
	# policy check opens the DB and can replay a stale WAL, so it is a writer and must
	# not start until the destructive restore is durably proven. db_restored==false (an
	# unverified restore or a non-durable proof) leaves it stopped -- authenticated
	# submission fails closed (451), never DUNNO.
	if [[ "$(jq -r '.policy_socket_restart_required // false' <<<"$db_restore_json")" == "true" ]]; then
		if [[ "$db_restored" != false ]]; then
			if systemctl restart "$OUTBOUND_POLICY_SOCKET_UNIT" >/dev/null 2>&1; then
				engine_log "rollback: outbound policy socket restarted"
			else
				engine_log "rollback: outbound policy socket FAILED to restart — authenticated submission fails closed (451) until it is started by hand (SC-236)"
				shortfalls+=("outbound policy socket: failed to restart")
				journal_update "$run_id" '.restart_required = ((.restart_required // []) + [$u] | unique)' --arg u "$OUTBOUND_POLICY_SOCKET_UNIT"
			fi
		else
			engine_log "rollback: outbound policy socket LEFT STOPPED — the database restore or durability proof failed (fail closed, SC-236)"
			journal_update "$run_id" '.restart_required = ((.restart_required // []) + [$u] | unique)' --arg u "$OUTBOUND_POLICY_SOCKET_UNIT"
		fi
	fi

	# §4.2-6 safety net on the recovery path (updater#161, SC-476). The downgrades
	# above re-ran each daemon's prerm/postinst, so a daemon whose deb prerm stops
	# the unit is now DOWN again — the exact gap that sent the forward run
	# unhealthy. Bring every daemon that was active at the run baseline and is
	# stopped now back up BEFORE the health re-measurement, or the rollback
	# measures its own unstarted daemons as unhealthy and ends rolled_back_partial
	# with the maintenance gate stuck. A start that fails is recorded in
	# restart_required and left for the health check to surface (it will, via
	# unit-states) — never silently swallowed.
	restore_baseline_managed_daemons "$jf"
	if [[ ${#MANAGED_DAEMONS_FAILED[@]} -gt 0 ]]; then
		engine_log "rollback: managed daemons that would NOT start: ${MANAGED_DAEMONS_FAILED[*]}"
		journal_update "$run_id" '.restart_required = ((.restart_required // []) + $s | unique)' \
			--argjson s "$(printf '%s\n' "${MANAGED_DAEMONS_FAILED[@]}" \
				| jq -R -s -c 'split("\n") | map(select(length > 0))')"
	fi

	# Reusing the stage body is deliberate: the rollback's health claim must be
	# measured exactly the way the run's was, or the two are not comparable.
	# What it must NOT do is overwrite the run's own verdict — `.health` is what
	# the panel importer stores as healthReport and what §4.5 step 6 attaches to
	# the CRITICAL notification, i.e. the evidence of WHY this rollback happened.
	# Redirect the re-measurement to `.rollback_health` for the duration.
	local saved_stage_result="$STAGE_RESULT" saved_health_key="$HEALTH_JOURNAL_KEY"
	local saved_health_status="$HEALTH_STATUS" saved_health_regression="$HEALTH_REGRESSION"
	HEALTH_JOURNAL_KEY="rollback_health"
	stage_health || true
	HEALTH_JOURNAL_KEY="$saved_health_key"
	local health_after="$HEALTH_STATUS" health_report="$STAGE_RESULT"
	STAGE_RESULT="$saved_stage_result"
	# HEALTH_STATUS is the runner's auto-rollback trigger and its terminal-status
	# input. Leaving the post-rollback reading in it would let a rollback that
	# happened to come back healthy re-declare the RUN healthy.
	# The rollback's own claim is "the box is back where it was", so it is judged
	# like a run: a check still failing only because it was ALREADY failing at the
	# run's baseline (regression:false, shcp-updater#181) does not make the
	# rollback partial. Before #181 such checks were recorded as skips and this
	# was implicitly true; making them failures must not strand an already-broken
	# box in maintenance after a rollback that did everything it could.
	local health_after_regression="$HEALTH_REGRESSION"
	HEALTH_STATUS="$saved_health_status"
	HEALTH_REGRESSION="$saved_health_regression"
	local health_after_ok=false
	[[ "$health_after" == "healthy" ]] && health_after_ok=true
	[[ "$health_after" == "unhealthy" && "$health_after_regression" != true ]] && health_after_ok=true
	if [[ "$health_after_ok" != true ]]; then
		shortfalls+=("health: still ${health_after} after the rollback")
	fi

	# --- step 6: outcome, journal, flag, notify -------------------------------------
	# health_after is inside the predicate on purpose: `rolled_back` is a claim to
	# the operator that the box is back where it was, and if health still says no
	# that claim is false. The cost is accepted — a box that was already unhealthy
	# for an unrelated reason reports partial after a mechanically perfect
	# rollback, which is conservative in the safe direction and names the exact
	# check that said no.
	local outcome="rolled_back"
	# The package term is a gate-blocker for a ROUTINE rollback only. For a
	# cross-series rollback the apt half is the least security-critical part of the
	# recovery (§F1): an exact prior deb that aged out of the reverted suite is a
	# WARNING, and jamming the whole rollback in maintenance over it is precisely
	# the failure SC-476 exists to prevent. The panel/
	# helpers/DB/workers/shcpd/health terms below stay hard for BOTH kinds.
	if [[ "$series_rollback" != true ]] && (( restored != total )); then
		outcome="rolled_back_partial"
	fi
	[[ "$pkgs_lost" == true ]] && outcome="rolled_back_partial"
	[[ "$panel_flipped_back" == "false" ]] && outcome="rolled_back_partial"
	# The flip is only half the panel: the prior release's out-of-process helper
	# binaries have to go back with it, or the box runs the new release's
	# file-broker/backup-stream against old panel code and no health check looks.
	[[ "$helpers_restored" == "false" ]] && outcome="rolled_back_partial"
	[[ "$db_restored" == "false" ]] && outcome="rolled_back_partial"
	[[ "$workers_started" == "false" ]] && outcome="rolled_back_partial"
	[[ "$shcpd_restarted" == "false" ]] && outcome="rolled_back_partial"
	# A pool left gated under a panel whose vhosts do not present its token 403s the
	# next site that panel re-renders (SC-710, shcp-updater#198).
	[[ "$pool_gate_ungated" == "false" ]] && outcome="rolled_back_partial"
	[[ "$pool_gate_regate_required" == true ]] && outcome="rolled_back_partial"
	[[ "$health_after_ok" == true ]] || outcome="rolled_back_partial"
	# Contradictory security journals cannot use the OS-only recovery promise.
	[[ "$security_scope_unproven" == true ]] && outcome="rolled_back_partial"
	# The package-manager POINTER is not the apt half. An exact prior aging out
	# still leaves the box reading the reverted series; a pointer that stayed on the
	# new series (its revert failed, or rpm could not prove the pre-upgrade leaf)
	# leaves every SHCP package patching from the series the panel just left, and
	# nothing later moves it back: `rolled_back` retires the run from
	# rollback_available, so the retry that would re-revert once the leaf is back
	# was never offered (shcp-updater#179). Partial keeps the run recoverable and
	# says why; the shortfall names the pointer.
	#
	# The pointer is NOT a gate term, though (SC-476: the gate stays up only when the
	# panel or the database did not come back). The likeliest cause is a repo outage,
	# and holding every later run — the security timer included — behind a 503 gate
	# over it strands a box whose panel and data are fine. So: partial, but the gate
	# follows the hard terms above, all of which are decided by now.
	local outcome_gating="$outcome"
	if [[ "$series_rollback" == true && "$apt_reverted" != true ]]; then
		outcome="rolled_back_partial"
		shortfalls+=("series: the package-manager series pointer was not reverted — SHCP packages still resolve from the upgraded series; retry once the prior series repo is reachable: shcp-update rollback ${run_id}")
	fi

	local maint_cleared=false
	if [[ "$outcome_gating" == "rolled_back" && "$maint_foreign" != true ]]; then
		maintenance_clear_if_mine "$run_id"
		[[ -f "$MAINT_FLAG" ]] || maint_cleared=true
	elif [[ "$outcome_gating" != "rolled_back" ]]; then
		engine_log "rollback INCOMPLETE — the maintenance gate stays up (SC-318 fail-closed)."
		engine_log "  retry with: shcp-update rollback ${run_id}"
		engine_log "  once the panel is known good, clear it with: shcp-update maintenance --clear"
	fi

	ROLLBACK_RESULT="$(printf '%s\n' "${pkg_results[@]+"${pkg_results[@]}"}" \
		| jq -s -c \
			--arg outcome "$outcome" --arg reason "$reason" --arg trigger "$trigger" \
			--argjson restored "$restored" --argjson total "$total" \
			--argjson flipped "$panel_flipped_back" --arg prev "$panel_previous_dir" \
			--arg flip_reason "$flip_reason" --argjson helpers "$helpers_restored" \
			--argjson dbr "$db_restored" --arg db_reason "$db_reason" \
			--argjson snap_at "$snapshot_at" --argjson loss "$loss_window" \
			--argjson superseded "$panel_superseded" \
			--argjson db_restore "$db_restore_json" --argjson db_stage "$db_stage_result" \
			--argjson workers "$workers_started" --arg workers_reason "$workers_reason" \
			--argjson shcpd "$shcpd_restarted" \
			--arg health "$health_after" --argjson health_report "$health_report" \
			--argjson maint_cleared "$maint_cleared" \
			--argjson series "$series_rollback" \
			--argjson apt_reverted "$apt_reverted" --argjson apt_revert "$apt_revert_json" \
			--argjson pg_ungated "$pool_gate_ungated" --arg pg_source "$pool_gate_source" \
			--arg pg_target "$pool_gate_target" --arg pg_detail "$pool_gate_detail" \
			--argjson pg_regate "$pool_gate_regate_required" --argjson pg_retry "$pool_gate_retry" \
			--argjson warnings "$(printf '%s\n' "${warnings[@]+"${warnings[@]}"}" \
				| jq -Rsc 'split("\n") | map(select(length > 0))')" \
			'{outcome: $outcome, reason: $reason, trigger: $trigger,
			  packages_restored: $restored, packages_total: $total, packages: .,
			  panel_flipped_back: $flipped,
			  previous_release_dir: (if $prev == "" then null else $prev end),
			  panel_reason: $flip_reason, helpers_restored: $helpers,
			  db_restored: $dbr, db_reason: $db_reason,
			  db_snapshot_taken_at: $snap_at, db_data_loss_window_seconds: $loss,
			  panel_superseded: $superseded,
			  db_restore: $db_restore, db_stage_result: $db_stage,
			  workers_started: $workers, workers_reason: $workers_reason,
			  shcpd_restarted: $shcpd,
			  health_after: $health, health_after_report: $health_report,
			  maintenance_cleared: $maint_cleared,
			  series_rollback: $series, apt_suite_reverted: $apt_reverted,
			  apt_revert: $apt_revert, series_apt_warnings: $warnings,
			  pool_gate: {ungated: $pg_ungated,
			    source: (if $pg_source == "" then null else $pg_source end),
			    target: (if $pg_target == "" then null else $pg_target end),
			    detail: (if $pg_detail == "" then null else $pg_detail end),
			    regate_required: $pg_regate, retry: $pg_retry}}')"

	local err="$reason"
	if [[ ${#shortfalls[@]} -gt 0 ]]; then
		local joined
		joined="$(printf '%s; ' "${shortfalls[@]}")"
		err="${reason}; rollback incomplete: ${joined%; }"
	fi

	# ONE rename closes the record and writes the terminal status. Two (stage_done,
	# then finish) left a kill window that stranded the run `running` under a
	# completed rollback record — see journal_rollback_finish.
	journal_rollback_finish "$run_id" "$ROLLBACK_RESULT" "$outcome" "$err"
	ROLLBACK_OUTCOME="$outcome"
	engine_log "rollback of ${run_id}: ${outcome}"

	# Arm the installer converge for the release the box is now running -- the
	# release rolled back TO on a completed rollback; on a partial where the flip
	# itself failed, panel_current_version still names what is actually running, so
	# converge reconciles to reality rather than to a release the box is not on. A
	# rollback removes or replaces installer-managed artifacts nothing else
	# reconciles (shcp-installer#1114: a worker unit a release delivered is gone
	# after the rollback, its transport then consumed by nobody until the next
	# successful update -- the SC-408 availability class). BOTH outcomes arm,
	# rolled_back and rolled_back_partial, since partial is the state most likely
	# to carry that drift; the failed outcomes returned before any mutation, so
	# they never reach here and correctly do not arm. Bookkeeping ONLY: the
	# converge is applied by shcp-update-converge.timer on its next tick, NEVER
	# inline here (SC-432), so arming adds no automated change to the incident
	# window. With no readable panel version there is no target, so skip rather
	# than write an empty marker.
	local converge_to
	converge_to="$(panel_current_version)"
	if [[ -n "$converge_to" ]]; then
		converge_mark_pending "$converge_to" "$run_id" \
			|| engine_log "converge: could not mark ${CONVERGE_PENDING} after rollback; run 'shcp-update converge-installer --apply' by hand"
	else
		engine_log "converge: panel version unreadable after rollback; not arming a converge (run 'shcp-update converge-installer --apply' once the panel is known good)"
	fi

	# §4.5 step 6: notify CRITICAL regardless of outcome. The engine has no
	# notifier; the panel does, and both rolled_back and rolled_back_partial are
	# UpdateRunStatus::isBad(), which is what makes announce() fire CRITICAL.
	panel_import_run "$run_id"
	return 0
}

# auto_rollback <run_id> <reason> — §4.2-8. Fires AT MOST ONCE per run
# (SC-377). Three guards, checked in this order:
#   1. the JOURNAL latch (a completed rollback record) — the authoritative one,
#      because it is the only guard that survives a kill -9, a systemd restart
#      or a separately-invoked manual verb;
#   2. the process-local latch, which stops both entry points firing in one
#      process (stage_db fails, the rollback converges, a later stage fails too);
#   3. `resume` cannot reach the trigger at all — it only picks up status
#      `running`, and BOTH callers write a terminal status BEFORE calling here:
#      the [R] path writes `failed`, the health path writes a provisional
#      `unhealthy` (run_stages). Until 1.0 the health path wrote nothing, so a
#      kill mid-rollback left `running` and the next resume re-entered the
#      finalize iteration and re-ran the whole rollback — repo revert, index
#      refresh, snapshot restore — over a box in an unknown state.
# It NEVER retries itself: a second automatic attempt over a box in an unknown
# state is how a rollback loop starts, and a rollback loop over a database swap
# destroys data with the operator watching a progress bar.
auto_rollback() {
	local run_id="$1" reason="$2"
	local prior
	prior="$(rollback_last_record "$run_id")"
	if [[ -n "$prior" ]]; then
		AUTO_ROLLBACK_FIRED=1
		ROLLBACK_OUTCOME="$(jq -r '.result.outcome // ""' <<<"$prior")"
		engine_log "auto-rollback refused: ${run_id} already carries a completed rollback record (${ROLLBACK_OUTCOME})"
		# A journal an older engine left `running` under that record (the
		# two-rename window) gets the status the record already decided.
		journal_reassert_rollback_terminal "$run_id"
		return 0
	fi
	if [[ $AUTO_ROLLBACK_FIRED -eq 1 ]]; then
		engine_log "auto-rollback already fired for ${run_id} — not re-entering (${reason})"
		return 0
	fi
	if ! rollback_has_work "$run_id"; then
		# Nothing was applied, so there is nothing to undo. Saying so is what makes
		# the `unhealthy` terminal status reachable instead of dressing a no-op up
		# as a rollback.
		AUTO_ROLLBACK_FIRED=1
		ROLLBACK_OUTCOME=""
		engine_log "auto-rollback: ${run_id} applied nothing — nothing to roll back (${reason})"
		return 0
	fi
	AUTO_ROLLBACK_FIRED=1
	if ! rollback_run "$run_id" "auto: ${reason}" auto; then
		# Journal the refusal as a rollback attempt, exactly as cmd_rollback does,
		# so the stages array always carries the rollback's result and why (#321).
		local refusal
		ROLLBACK_OUTCOME="failed"
		refusal="$(jq -r '[.shortfalls[]? | tostring] | join("; ")' <<<"$ROLLBACK_RESULT" 2>/dev/null || true)"
		[[ -n "$refusal" ]] || refusal="rollback_run refused without a recorded reason"
		ROLLBACK_RESULT="$(jq -c --arg reason "auto: ${reason}" \
			'. + {outcome: "failed", reason: $reason, trigger: "auto"}' <<<"$ROLLBACK_RESULT" 2>/dev/null \
			|| jq -nc --arg reason "auto: ${reason}" '{outcome: "failed", reason: $reason, trigger: "auto"}')"
		rollback_stage_start "$run_id"
		journal_rollback_finish "$run_id" "$ROLLBACK_RESULT" "failed" \
			"${reason}; the rollback itself failed: ${refusal}"
		engine_log "auto-rollback FAILED for ${run_id}: ${refusal}"
	fi
	return 0
}

# --- stage runner ---------------------------------------------------------------
# run_stages <run_id> — executes from the first stage without stage_done.
# Empty work set after preflight ⇒ jump straight to finalize (noop run).
# recover_run_request_state <run_id> — restore the request globals a RESUMED
# process never received. This function IS SC-420: recover all four or die.
#
# `resume` reaches run_stages having set none of them: it only has a run id.
# That was not cosmetic. With REQ_SCOPE empty, apt_target_pkgs falls through its
# `security` test to osf_upgradable, so a resumed scope=security run quietly
# upgraded EVERY upgradable package — the scope the operator chose (and, for the
# daily security timer, the one thing AD-6 promises stays narrow) silently
# widened because a run got interrupted. Same shape for the reinstall mode: a
# resumed repair would stop re-extracting and degrade to "nothing newer,
# nothing to do", reporting success.
#
# journal_init recorded both at run start, so read them back rather than letting
# an unset variable pick a default. Only ever FILLS empties — a live process
# that already has them keeps what it was invoked with.
recover_run_request_state() {
	local run_id="$1"
	local jf; jf="$(journal_path "$run_id")"

	if [[ -z "$REQ_SCOPE" ]]; then
		REQ_SCOPE="$(jq -r '.scope // empty' "$jf" 2>/dev/null || true)"
	fi
	# ALL FOUR, not just the scope. journal_init persists the validated request
	# verbatim (`request: $request`), and panel_target reads the target version
	# and the series as arguments 4 and 5 — so recovering only the scope leaves
	# a PINNED run unpinned:
	#
	#   series lost  -> panel_target re-derives the series from the RUNNING
	#                   version, so an approved 1.1 upgrade silently installs
	#                   the head of 1.0 instead;
	#   version lost -> the "requested X but the series publishes Y" refusal
	#                   never fires, so a run pinned to 1.4.7 installs 1.4.8.
	#
	# Both report healthy. And the trigger is routine, not exotic: stage 0
	# self-update re-execs with ONLY the run id on argv, so this fires on the
	# first run after every shcp-updater release — precisely when a pinned run
	# is most likely to be in flight.
	if [[ -z "$REQ_TARGET_VERSION" ]]; then
		REQ_TARGET_VERSION="$(jq -r '.request.target_panel_version // empty' "$jf" 2>/dev/null || true)"
	fi
	if [[ -z "$REQ_SERIES" ]]; then
		REQ_SERIES="$(jq -r '.request.series // empty' "$jf" 2>/dev/null || true)"
	fi
	if [[ "$REQ_REINSTALL" != "1" ]] && jq -e '.reinstall == true' "$jf" >/dev/null 2>&1; then
		REQ_REINSTALL=1
	fi

	# The request-file rules for a series jump hold on RESUME too: the journal may
	# come from an engine that predates them (a pre-base#1500 `packages`/`all` run
	# carrying a series, auto-triggered or not), and resume and the self-update
	# re-exec rebuild the request from the journal without validate_request_file
	# (updater#178 break-it round 3). Refuse rather than jump series unchecked.
	if [[ -n "$REQ_SERIES" ]]; then
		local rtrig
		rtrig="$(jq -r '.request.trigger // .trigger // empty' "$jf" 2>/dev/null || true)"
		if [[ "$REQ_SCOPE" != "series" || "$rtrig" != "manual" \
			|| ! "$REQ_SERIES" =~ ^[0-9]+\.[0-9]+$ \
			|| ! "$REQ_TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ \
			|| "${REQ_TARGET_VERSION%.*}" != "$REQ_SERIES" ]]; then
			die "run ${run_id}: journal requests series ${REQ_SERIES} under scope '${REQ_SCOPE}' / trigger '${rtrig}' / target '${REQ_TARGET_VERSION}' — a series run must be scope=series, manual, with a target in that series; refusing to resume (roll it back instead)"
		fi
	fi

	# Fail CLOSED (SC-420). An unreadable or truncated journal leaves REQ_SCOPE empty,
	# and empty is not inert: apt_target_pkgs tests for `panel` and `security`
	# and otherwise falls through to osf_upgradable — every upgradable package
	# on the box. Guessing "upgrade everything" from a damaged journal is the
	# widest possible blast radius; refusing to resume is always recoverable.
	if [[ -z "$REQ_SCOPE" ]]; then
		die "run ${run_id}: cannot establish the run's scope from its journal — refusing to resume (fix or roll back the journal first)"
	fi
}

run_stages() {
	local run_id="$1"
	CURRENT_RUN_ID="$run_id"

	# Detect the OS family up front so EVERY stage (including a resumed run that
	# re-enters past preflight) has the osf_* backend selected.
	osf_detect_family

	# An interrupted reinstall can leave the panel symlink dangling (no version to
	# resolve a target from) or a half-built tree live: repair it before anything
	# reads the panel version (shcp-updater#183).
	release_trees_recover || true

	# Self-heal FIRST, before the pending-stage test: a journal left `running`
	# under a completed rollback record used to answer "all stages already
	# done" here on every resume, and nothing ever wrote its terminal status.
	journal_reassert_rollback_terminal "$run_id"

	local pending
	pending="$(journal_pending_stage "$run_id")"
	if [[ -z "$pending" ]]; then
		engine_log "run ${run_id}: all stages already done"
		return 0
	fi

	# A resumed process re-enters past the health stage with no HEALTH_STATUS.
	# Recover the verdict from the journal so the terminal status and the
	# auto-rollback decision do not depend on WHICH process measured it.
	if [[ -z "$HEALTH_STATUS" ]]; then
		HEALTH_STATUS="$(jq -r '.health.status // empty' "$(journal_path "$run_id")" \
			2>/dev/null || true)"
		# `if has` and not `//`: `//` reads false exactly like null. Absent (a
		# journal from an engine without the field) keeps the old meaning, where
		# every unhealthy verdict was a regression.
		HEALTH_REGRESSION="$(jq -r '.health | if type == "object" and has("regression") then (.regression | tostring) else "true" end' \
			"$(journal_path "$run_id")" 2>/dev/null || echo true)"
		[[ "$HEALTH_REGRESSION" == false ]] || HEALTH_REGRESSION=true
	fi

	recover_run_request_state "$run_id"

	# The series-run signal, decided ONCE here so every stage — including a
	# resumed one — branches on the same answer. Set after recover_run_request_
	# state so REQ_SERIES is populated even on a resume that received only a run
	# id (SC-420). Consulted only by stages that run before the panel flip, so the
	# pre-jump panel version is_series_run reads is the right comparand.
	SERIES_RUN=0
	if is_series_run; then
		SERIES_RUN=1
		engine_log "run ${run_id}: series upgrade to ${REQ_SERIES} (running $(ver_series "$(panel_current_version)"))"
	fi

	local started=0 noop=0 stage fn done_n=0
	# A resumed run whose preflight already recorded an empty work set must
	# keep the noop routing (only finalize remains).
	if jq -e '.stages[] | select(.stage == "preflight")
			| .result.workset_empty == true' \
			"$(journal_path "$run_id")" >/dev/null 2>&1; then
		noop=1
	fi
	for stage in "${STAGES[@]}"; do
		if [[ $started -eq 0 ]]; then
			if [[ "$stage" == "$pending" ]]; then
				started=1
			else
				done_n=$((done_n + 1))
				continue
			fi
		fi
		if [[ $noop -eq 1 && "$stage" != "finalize" ]]; then
			# Counted, like a product-seam skip below: a noop run used to end
			# with status.json at stages_done=3 / 27% because this `continue`
			# advanced nothing, so the panel's last poll of a CONVERGED run read
			# as three-quarters unfinished.
			done_n=$((done_n + 1))
			continue
		fi

		# A noop run skipped stage_apt, and with it the additive, manifest-driven
		# host-repair steps. Deliver them once here, immediately before finalize
		# (SC-543): a fully-patched box carrying only an uninstalled runtime dep
		# produces an empty work set every night, so stage_apt — where the repair
		# otherwise lives — is exactly where it would never reach that host. The guard
		# above lets execution past it only when noop=0 or stage==finalize, so this
		# fires solely on the noop finalize iteration, covering both the fresh- and
		# resumed-noop entry points. Idempotent + non-fatal; the steps carry their own
		# journaling and never arm a rollback.
		if [[ $noop -eq 1 && "$stage" == "finalize" ]]; then
			additive_currency_ensure
		fi

		# §4.2-8, the health trigger. It sits HERE — immediately before finalize,
		# never after the loop — because stage_finalize clears the maintenance
		# flag, and clearing the gate over an unhealthy panel is precisely the
		# SC-318 inversion. Placing it at the finalize iteration rather than
		# straight after the health stage also covers the RESUMED process, which
		# re-enters here with the verdict read back from the journal.
		if [[ "$stage" == "finalize" && "$HEALTH_STATUS" == "unhealthy" && "$HEALTH_REGRESSION" != true ]]; then
			# shcp-updater#181: unhealthy, but every failing check was already
			# failing before this run applied anything. Rolling back cannot fix
			# it and would revert the patches this run did apply (the UPD-5 lesson),
			# so the run stays unhealthy — loudly — and is not rolled back.
			engine_log "health: unhealthy, but every failing check was already failing at the preflight baseline — not rolling back"
		elif [[ "$stage" == "finalize" && "$HEALTH_STATUS" == "unhealthy" ]]; then
			# TERMINAL STATUS FIRST — the [R] doctrine below, applied here. The
			# health path used to call auto_rollback with the journal still
			# `running`, so a kill mid-rollback (the apply and its rollback share
			# one TimeoutStartSec) was picked up by `resume`, which re-entered
			# this iteration and re-ran the rollback — repo revert and index
			# refresh included — as if nothing had happened. With `unhealthy`
			# written first, that kill leaves an OPEN rollback record under a
			# terminal status: resume ignores it, rollback_last_record does not
			# count it, and the manual verb is the retry path exactly as for [R].
			# rollback_run overwrites this with its own verdict when it finishes;
			# the terminal `case` below rewrites it when there was nothing to undo.
			if [[ "$(jq -r '.status // empty' "$(journal_path "$run_id")" 2>/dev/null)" == "running" ]]; then
				journal_finish "$run_id" "unhealthy" "health check reported unhealthy — rolling back"
			fi
			auto_rollback "$run_id" "health check reported unhealthy"
			if [[ -n "$ROLLBACK_OUTCOME" && "$ROLLBACK_OUTCOME" != "rolled_back" ]]; then
				# Fail closed. finalize prunes snapshots and release dirs, and a
				# partial rollback is exactly the state where a manual retry needs
				# both of them.
				engine_log "auto-rollback did not converge (${ROLLBACK_OUTCOME}) — skipping finalize"
				status_write "$run_id" "rollback" "$done_n" "updates.stage.rollback" \
					"$ROLLBACK_OUTCOME"
				return 1
			fi
		fi

		# Product seam (SC-614): skip a stage that does not apply to THIS product.
		# An app-scoped product skips the box-wide apt stage (so it is never a
		# second apt owner, SC-319); shcp-dns has no DB seam and no Apache vhosts.
		# SKIPPED, not errored: recorded done with a skip reason so a resume steps
		# over it and stages_total/percentage stay honest. Gated only when PRODUCT
		# is resolved -- a sourced unit test that drives run_stages directly leaves
		# it empty and keeps the historical all-stages behavior; production always
		# resolves it in cmd_apply/cmd_resume before reaching here.
		if [[ -n "$PRODUCT" ]] && ! product_stage_applies "$PRODUCT" "$stage"; then
			engine_log "stage ${stage}: skipped -- not applicable to product ${PRODUCT}"
			journal_stage_start "$run_id" "$stage"
			journal_stage_done "$run_id" "$stage" \
				"$(jq -nc --arg p "$PRODUCT" '{skipped: ("not applicable to product " + $p)}')"
			done_n=$((done_n + 1))
			status_write "$run_id" "$stage" "$done_n" "updates.stage.${stage}" "skipped"
			engine_log "stage ${stage}: done (skipped)"
			continue
		fi

		journal_stage_start "$run_id" "$stage"
		status_write "$run_id" "$stage" "$done_n" "updates.stage.${stage}" ""
		engine_log "stage ${stage}: started"
		stage_crash_check "$stage"

		fn="stage_${stage//-/_}"
		STAGE_RESULT='{}'
		if ! "$fn"; then
			engine_log "stage ${stage}: FAILED"
			# ORDER MATTERS. journal_finish FIRST, stage_done second.
			# journal_stage_done sets done_at, and journal_pending_stage treats a
			# stage with done_at as complete. These are two separate jq+rename
			# cycles, so a SIGKILL between them (TimeoutStartSec, power loss) used
			# to leave status=running with the failed stage marked done — and
			# `resume` would then step OVER the failed db stage into restart /
			# health / finalize, clear the maintenance flag and finish the run
			# HEALTHY over a half-migrated database.
			# Writing the terminal status first means a kill in the same window
			# leaves status=failed: resume ignores it (it only picks up
			# `running`) and the check verb reports it, which is the honest
			# outcome for a run that did fail.
			# Carry the stage's OWN diagnosis into the top-level error. The panel
			# importer reads only `.error` — it never looks at `.stages[].error` —
			# so every specific reason UPD-4 records via stage_error ("panel DB
			# path disagreement", "post-migration integrity_check failed",
			# "snapshot could not be fingerprinted") was being replaced by the
			# generic "stage db failed" before it ever reached a notification.
			# The operator got told WHICH stage failed but never WHY.
			#
			# Two places a stage can leave its reason, and BOTH have to be read.
			# stage_error writes `.stages[].error` and survives. STAGE_RESULT does
			# NOT: the assignment below replaces it with {"failed": true}, so a
			# stage that only described itself there is silently reduced to the
			# generic message. stage_preflight and stage_panel do exactly that —
			# UPD-4 gave stage_error to snapshot/apt/db and left those two behind,
			# and it shows on a real box: a preflight that refused because
			# unattended-upgrades still owns apt journals nothing but "stage
			# preflight failed", while the `check` verb three lines away can name
			# the reason exactly. Same for every panel abort (artifact
			# verification, extraction, smoke test, promotion, flip).
			# So harvest STAGE_RESULT's own `.failed`/`.blockers[0]` when the stage
			# recorded no error, and put it where stage_error would have. Fixing it
			# here rather than at the 11 call sites closes the class instead of the
			# instances, and any stage added later inherits it.
			#
			# `.reason` (+ `.detail`) is the third shape: a helper's result global
			# handed on as STAGE_RESULT describes its failure ONLY there. The
			# panel-state migration that motivated this (since removed) journaled
			# a bare "stage panel failed" while the remedy ("Upgrade the shcpd
			# package ...") reached only journalctl (shcp-updater#166). `.detail` is joined
			# on because `.reason` is the short label and the remedy lives in
			# `.detail`. Checked after `.failed`, so a stage that sets both keeps
			# the message it chose.
			local stage_reason
			stage_reason="$(jq -r --arg s "$stage" \
				'(.stages[]? | select(.stage == $s) | .error) // empty' \
				"$(journal_path "$run_id")" 2>/dev/null || true)"
			if [[ -z "$stage_reason" ]]; then
				# jq on STAGE_RESULT, which is a string this shell holds — a stage
				# is free to leave it non-JSON, so tolerate a parse failure.
				stage_reason="$(jq -r '
					def text: type == "string" and length > 0;
					if (.failed? | type) == "string" then .failed
					elif (.blockers? | type) == "array" and (.blockers | length) > 0
						then (.blockers[0] | tostring)
					elif (.reason? | text)
						then .reason + (if (.detail? | text) then ": " + .detail else "" end)
					else empty end' <<<"$STAGE_RESULT" 2>/dev/null || true)"
				[[ -n "$stage_reason" ]] && stage_error "$stage" "$stage_reason"
			fi
			# The reason has to reach the SYSTEM journal too, not only the run
			# journal. `journalctl -u shcp-update-security` is where an operator
			# looks first, and it showed a bare "stage preflight: FAILED" while the
			# cause sat in runs/<id>/journal.json — five steps away: read the unit,
			# read the journal, resolve SHCP_UPDATE_STATE_DIR, find the run dir,
			# parse the JSON. The string is already in hand by this point; the only
			# reason it was not printed is that the bare marker above is emitted
			# BEFORE the harvest below computes it.
			#
			# An `if` and not `[[ ... ]] && engine_log ...`: under `set -e` the
			# and-list returns 1 when the test is false, which would abort the run
			# on every failure that has no recorded reason.
			if [[ -n "$stage_reason" ]]; then
				engine_log "stage ${stage}: reason: ${stage_reason}"
				journal_finish "$run_id" "failed" "stage ${stage} failed: ${stage_reason}"
			else
				journal_finish "$run_id" "failed" "stage ${stage} failed"
			fi
			journal_stage_done "$run_id" "$stage" '{"failed": true}'
			status_write "$run_id" "$stage" "$done_n" "updates.stage.${stage}" "failed"

			# §4.2-8's other trigger: the `[R]` stage set. self-update, preflight
			# and snapshot are deliberately NOT in it — they fail before anything
			# is applied, and stage_snapshot's own header says so.
			#
			# journal_finish "failed" above stays exactly where it is. A SIGKILL
			# between the terminal-status write and journal_stage_done must leave
			# `failed`, not a resumable "healthy"; auto_rollback runs after BOTH
			# and rewrites the terminal status at the end via its own
			# journal_finish. A SIGKILL mid-rollback therefore leaves `failed` —
			# non-resumable, honest, and the operator still has the manual verb.
			case "$stage" in
				apt|panel|db|restart)
					# Carry the stage's OWN diagnosis into the rollback reason, and
					# from there into the terminal error. rollback_run rewrites
					# `.error`, and the panel importer reads only `.error` — so
					# passing a generic "stage db failed" here would throw away
					# every specific reason stage_error records ("post-migration
					# integrity_check failed", "no console at …") at the exact
					# moment the operator most needs it.
					auto_rollback "$run_id" \
						"stage ${stage} failed${stage_reason:+: ${stage_reason}}"
					if [[ "$ROLLBACK_OUTCOME" == "rolled_back" ]]; then
						# The rollback converged, so finalize's pruning is safe and
						# the fixture's finalize-after-rollback ordering holds. Same
						# per-stage bookkeeping as the loop body: this path used to
						# skip status_write, so status.json's last word on a run that
						# went on to finalize was "<stage> failed".
						# Interpolated key, as the loop body emits it: the stage
						# catalogue (tests/lib/stage-catalogue.sh) derives the
						# emitted set from STAGES plus literal keys, and a literal
						# for a stage STAGES already names would double-count it.
						local fin_stage="finalize"
						journal_stage_start "$run_id" "$fin_stage"
						status_write "$run_id" "$fin_stage" "$done_n" "updates.stage.${fin_stage}" ""
						STAGE_RESULT='{}'
						if stage_finalize; then
							journal_stage_done "$run_id" "$fin_stage" "$STAGE_RESULT"
							done_n=$((done_n + 1))
							status_write "$run_id" "$fin_stage" "$done_n" "updates.stage.${fin_stage}" "done"
						else
							journal_stage_done "$run_id" "$fin_stage" '{"failed": true}'
							status_write "$run_id" "$fin_stage" "$done_n" "updates.stage.${fin_stage}" "failed"
						fi
					fi
					;;
			esac
			return 1
		fi
		journal_stage_done "$run_id" "$stage" "$STAGE_RESULT"
		done_n=$((done_n + 1))
		status_write "$run_id" "$stage" "$done_n" "updates.stage.${stage}" "done"
		engine_log "stage ${stage}: done"

		# Cross-reboot resume (shcp-build#97 / SC-532).
		# A stage that needs a deliberate reboot before the run can continue sets
		# REBOOT_AND_RESUME_REQUESTED=1 in its body; the SHCP_UPDATE_REBOOT_AT_STAGE
		# seam does the same after a named stage (the drill hook and the OS-upgrade
		# epic's extension point). Evaluated HERE — after journal_stage_done — so
		# the just-finished stage is recorded complete and the resumed run
		# re-enters at the NEXT stage, never re-running the stage that asked for the
		# reboot. Re-running it would let it re-request the reboot: an infinite
		# loop. Never on the last stage — there is nothing to resume into.
		if [[ "${SHCP_UPDATE_REBOOT_AT_STAGE:-}" == "$stage" ]]; then
			REBOOT_AND_RESUME_REQUESTED=1
		fi
		if [[ "${REBOOT_AND_RESUME_REQUESTED:-0}" -eq 1 \
				&& "$stage" != "${STAGES[$((${#STAGES[@]}-1))]}" ]]; then
			if park_for_reboot "$run_id" "$done_n"; then
				return 0   # parked: status is awaiting_reboot; the box is rebooting
			fi
			return 1       # hard refusal (loop cap / unwritable marker) — already failed
		fi

		if [[ "$stage" == "preflight" && $WORKSET_EMPTY -eq 1 ]]; then
			engine_log "work set is empty — noop run, skipping to finalize"
			noop=1
		fi
	done

	# §4.2-8's truth table for the terminal status:
	#   noop run                                          -> noop
	#   healthy                                           -> healthy
	#   unhealthy, nothing to roll back                    -> unhealthy
	#   unhealthy or [R] failure, rollback converged       -> rolled_back
	#   ... rollback partial                               -> rolled_back_partial
	#   ... the rollback body itself errored               -> failed
	# The last three are written by rollback_run itself, which is why a non-empty
	# ROLLBACK_OUTCOME means "the status is already decided, do not overwrite it".
	local terminal
	if [[ $noop -eq 1 ]]; then
		terminal="noop"
	elif [[ -n "$ROLLBACK_OUTCOME" ]]; then
		terminal="$ROLLBACK_OUTCOME"
	elif [[ "$HEALTH_STATUS" == "unhealthy" ]]; then
		terminal="unhealthy"
	else
		terminal="healthy"
	fi

	# A successful security-covering run stamps last_security_success — the
	# SC-319 watchdog input. AD-6 wants EVERY security run to stamp, including a
	# noop on a fully-patched host: Debian-Security publishes irregularly, so a
	# healthy idle host mostly produces noop security runs, and stamping only on
	# an actual apply would false-alarm "security patching stalled" after 3 days.
	# Gated on CONVERGENCE, not merely on reaching the end of the loop: a run that
	# ended unhealthy or was rolled back has not patched this host, and stamping
	# it would tell the watchdog otherwise.
	# ...and on a COMPLETE index refresh (SC-319). A run whose refresh failed worked
	# from lists that may be days stale, so neither its "nothing to do" nor the set
	# it DID apply proves the host is patched, even when it ended healthy.
	# An unhealthy run with NO regression also converged: it applied its patches
	# and was deliberately not rolled back (shcp-updater#181), so the host is
	# patched even though the panel is down.
	local converged=0
	[[ "$terminal" == "healthy" || "$terminal" == "noop" ]] && converged=1
	[[ "$terminal" == "unhealthy" && "$HEALTH_REGRESSION" != true ]] && converged=1
	if (( converged )); then
		if jq -e '.index_refresh_failed == true' "$(journal_path "$run_id")" >/dev/null 2>&1; then
			engine_log "not stamping last_security_success: this run's package index refresh failed (SC-319)"
		elif jq -e '(.packages.held_back // []) | length > 0' "$(journal_path "$run_id")" >/dev/null 2>&1; then
			# #321: a package held back is a pending update this run did not apply.
			engine_log "not stamping last_security_success: this run held packages back (see check: packages_held_back)"
		else
			case "$(jq -r '.scope // empty' "$(journal_path "$run_id")" 2>/dev/null)" in
				# series runs the full package set exactly like all (base#1500).
				security|packages|all|series)
					journal_update "$run_id" '.last_security_success = $now' --arg now "$(now_utc)" ;;
			esac
		fi
	fi

	case "$terminal" in
		noop|healthy) journal_finish "$run_id" "$terminal" ;;
		unhealthy)
			if [[ "$HEALTH_REGRESSION" != true ]]; then
				journal_finish "$run_id" "unhealthy" \
					"health check reported unhealthy: $(jq -r '[.health.checks[]? | select(.ok == false) | .check] | join(", ")' "$(journal_path "$run_id")" 2>/dev/null || true) failing, and already failing before this run — not rolled back; the panel needs attention"
			else
				journal_finish "$run_id" "unhealthy" \
					"health check reported unhealthy; the run applied nothing that could be rolled back"
			fi
			;;
		*) : ;;   # rollback_run already wrote the terminal status and the reason
	esac

	# §4.2-9: hand the finished run to the panel only after journal_finish.
	# Importing from stage_finalize exposed the pre-terminal `running` state and
	# left SystemUpdateRun stale until the next shcp:update:check reconciliation.
	panel_import_run "$run_id"
	return 0
}

# --- abnormal-exit trap ----------------------------------------------------------
# On any non-zero exit after a run started, mark the journal failed if it is
# still "running". kill -9 bypasses this by design — that is what `resume` and
# the panel's stale-journal watchdog are for.
#
# *** THIS TRAP DOES NOT CLEAR THE MAINTENANCE FLAG. That is the whole point. ***
# It used to (UPD-1 skeleton), and UPD-4 is what made that destructive: stage_db
# is the first stage whose failure can leave the DATABASE inconsistent. The path
# was: db stage fails -> run_stages returns 1 -> emit_run_result returns 1 (its
# last expression is a status test) -> main returns 1 -> `set -e` exits 1 -> this
# trap fires -> flag removed. The box was then left serving the NEW panel code
# against a HALF-MIGRATED schema with the gate down, accepting writes that land
# on a schema neither release expects — and since `resume` only picks up
# status=running, leaving `shcp-update rollback <run-id>` as the recovery verb —
# which the check verb now advertises as the `rollback_available` blocker.
#
# SC-318 is explicit that this is backwards: the flag is cleared only once the
# panel is in a consistent state (healthy new version, or rolled back), and
# "abnormal exits leave the flag IN PLACE (fail closed)". So the ONLY places
# that clear it are stage_finalize (a run that converged) and the operator's
# `shcp-update maintenance --clear`. A failed run intentionally leaves the panel
# in maintenance: a visible outage the operator can act on is strictly better
# than a silently corrupt panel that looks healthy.
on_engine_exit() {
	local rc=$?
	shcp_release_lifecycle_release || true
	# A transient no-autostart policy-rc.d must never outlive the process (#256).
	# Idempotent: a no-op unless a package-install window was interrupted.
	osf_policy_rc_d_restore
	# No engine exit leaves the panel's timers stopped on a box that is back in
	# service (shcp-updater#302; the SC-757 class of leftover). In a subshell:
	# a journal write that fails there must not end this trap early.
	[[ -z "$CURRENT_RUN_ID" ]] || ( panel_timers_release_unless_gated "$CURRENT_RUN_ID" ) || true
	[[ $rc -eq 0 ]] && return 0
	if [[ -n "$CURRENT_RUN_ID" ]]; then
		local jf
		jf="$(journal_path "$CURRENT_RUN_ID")"
		if [[ -f "$jf" ]] \
				&& [[ "$(jq -r '.status' "$jf" 2>/dev/null)" == "running" ]]; then
			journal_finish "$CURRENT_RUN_ID" "failed" "engine exited with status ${rc}" \
				2>/dev/null || true
		fi
		if [[ -f "$MAINT_FLAG" ]]; then
			engine_log "maintenance flag LEFT IN PLACE after a failed run (SC-318 fail-closed)."
			engine_log "  the panel stays gated until the update is completed or rolled back;"
			engine_log "  once the panel is known good, clear it with: shcp-update maintenance --clear"
		fi
	fi
	return 0
}

# --- verbs ------------------------------------------------------------------------

command_uuid_valid() {
	# Keep this byte-for-byte grammar aligned with request_validate: UUIDs are
	# opaque correlation values here, not version/variant dispatch selectors.
	[[ "$1" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]
}

cmd_apply() {
	local from_request=0 reinstall=0 scope_arg="" resumed_run_id="" yes=0 command_uuid_arg=""
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--from-request) from_request=1; shift ;;
			--reinstall)    reinstall=1; shift ;;
			--command-uuid) [[ $# -ge 2 ]] || die "apply: --command-uuid requires a value"; command_uuid_arg="$2"; shift 2 ;;
			--scope)        scope_arg="${2:-}"; shift 2 ;;
			--resumed-from-self-update) resumed_run_id="${2:-}"; shift 2 ;;
			--json)         JSON_OUTPUT=1; shift ;;
			--yes)          yes=1; shift ;;
			*) die "apply: unknown argument: $1" ;;
		esac
	done
	json_claim_stdout
	: "$yes"  # reserved: apply is non-destructive at this layer (SC-071 covers rollback)

	# --scope is accepted ONLY for the panel-independent security path
	# (AD-6). Anything else must come through the validated request file —
	# the panel cannot pass argv (SC-317).
	if [[ -n "$scope_arg" && "$scope_arg" != "security" ]]; then
		die "apply --scope only accepts 'security' (use the request file for other scopes)"
	fi

	# --command-uuid is the ONE argv value the panel may pass into apply, and it
	# is only meaningful for --reinstall (base#811, SC-317 carve-out). It is the
	# panel's audit-row uuid arriving as the reinstall unit's %i instance name —
	# an OPAQUE correlation echo, byte-for-byte the same treatment the request
	# file's command_uuid gets. It rides REQ_JSON into the journal and NOTHING
	# else: it never reaches apt, dpkg, or target resolution. Reject anything
	# that is not a strict lowercase RFC-4122 uuid so a malformed %i cannot
	# propagate; refuse it outside --reinstall so it stays a single narrow seam.
	if [[ -n "$command_uuid_arg" ]]; then
		[[ $reinstall -eq 1 ]] || die "apply --command-uuid is only valid with --reinstall (SC-317)"
		command_uuid_valid "$command_uuid_arg" || die "apply --command-uuid: invalid uuid '${command_uuid_arg}' (SC-317)"
	fi

	mkdir -p "$RUNS_DIR"

	# SC-614: resolve which product this box is from on-box state BEFORE any
	# fetch or apply, and FAIL CLOSED. Covers the normal apply AND the
	# self-update re-exec hop below (a fresh process that must re-resolve).
	require_product_resolved

	# Self-update re-exec hop: continue the existing run.
	if [[ -n "$resumed_run_id" ]]; then
		run_id_valid "$resumed_run_id" || die "invalid --resumed-from-self-update run id"
		[[ -f "$(journal_path "$resumed_run_id")" ]] \
			|| die "no journal for resumed run ${resumed_run_id}"
		# SC-636: the hop continues the run from its
		# on-disk journal exactly as `resume` does, so it passes the same gate,
		# and before the lock/trap for the same reasons (see cmd_resume).
		if ! run_tree_trusted "$resumed_run_id"; then
			engine_log "apply: refusing the self-update resume of run ${resumed_run_id} — ${RUN_TREE_UNTRUSTED} (fail closed). $(run_tree_escape "$resumed_run_id" "shcp-update resume")"
			die "run ${resumed_run_id} failed ownership validation — refusing to resume after the self-update hop; nothing was changed"
		fi
		RESUMED_FROM_SELF_UPDATE=1
		acquire_lock || lock_refused_exit
		CURRENT_RUN_ID="$resumed_run_id"
		trap on_engine_exit EXIT
		ensure_run_policy_admitted "$resumed_run_id"
		engine_log "resuming run ${resumed_run_id} after self-update hop"
		run_stages "$resumed_run_id" || true
		run_initramfs_verdict "$resumed_run_id" || true
		emit_run_result "$resumed_run_id"
		return
	fi

	local trigger scope request_json
	if [[ $from_request -eq 1 ]]; then
		validate_request_file "$REQUEST_FILE" \
			|| die "request rejected: ${REQ_ERROR}"
		trigger="$REQ_TRIGGER"
		scope="$REQ_SCOPE"
		request_json="$REQ_JSON"
	elif [[ -n "$scope_arg" ]]; then
		# Engine-direct security run (shcp-update-security.timer). Settings
		# read fails toward patching (AD-6): absent/unreadable DB or key ⇒
		# enabled.
		local enabled
		enabled="$(settings_get "update.security.enabled" "true")"
		if [[ "$enabled" == "false" || "$enabled" == "0" ]]; then
			log "security runs disabled by update.security.enabled — skipping"
			if (( JSON_OUTPUT == 1 )); then
				jq -n '{status: "skipped", reason: "security_updates_disabled"}' >&"$JSON_FD"
			fi
			return 0
		fi
		trigger="auto"
		scope="security"
		request_json='{"scope": "security", "trigger": "auto", "requested_by": 0}'
	elif [[ $reinstall -eq 1 ]]; then
		# upcp --force parity (§2.1): run the full pipeline against the CURRENT
		# panel version, re-extracting it rather than skipping because there is
		# nothing newer. REQ_REINSTALL is what panel_target reads to allow that;
		# without it this branch journals `reinstall: true` and then resolves no
		# target at all, which is what it did until UPD-8.
		trigger="manual"
		scope="panel"
		REQ_REINSTALL=1
		# requested_by stays 0 — the human initiator lives on the panel's audit
		# row, not here (base#811). command_uuid, when the panel passed one via
		# %i, is folded in the SAME way validate_request_file rebuilds REQ_JSON:
		# validated fields only, so nothing unvalidated reaches the journal.
		request_json="$(jq -n \
			--arg cu "$command_uuid_arg" \
			'{scope: "panel", trigger: "manual", requested_by: 0}
			 + (if $cu == "" then {} else {command_uuid: $cu} end)')"
	else
		die "apply needs one of --from-request | --scope security | --reinstall"
	fi

	# The stages read the request scope from REQ_SCOPE; the --from-request path
	# set it via validate_request_file, but the engine-direct (--scope security)
	# and --reinstall paths must set it too, or stage_apt would default to a
	# full upgrade instead of the intended set.
	REQ_SCOPE="$scope"

	# A run PARKED for a reboot holds no lock (its process exited so the box could
	# reboot), yet it is mid-flight with the maintenance gate up and a resume
	# owing. Starting a FRESH run over it would interleave two updates and leave
	# the parked run's gate owned by a run that will never finish. The flock does
	# not catch this — the parked run is not holding it — so guard explicitly.
	local existing_parked
	existing_parked="$(latest_run_id_with_status awaiting_reboot)"
	if [[ -n "$existing_parked" ]]; then
		die "run ${existing_parked} is awaiting a reboot to resume — reboot the host (or run \`shcp-update resume\`) before starting a new update"
	fi

	acquire_lock || lock_refused_exit

	# The same guard for a run left `running` by a SIGKILL or power loss. With
	# the lock now HELD, a `running` journal cannot belong to a live engine: it
	# is an interrupted run, half-applied, with its maintenance gate up. Starting
	# a fresh run over it (the nightly security timer would, every night) has
	# maintenance_write rewrite the flag's run_id to the new run and finalize
	# clear the old run's gate — the SC-318 inversion by proxy — while the old
	# run's packages, symlink and schema are wherever the kill left them. Exit
	# 1, not 75: this is not retryable without an operator (resume or rollback).
	# Checked after the lock so it cannot race the engine it would otherwise see.
	local interrupted
	interrupted="$(latest_run_id_with_status running)"
	if [[ -n "$interrupted" ]]; then
		die "run ${interrupted} was interrupted (status running, no live process): resume it with \`shcp-update resume\` or investigate; refusing to start a new run over it"
	fi

	# A maintenance flag OWNED BY ANOTHER RUN is the same hazard from the flag's
	# side: a failed run left the gate up on purpose (SC-318 fail-closed) and the
	# operator's remedies are complete it, roll it back, or clear the flag. A new
	# run would take the flag over and finalize would drop it. The operator's own
	# `maintenance --set` flag (run_id "operator", cmd_maintenance) is NOT a
	# refusal: it was raised deliberately and a run may proceed under it — the
	# run rewrites the owner while it holds the window (maintenance_write) and
	# clears its own on finalize, which is the pre-1.0 behaviour, documented here
	# rather than changed. Unreadable/unowned flags are left to the run.
	if [[ -f "$MAINT_FLAG" ]]; then
		local maint_owner
		maint_owner="$(jq -r '.run_id // empty' "$MAINT_FLAG" 2>/dev/null || true)"
		if [[ -n "$maint_owner" && "$maint_owner" != "operator" ]]; then
			die "the maintenance flag ${MAINT_FLAG} belongs to run ${maint_owner} (left up by an incomplete run, SC-318): complete it, roll it back (\`shcp-update rollback ${maint_owner}\`) or clear it (\`shcp-update maintenance --clear\`); refusing to start a new run over it"
		fi
	fi

	# Re-read authorization after winning the updater lock and before creating a
	# journal, maintenance flag, snapshot, or package/panel mutation.
	local run_id admission
	if ! admission="$(routine_policy_admit "$scope" "$trigger")"; then
		die "update policy admission refused: ${admission}"
	fi
	fresh_run_state_trusted \
		|| die "${RUN_TREE_UNTRUSTED} — refusing to start a run that could not be resumed or rolled back; restore root ownership and a mode without group/other write on that path. Nothing was changed"
	run_id="$(new_run_id)"
	journal_init "$run_id" "$trigger" "$scope" "$request_json" \
		"$( [[ $reinstall -eq 1 ]] && echo true || echo false )" "$admission"
	CURRENT_RUN_ID="$run_id"
	trap on_engine_exit EXIT
	ensure_run_policy_admitted "$run_id"

	# base#737 (SC-317): CONSUME the request file now that this run holds the lock
	# and its request bytes are durably in the journal above. The request file is
	# a single-use token: while it exists the panel treats a request as pending
	# and refuses to overwrite it, and while the lock is held it refuses to
	# enqueue another — so the two guards hand off with no gap (present-then-held)
	# and a run can never execute a scope from a DIFFERENT request than the
	# ACCEPTED the operator saw (last-writer-wins). Only --from-request reads it;
	# the engine-direct security and reinstall paths never wrote REQ_SCOPE from a
	# file, and a resume rebuilds the request from the journal (SC-420), so
	# removing it here never harms a resume.
	if [[ $from_request -eq 1 ]]; then
		rm -f "$REQUEST_FILE"
	fi

	engine_log "run ${run_id} started (trigger=${trigger} scope=${scope})"

	run_stages "$run_id" || true
	run_initramfs_verdict "$run_id" || true
	emit_run_result "$run_id"
}

# park_for_reboot <run_id> <done_n> — pause a run for a deliberate reboot so it
# resumes automatically at boot (shcp-build#97 / SC-532).
# Called from run_stages AFTER the reboot-requesting stage is journaled done, so
# the resumed run re-enters at the NEXT stage.
#
# ORDER IS THE SAFETY, the same doctrine as the stage-failure path's
# "journal_finish before stage_done": marker FIRST, then the status flip, then
# reboot.
#   - killed before the flip -> status is still `running` + a marker: degrades to
#     the existing, understood stale-`running`/resume recovery, never a novel
#     dead state.
#   - killed after the flip  -> awaiting_reboot + marker: the boot unit or a
#     manual `resume` picks it up, and the aged `check` blocker surfaces it if
#     neither does.
# Returns 0 when the run is parked (the caller stops the loop and reports
# awaiting_reboot); 1 on a hard refusal (reboot-loop cap, or an unwritable
# marker), having ALREADY journaled the run failed — the caller propagates that.
park_for_reboot() {
	local run_id="$1" done_n="$2"

	# Reboot-loop cap: a caller that re-requests a reboot without converging must
	# not be able to reboot-loop a customer's box. A multi-reboot distro upgrade
	# needs only a handful of hops.
	local count
	count="$(jq -r '.reboot.count // 0' "$(journal_path "$run_id")" 2>/dev/null || echo 0)"
	[[ "$count" =~ ^[0-9]+$ ]] || count=0
	if (( count >= SHCP_UPDATE_REBOOT_MAX )); then
		engine_log "park: run ${run_id} has already rebooted ${count} time(s) (cap ${SHCP_UPDATE_REBOOT_MAX}) — refusing another and failing the run"
		journal_finish "$run_id" "failed" \
			"reboot loop cap reached (${count} reboots) — a stage keeps requesting a reboot without converging"
		return 1
	fi
	count=$(( count + 1 ))

	# Never reboot into a kernel whose initramfs cannot mount root: that is the
	# one failure no resume recovers from (SC-783).
	if ! initramfs_gate "$run_id"; then
		journal_finish "$run_id" "failed" "refusing to reboot: ${INITRAMFS_ERROR}"
		return 1
	fi

	local pending now
	pending="$(journal_pending_stage "$run_id")"
	now="$(now_utc)"

	# Marker FIRST (0600 root — atomic_write_durable's fixed mode; the panel never
	# reads it, and the resume path re-validates the owner regardless). If we
	# cannot write it, do NOT reboot into a run nothing will resume — fail loudly
	# instead. DURABLE: this and the status flip below are the one-way latch
	# before `systemctl reboot`, and a plain rename can still be in the page
	# cache when the reboot lands a few milliseconds later. A marker that never
	# reached the disk means the boot unit's ConditionPathExists never fires and
	# the run comes back `running` with nothing to resume it.
	if ! jq -nc --arg r "$run_id" --arg s "$pending" --arg now "$now" \
			'{run_id: $r, resume_stage: $s, armed_at: $now}' \
			| atomic_write_durable "$AWAITING_REBOOT_MARKER"; then
		engine_log "park: could not write the awaiting-reboot marker ${AWAITING_REBOOT_MARKER} — failing the run rather than rebooting into an unresumable state"
		journal_finish "$run_id" "failed" "could not write the awaiting-reboot marker"
		return 1
	fi

	# Then the status flip + reboot bookkeeping. resume_stage is DIAGNOSTIC ONLY:
	# a resume recomputes the re-entry point from journal_pending_stage (the
	# journal is authoritative, SC-420), never from this copy.
	#
	# Two facts are captured HERE, before the reboot, because both are ephemeral:
	#   - boot_id: the current kernel boot_id, so `resume --at-boot` can prove the
	#     box actually rebooted (the marker alone cannot — it is just a file).
	#   - reboot_required: osf_reboot_required read while deb's tmpfs marker still
	#     exists. The reboot below wipes it; a resumed stage_restart would then
	#     re-probe false and lose the fact. OR into the journal — never downgrade a
	#     `true` a stage already recorded (a park after `restart`).
	local park_boot_id; park_boot_id="$(current_boot_id)"
	local park_reboot_req; park_reboot_req="$(osf_reboot_required)"
	[[ "$park_reboot_req" == "true" ]] || park_reboot_req="false"
	# Durable for the same reason as the marker: the flip is the other half of
	# the latch the reboot must find on disk.
	journal_update_durable "$run_id" \
		'.status = "awaiting_reboot"
		 | .reboot_required = ((.reboot_required // false) or $rr)
		 | .reboot = ((.reboot // {}) + {awaiting_at: $now, resume_stage: $s, count: $c, boot_id: $bid})' \
		--arg now "$now" --arg s "$pending" --argjson c "$count" \
		--argjson rr "$park_reboot_req" --arg bid "$park_boot_id"
	# status.json message stays a real stage key (the interpolated form the stage
	# catalogue excludes, and the panel's ENGINE_STAGES map can render); the pause
	# is carried in the detail tick. awaiting-reboot is a STATUS, never a stage, so
	# it must not mint a new literal key in the updates-stage namespace — that
	# namespace belongs to the stage catalogue, guarded by test-updater-stages.sh.
	status_write "$run_id" "$pending" "$done_n" "updates.stage.${pending}" "awaiting-reboot"
	engine_log "run ${run_id}: parked for reboot (resume at stage ${pending}, reboot ${count}/${SHCP_UPDATE_REBOOT_MAX}); shcp-update-resume.service will run \`shcp-update resume --at-boot\`"

	# Reboot. A failure here is CRITICAL-loud but NOT fatal to the journal: the
	# reboot-requesting stage's packages are already applied, so reverting to
	# `running` and re-running would be wrong. The run stays awaiting_reboot; the
	# box resumes on its next reboot and the aged `check` blocker surfaces it
	# meanwhile. Guarded so `set -e` cannot abort us mid-park. The command is a
	# string ("systemctl reboot") split into argv the house way (read -r -a).
	local -a reboot_cmd
	read -r -a reboot_cmd <<<"$SHCP_UPDATE_REBOOT_CMD"
	if ! "${reboot_cmd[@]}"; then
		engine_log "park: CRITICAL — reboot command '${SHCP_UPDATE_REBOOT_CMD}' failed; run ${run_id} stays awaiting_reboot. Reboot the host to resume, or run \`shcp-update resume\`."
	fi
	return 0
}

emit_run_result() {
	local run_id="$1" jf
	jf="$(journal_path "$run_id")"
	local status
	status="$(jq -r '.status' "$jf")"
	if [[ $JSON_OUTPUT -eq 1 ]]; then
		jq -n --arg run_id "$run_id" --arg status "$status" \
			'{run_id: $run_id, status: $status}' >&"$JSON_FD"
	elif [[ "$status" == "awaiting_reboot" ]]; then
		log "run ${run_id} paused for reboot; it will resume automatically at boot"
	else
		log "run ${run_id} finished: ${status}"
	fi
	# awaiting_reboot is a clean PAUSE, not a failure: a parked run exits 0 so the
	# oneshot apply.service does not record a successful park as a unit failure in
	# the journal. healthy/noop are the converged terminal successes.
	[[ "$status" == "healthy" || "$status" == "noop" || "$status" == "awaiting_reboot" ]]
}

# run_tree_trusted <run-id> — may a root verb act on this run's ON-DISK state?
#
# SC-636: the rename-substitute class SC-532 closes
# for `resume --at-boot` only. Whoever can write a directory above the state
# dir can rename the root-owned update/ subtree aside and plant their own; the
# panel user could, while it owned /var/lib/shcp (before SC-663 made it
# root:shcp 0751). Every verb that continues or acts on an existing run from its journal —
# `resume` (parked or crashed), the `apply --resumed-from-self-update` hop, and
# `rollback` (whose unit the PANEL starts with the run id as %i) — would then
# execute attacker-chosen fields as root: .console_identity_target and the run's
# manifest.json pick the release, .packages.changed picks what to downgrade.
#
# So trust nothing by content. runs/, the run's dir and its journal must be
# owned by the expected uid and not group/other-writable, plus the run's
# manifest.json when one exists. A non-root attacker cannot forge a root-owned
# file, and `stat` (no -L) reports a symlink's OWN owner and mode (0777), so a
# planted file, directory or symlink is refused. The runs/ CONTAINER matters as
# much as the leaves: inside an attacker-owned runs/, genuine root-written run
# dirs can still be renamed — hidden (defeating run_superseded_by), re-ordered,
# or given another run's id.
#
# The leaves alone cannot stop a genuine older update/ or runs/ tree being
# renamed back into place (a replay of root-written state), or a swap mid-run
# after the gate has passed. Only a chain nobody else can write does
# (shcp-updater#297), so update/ must pass the same test and every directory
# above it must be root-held (path_chain_root_held: a sticky /tmp in the
# suites). On a box that is root:shcp 0751 /var/lib/shcp, which the panel
# cannot change, so a chain that passes here holds for the whole run.
# SHCP_UPDATE_EXPECT_UID is the same seam the request-file owner check uses.
# Sets RUN_TREE_UNTRUSTED to the failing path.
RUN_TREE_UNTRUSTED=""
state_dir_trusted() {
	RUN_TREE_UNTRUSTED=""
	if ! path_chain_root_held "$(dirname -- "$SHCP_UPDATE_STATE_DIR")"; then
		RUN_TREE_UNTRUSTED="${CHAIN_BREAK} is not owned by root or uid ${SHCP_UPDATE_EXPECT_UID}, or is group/other-writable without the sticky bit, so whoever owns it can swap ${SHCP_UPDATE_STATE_DIR}"
		return 1
	fi
	if ! path_uid0_not_writable "$SHCP_UPDATE_STATE_DIR"; then
		RUN_TREE_UNTRUSTED="${SHCP_UPDATE_STATE_DIR} is not owned by uid ${SHCP_UPDATE_EXPECT_UID} or is group/other-writable"
		return 1
	fi
	return 0
}

# fresh_run_state_trusted — may a NEW run be started in this state dir? The
# same test run_tree_trusted applies to the directories above a run, asked
# before the run exists: a run started where the test fails is one `resume` and
# `rollback` would then refuse to touch. The state dir's mode is pinned first,
# as journal_init pins it, so only its owner and the chain above can refuse.
#
# Only when the engine runs as the uid it expects to own the tree — root on a
# box. Any other caller owns none of it and has no run to start; the suites
# that leave SHCP_UPDATE_EXPECT_UID at 0 and run unprivileged are that case.
fresh_run_state_trusted() {
	RUN_TREE_UNTRUSTED=""
	[[ "$EUID" == "$SHCP_UPDATE_EXPECT_UID" ]] || return 0
	[[ -d "$SHCP_UPDATE_STATE_DIR" && ! -L "$SHCP_UPDATE_STATE_DIR" ]] \
		&& chmod 0711 -- "$SHCP_UPDATE_STATE_DIR" 2>/dev/null
	state_dir_trusted
}

run_tree_trusted() {
	local run_id="$1" p
	state_dir_trusted || return 1
	for p in "$RUNS_DIR" "${RUNS_DIR}/${run_id}" \
			"$(journal_path "$run_id")" "${RUNS_DIR}/${run_id}/manifest.json"; do
		if [[ "$p" == */manifest.json && ! -e "$p" && ! -L "$p" ]]; then
			continue
		fi
		if ! path_uid0_not_writable "$p"; then
			RUN_TREE_UNTRUSTED="${p} is not owned by uid ${SHCP_UPDATE_EXPECT_UID} or is group/other-writable"
			return 1
		fi
	done
	return 0
}

# run_tree_escape <run-id> <retry-command> — the operator's way out of a refusal.
# Printed with every run_tree_trusted refusal, because without it a genuinely
# broken tree is a dead end: the awaiting_reboot_stale remedy (`shcp-update
# resume`) hits this same gate, and `apply` will not start over any
# awaiting_reboot journal, validated or not — so abandoning a run has to take its
# directory out of runs/, not just drop the maintenance gate. Nothing here relaxes
# the gate.
run_tree_escape() {
	local run_id="$1" retry="$2"
	printf 'recovery: if this tree is genuine, restore root ownership and a mode without group/other write on that path, then re-run `%s`; if it is not, abandon the run as root: move %s out of %s (e.g. under /root), remove %s if present, then run `shcp-update maintenance --clear`' \
		"$retry" "${RUNS_DIR}/${run_id}" "$SHCP_UPDATE_STATE_DIR" "$AWAITING_REBOOT_MARKER"
}

cmd_resume() {
	local at_boot=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--at-boot) at_boot=1; shift ;;
			--json)    JSON_OUTPUT=1; shift ;;
			*) die "resume: unknown argument: $1" ;;
		esac
	done
	json_claim_stdout

	# Pick the run. A PARKED run (awaiting_reboot) is the deliberate cross-reboot
	# case and takes precedence; a CRASHED run (running) is the operator-recovery
	# case. --at-boot handles ONLY the parked case: a crashed run must never be
	# auto-continued as root on an unrelated boot — that stays operator/watchdog
	# territory, which is the entire point of keeping the two states distinct
	# (SC-532).
	local run_id parked=0
	run_id="$(latest_run_id_with_status awaiting_reboot)"
	if [[ -n "$run_id" ]]; then
		parked=1
	elif [[ $at_boot -eq 0 ]]; then
		run_id="$(latest_run_id_with_status running)"
	fi

	if [[ -z "$run_id" ]]; then
		if [[ $at_boot -eq 1 ]]; then
			# The ConditionPathExists marker fired but there is no parked run — a
			# stale marker, or one planted under the shcp-owned parent. Remove it
			# and exit CLEAN: a boot unit must never `die` (that reads as failed).
			rm -f "$AWAITING_REBOOT_MARKER"
			engine_log "resume --at-boot: no awaiting_reboot run; removed stale marker ${AWAITING_REBOOT_MARKER}"
			return 0
		fi
		die "no interrupted (status=running or awaiting_reboot) run to resume"
	fi

	# Lock FIRST — before touching the marker or the status. The security/drift
	# timers are Persistent and can fire at this same boot; if we lose that race
	# we must not have already deleted the marker and orphaned the parked run. The
	# resume unit also orders Before= those timers, but the flock is the real
	# guard.
	if ! acquire_lock; then
		if [[ $at_boot -eq 1 ]]; then
			engine_log "resume --at-boot: ${LOCK_BUSY_REASON:-the run lock could not be taken (SC-500 refusal, see above)}; leaving the marker for the next boot and exiting clean"
			return 0
		fi
		lock_refused_exit
	fi

	# Run-tree gate (run_tree_trusted; SC-532 for the parked marker,
	# SC-636 for the tree), for BOTH resumable states.
	# A parked run is not the only one a forged tree can steer: an operator's bare
	# `resume` of a crashed (running) run continues from the same journal and
	# manifest.json, which pick the release. The marker check is parked-only.
	#
	# BEFORE CURRENT_RUN_ID and the EXIT trap exist, not after: with the run id
	# set, engine_log appends to <run dir>/log (following a planted symlink, as
	# root), and on_engine_exit's journal_finish would write `failed` into the
	# very journal this gate just refused to trust.
	local ok=1 marker_run=""
	if [[ $parked -eq 1 && -e "$AWAITING_REBOOT_MARKER" ]]; then
		# Read it only once its owner is vouched for: a planted FIFO would
		# otherwise block jq, and the boot unit, until TimeoutStartSec.
		if path_uid0_not_writable "$AWAITING_REBOOT_MARKER"; then
			marker_run="$(jq -r '.run_id // empty' "$AWAITING_REBOOT_MARKER" 2>/dev/null || true)"
		fi
		if [[ "$marker_run" != "$run_id" ]]; then
			ok=0
			RUN_TREE_UNTRUSTED="the awaiting-reboot marker is not root-owned or does not name run ${run_id}"
		fi
	fi
	if [[ $ok -eq 1 ]] && ! run_tree_trusted "$run_id"; then
		ok=0
	fi
	if [[ $ok -ne 1 ]]; then
		engine_log "resume: refusing ${run_id} — ${RUN_TREE_UNTRUSTED} (fail closed). $(run_tree_escape "$run_id" "shcp-update resume")"
		# Do not delete a file whose ownership we could not vouch for.
		[[ $at_boot -eq 1 ]] && return 0
		die "run ${run_id} failed ownership validation — refusing to resume; nothing was changed"
	fi

	CURRENT_RUN_ID="$run_id"
	trap on_engine_exit EXIT

	if [[ $parked -eq 1 ]]; then
		# Boot-id gate (--at-boot only). The boot unit fires on the marker's
		# PRESENCE, which is not proof the box rebooted — a same-boot re-fire, a
		# replay, or a manual `systemctl start shcp-update-resume` all present a
		# surviving marker with the reboot still owed. park_for_reboot recorded the
		# boot_id it parked under; if the live boot_id still matches, the box has
		# NOT rebooted since, so the reboot the parked stage demanded has not
		# happened — refuse loudly and non-zero, leave the marker and the
		# awaiting_reboot status untouched so the next REAL boot (or the aged
		# `check` blocker) still catches the run. A manual `resume` is the
		# operator's deliberate override and is intentionally NOT gated. Two
		# refusals: an EMPTY parked id (below — not something park_for_reboot
		# writes, so not a run it parked), and both ids known and equal. An
		# unreadable CURRENT id is "cannot confirm": it comes from the kernel,
		# and stranding a legitimate run over a read error is worse than proceeding.
		if [[ $at_boot -eq 1 ]]; then
			local parked_boot_id cur_boot_id
			parked_boot_id="$(jq -r '.reboot.boot_id // empty' "$(journal_path "$run_id")" 2>/dev/null || true)"
			cur_boot_id="$(current_boot_id)"
			# An EMPTY parked boot_id fails CLOSED. park_for_reboot has recorded it
			# on every park since the field existed and 1.0 ships no engine that
			# does not, so a parked journal without one was not written by
			# park_for_reboot: damaged, or forged under the shcp-owned parent to
			# get a root resume without the reboot it is gated on. The boot unit
			# still exits 0 (SC-532: it never reads as a failed unit) and the
			# marker and status are left for the operator, whose bare `resume`
			# is the deliberate, ungated override. Only the CURRENT id keeps the
			# "cannot confirm" leniency: it comes from the kernel, not the journal.
			if [[ -z "$parked_boot_id" ]]; then
				engine_log "resume --at-boot: REFUSING run ${run_id} — its journal records no parked boot_id, so it was not parked by this engine (damaged or forged). Leaving the marker and awaiting_reboot status; an operator's \`shcp-update resume\` is the override. $(run_tree_escape "$run_id" "shcp-update resume")"
				return 0
			fi
			if [[ -n "$parked_boot_id" && -n "$cur_boot_id" && "$parked_boot_id" == "$cur_boot_id" ]]; then
				journal_update "$run_id" \
					'.reboot = ((.reboot // {}) + {boot_id_refused_at: $now, boot_id_refused: $bid})' \
					--arg now "$(now_utc)" --arg bid "$cur_boot_id" || true
				engine_log "resume --at-boot: REFUSING run ${run_id} — boot_id unchanged (${cur_boot_id}); the box has not rebooted since the run parked. Leaving the marker and awaiting_reboot status for the next real boot."
				die "resume --at-boot refused for run ${run_id}: boot_id unchanged — the box did not reboot"
			fi
			engine_log "resume --at-boot: boot_id confirms a reboot (parked=${parked_boot_id:-<unrecorded>} now=${cur_boot_id:-<unreadable>}); resuming run ${run_id}"
		fi

		# Consume the marker now that it is validated and the lock is held, so a
		# resume that itself re-crashes does not re-fire the boot unit with no
		# forward progress. A legitimate second reboot in the same run writes a
		# fresh marker via park_for_reboot.
		rm -f "$AWAITING_REBOOT_MARKER"

		# Back to `running` so the crash-recovery machinery (on_engine_exit,
		# stale_running_journal) covers the run again now that it is actively
		# executing rather than parked. The re-entry point is recomputed from the
		# journal (SC-420), never from the marker's diagnostic resume_stage.
		journal_update "$run_id" '.status = "running"'
		engine_log "resume: run ${run_id} was parked for reboot; resuming at stage $(journal_pending_stage "$run_id")"
	else
		engine_log "resuming interrupted run ${run_id} at stage $(journal_pending_stage "$run_id")"
	fi

	# A resumed run must not repeat the self-update hop dance.
	RESUMED_FROM_SELF_UPDATE=1
	ensure_run_policy_admitted "$run_id"
	# SC-614: the on-box product must still resolve before resuming stages (fail
	# closed); a reboot does not change which product this box is.
	require_product_resolved
	run_stages "$run_id" || true
	run_initramfs_verdict "$run_id" || true
	emit_run_result "$run_id"
}

latest_run_id_with_status() {
	local want="$1" d run_id
	[[ -d "$RUNS_DIR" ]] || return 0
	# Run dirs sort chronologically by name; newest last.
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		[[ -f "$(journal_path "$d")" ]] || continue
		# A running webmail journal is the webmail verbs' to recover. Handing it to
		# resume would re-enter run_stages on a run that has none, and apply's
		# interrupted-run guard would refuse every update until it was resolved.
		journal_is_webmail "$d" && continue
		if [[ "$(jq -r '.status' "$(journal_path "$d")" 2>/dev/null)" == "$want" ]]; then
			printf '%s\n' "$d"
			return 0
		fi
	done
	return 0
}

# The newest run an operator could still recover with `rollback`, as
# "<run-id><TAB><status>", or nothing. Walks newest-first and stops at the FIRST
# terminal verdict: a later converged run supersedes an older bad one, and
# offering a rollback of a superseded run would undo the good update on top of it.
latest_recoverable_run() {
	local d st
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		[[ -f "$(journal_path "$d")" ]] || continue
		# A webmail run changed no package, panel or database: it neither offers a
		# rollback nor supersedes the update run before it.
		journal_is_webmail "$d" && continue
		st="$(jq -r '.status // empty' "$(journal_path "$d")" 2>/dev/null || true)"
		# An unhealthy run with no regression (shcp-updater#181) converged: its
		# failing checks predate it, it was deliberately not rolled back, and
		# advising `rollback` would revert the patches it applied without fixing
		# anything. It supersedes older runs exactly as a healthy one does.
		if [[ "$st" == unhealthy ]] && jq -e '.health.regression == false' "$(journal_path "$d")" >/dev/null 2>&1; then
			return 0
		fi
		case "$st" in
			failed|unhealthy|rolled_back_partial)
				# A run that failed before touching the host (no apt/panel/db/restart
				# stage ever started, no package changed) has nothing to recover, and
				# running `rollback` on it only relabels a no-op run rolled_back. Look
				# further back instead: nothing in between changed the host.
				if [[ "$st" == failed ]] && ! jq -e '([.stages[]?.stage] | any(. == "apt" or . == "panel" or . == "db" or . == "restart"))
						or (((.packages.changed // []) | length) > 0)' "$(journal_path "$d")" >/dev/null 2>&1; then
					continue
				fi
				printf '%s\t%s\n' "$d" "$st"
				return 0 ;;
			healthy|noop|rolled_back)
				return 0 ;;   # superseded — nothing older is worth offering
			running|awaiting_reboot)
				# A `running` journal may have upgraded packages or migrated the
				# schema and been killed (TimeoutStartSec, power loss) before it
				# could say so; an `awaiting_reboot` journal is a run deliberately
				# paused mid-flight for a reboot. Either way it is the newest run
				# and in progress — offering a rollback of anything OLDER would
				# restore a pre-update snapshot over whatever it did. `running` is
				# surfaced through stale_running_journal and `awaiting_reboot`
				# through its own advisory/aged blocker; both are the honest things
				# to act on.
				return 0 ;;
			*) : ;;           # unrecognised: keep looking back
		esac
	done
	return 0
}

# latest_finished_security_run — the newest security-scope run with a terminal
# status, or nothing. An in-flight run has decided nothing yet. Feeds `check`'s
# package_index_refresh_failing.
latest_finished_security_run() {
	local d sc st
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		[[ -f "$(journal_path "$d")" ]] || continue
		sc=""; st=""
		read -r sc st < <(jq -r '"\(.scope // "-") \(.status // "-")"' "$(journal_path "$d")" 2>/dev/null) || true
		[[ "$sc" == security ]] || continue
		case "$st" in
			running|awaiting_reboot|-|"") continue ;;
		esac
		printf '%s\n' "$d"
		return 0
	done
	return 0
}

# latest_run_id — the newest update run, valid id, journal or not. Webmail runs
# are skipped: `status` and check's newest-run advisories describe the update
# state machine, and a webmail activation an hour later must not hide them.
# Always returns 0; an empty runs dir prints nothing.
latest_run_id() {
	local d
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		journal_is_webmail "$d" && continue
		printf '%s\n' "$d"
		return 0
	done
	return 0
}

# journal_is_webmail <run-id> — the run is a webmail activation (WM-6,
# shcp-updater#292). Those runs have no stages and are recovered only by the
# `webmail` verbs, so no update-run picker may choose one. A missing or
# unparseable journal is NOT webmail: it keeps the handling it had before.
journal_is_webmail() {
	[[ "$(jq -r '.scope // empty' "$(journal_path "$1")" 2>/dev/null)" == webmail ]]
}

# latest_unfinished_webmail_run — the newest webmail journal with no
# finished_at, or nothing. This is the shape the installer reads as "a
# transaction is pending" (rcm_webmail_transaction_pending), and the one the
# webmail verbs recover.
latest_unfinished_webmail_run() {
	local d
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		[[ -f "$(journal_path "$d")" ]] || continue
		journal_is_webmail "$d" || continue
		if jq -e '.finished_at == null' "$(journal_path "$d")" >/dev/null 2>&1; then
			printf '%s\n' "$d"
			return 0
		fi
	done
	return 0
}

# check_pending — `check --pending`: the OS packages an update run would apply,
# as JSON, for the panel's pending read-cache (shcp-base#1506). The panel used to
# run `apt-get -s upgrade` itself, which exits 127 on EL: its cache never filled,
# so `shcp:update:auto` declined every window and EL boxes never auto-patched.
# This is the engine's own work set — apt_target_pkgs, family-dispatched and
# blacklist-subtracted exactly as preflight computes it — so the panel's count
# and the run's work set cannot disagree. Read-only: no lock, no index refresh
# (the security timer and the run's preflight own that).
#
# Exits non-zero, printing nothing on stdout, when the package manager could
# not list: the panel keeps its previous cache rather than reading "0 pending".
check_pending() {
	osf_detect_family
	case "$OS_FAMILY" in
		deb|rpm) ;;
		*) die "check --pending: unsupported OS family '${OS_FAMILY}'" ;;
	esac
	local markdir all sec sec_targets failed=0
	markdir="$(mktemp -d)" || die "check --pending: cannot create a temporary directory"
	OSF_LISTING_FAIL_MARK="${markdir}/failed"
	OSF_PENDING_HOLDS_FILE="${markdir}/held"
	: > "$OSF_PENDING_HOLDS_FILE"
	all="$(apt_target_pkgs packages)" || failed=1
	sec_targets="$(apt_target_pkgs security)" || failed=1
	# Security work is carried as an exact version/NEVRA, while the pending API
	# reports bare package names. Map only against the already-blacklist-filtered
	# all-set so a hyphenated RPM name cannot be guessed by splitting its NEVRA.
	sec=""
	while IFS= read -r pending_name; do
		[[ -n "$pending_name" ]] || continue
		while IFS= read -r target; do
			case "$OS_FAMILY" in
				deb) [[ "$target" == "$pending_name="* ]] || continue ;;
				rpm) [[ "$target" == "$pending_name-"* ]] || continue ;;
			esac
			sec+="${pending_name}"$'\n'
			break
		done <<< "$sec_targets"
	done <<< "$all"
	[[ -e "$OSF_LISTING_FAIL_MARK" ]] && failed=1
	OSF_LISTING_FAIL_MARK=""
	# pipefail: a deb listing that failed also fails apt_target_pkgs itself; an
	# rpm one is caught only by the marker (its lister ends in `|| true`).
	if (( failed != 0 )); then
		rm -f -- "$OSF_LISTING_FAIL_MARK" "$OSF_PENDING_HOLDS_FILE"
		rmdir -- "$markdir"
		die "check --pending: the package manager could not list pending updates"
	fi
	local result
	result="$(jq -n --arg family "$OS_FAMILY" --arg all "$all" --arg sec "$sec" --rawfile held "$OSF_PENDING_HOLDS_FILE" '
		($sec | split("\n") | map(select(length > 0))) as $s
		| {family: $family,
		   packages: ($all | split("\n") | map(select(length > 0)) | unique
		     | map({name: ., security: (. as $n | $s | index($n) != null)})),
		   security: ($s | unique),
		   security_held: ($held | split("\n") | map(select(length > 0) | split("\t"))
		     | map({package: .[0], target: .[1], reason: .[2]}) | unique_by(.package, .target))}
		| .count = (.packages | length)
		| .security_count = ([.packages[] | select(.security)] | length)
		| .security_held_count = (.security_held | length)')"
	rm -f -- "$OSF_LISTING_FAIL_MARK" "$OSF_PENDING_HOLDS_FILE"
	rmdir -- "$markdir"
	OSF_PENDING_HOLDS_FILE=""
	if [[ "${CHECK_PENDING_TEXT:-0}" == 1 ]]; then
		jq -r '(.packages[] | "\(.name)\t\(if .security then "security" else "update" end)"),
			(.security_held[] | "HELD\t\(.package)\t\(.target)\t\(.reason)")' <<<"$result"
	else
		printf '%s\n' "$result"
	fi
}

# check_reboot — `check --reboot`: the live "does this box need a reboot right
# now" predicate as JSON `{reboot_required: true|false}`, for the panel's
# `update.reboot.pending` read-cache (shcp-base#773). deb reads the kernel/libc
# marker file, EL runs `needs-restarting -r` — the panel has NO marker file on
# EL and its confined web tier must never exec the package manager (SC-731), so
# it asks the engine, which already owns osf_reboot_required for both families
# (the same predicate a run journals at §4.4). Two implementations would drift,
# and EL's exit-code inversion (exit 0 ⇒ no reboot, anything else ⇒ reboot) is
# exactly where a re-derivation would get it wrong. Read-only: no lock, no
# package-index refresh — cheap enough for the panel's 15-minute reboot tick.
check_reboot() {
	osf_detect_family
	local rr; rr="$(osf_reboot_required)"
	[[ "$rr" == "true" ]] || rr="false"
	jq -n --argjson r "$rr" '{reboot_required: $r}'
}

# security_proof_find — "<run-id><TAB><iso8601>" of the most recent run journal
# carrying last_security_success, or nothing (return 1).
#
# The journal is where run_stages stamps that marker, and it is stamped only at
# the run's CONVERGENCE point — a run that ended unhealthy or was rolled back
# does not stamp, because it did not patch this host. `systemctl is-active` on
# the timer is not an acceptable substitute and is not used: measured on a live
# box, shcp-update-security.timer was enabled AND active with LastTriggerUSec=
# empty. Enabled is a schedule; this is a receipt.
#
# Empty means "I cannot show the engine has patched this host", which is not the
# same as "it hasn't" — cmd_check's SC-319 staleness advisory reads either as a
# reason to look, never as proof of a lapse.
security_proof_find() {
	[[ -d "$RUNS_DIR" ]] || return 1
	local d jf ts best_id="" best_ts=""
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		jf="$(journal_path "$d")"
		[[ -f "$jf" ]] || continue
		ts="$(jq -r '.last_security_success // empty' "$jf" 2>/dev/null || true)"
		[[ -n "$ts" ]] || continue
		# ISO-8601 UTC sorts lexicographically in chronological order.
		if [[ -z "$best_ts" || "$ts" > "$best_ts" ]]; then
			best_ts="$ts"
			best_id="$d"
		fi
	done
	[[ -n "$best_ts" ]] || return 1
	printf '%s\t%s' "$best_id" "$best_ts"
}

# oldest_run_started_at — the started_at of the EARLIEST run journal on this box,
# or nothing when there are no runs. Run ids are timestamp-prefixed (run_id_valid),
# so ascending id order is chronological and the first one carrying a parseable
# journal is the oldest evidence the engine has managed this host. Mirrors
# security_proof_find's own walk of RUNS_DIR, one direction reversed. Consumed by
# cmd_check's SC-319 zero-baseline arm (updater#106) to age a host that has never
# stamped last_security_success: a host managed for longer than the staleness
# threshold with no proof at all is the blind spot; a host with no runs is a fresh
# install and has no age to be stale against.
oldest_run_started_at() {
	[[ -d "$RUNS_DIR" ]] || return 0
	local d jf at
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort); do
		run_id_valid "$d" || continue
		jf="$(journal_path "$d")"
		[[ -f "$jf" ]] || continue
		at="$(jq -r '.started_at // empty' "$jf" 2>/dev/null || true)"
		[[ -n "$at" ]] && { printf '%s' "$at"; return 0; }
	done
	return 0
}

cmd_check() {
	local blockers_mode=0 pending_mode=0 reboot_mode=0 CHECK_PENDING_TEXT=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--blockers) blockers_mode=1; shift ;;
			--pending)  pending_mode=1; shift ;;
			--text)     CHECK_PENDING_TEXT=1; shift ;;
			--reboot)   reboot_mode=1; shift ;;
			--json)     JSON_OUTPUT=1; shift ;;
			*) die "check: unknown argument: $1" ;;
		esac
	done
	: "$blockers_mode"  # check without --pending/--reboot always emits the blocker view.
	if (( pending_mode )); then
		(( blockers_mode == 0 )) || die "check: --pending and --blockers are separate views"
		(( reboot_mode == 0 )) || die "check: --pending and --reboot are separate views"
		check_pending
		return
	fi
	(( CHECK_PENDING_TEXT == 0 )) || die "check: --text requires --pending"
	if (( reboot_mode )); then
		(( blockers_mode == 0 )) || die "check: --reboot and --blockers are separate views"
		check_reboot
		return
	fi

	# Read-only preflight: structured JSON blocker list — the UI's "why can't
	# I update" surface. Never takes the lock, never mutates state.
	#
	# `advisories` is a SEPARATE array from `blockers` and the distinction is
	# load-bearing: a blocker is a reason an update cannot proceed, an advisory
	# is something the operator should know about. rollback_available lived in
	# `blockers` and blocks nothing — a name that invites a consumer to treat one
	# bad run as a permanent update block.
	local -a blockers=()
	local -a advisories=()

	# Several checks below are family-specific. cmd_check used to be the only
	# top-level verb that never resolved the family, so OS_FAMILY was "" and
	# every `case "$OS_FAMILY"` here fell through to nothing.
	osf_detect_family

	# SC-614: resolve the product before deriving any product-specific path
	# (keyring, manifest URL). check is offline -- it does not fetch -- but it
	# must not report the WRONG product's keyring, and an unresolvable product is
	# itself a blocker the operator must fix before any update can run.
	if ! resolve_product; then
		blockers+=("$(jq -nc --arg d "$PRODUCT_RESOLVE_ERROR" '{id: "product_unresolved", detail: $d}')")
	fi
	local live_release
	live_release="$(panel_link_target)"
	if [[ -n "$live_release" ]] && ! managed_worker_installed_check "$live_release"; then
		blockers+=("$(jq -nc --arg id "$REQUIRED_UNIT_BLOCKER" --arg d "$REQUIRED_UNIT_DETAIL" \
			'{id: $id, detail: $d}')")
	fi

	# The panel half of the update is silently skippable and nothing reported
	# it. manifest_fetch fails closed when the release keyring is unreadable —
	# installer-owned state, i.e. exactly the state §4.13 establishes is frozen
	# at install time and never delivered to an existing box. On the default
	# scopes preflight then DEGRADES rather than failing: it logs "manifest
	# unavailable — continuing without a panel candidate", records
	# panel_candidate: null, and the run completes `healthy`. The log it writes
	# that to is 0600 and SC-416 forbids the panel opening it, so a box can
	# patch OS packages nightly, report healthy every time, and never install a
	# panel release again — including a `critical: true` one under AD-12.
	#
	# Checked here because `check` is read-only and offline: keyring
	# READABILITY is a local fact and the expensive half (fetch + gpgv) is not
	# attempted. A blocker, not an advisory: the panel half genuinely cannot
	# proceed, and the fix is an operator action.
	if [[ ! -r "$RELEASE_KEYRING" ]]; then
		# The remedy names an operator-performable action, NOT "re-run the
		# installer" (impossible on an installed box; the export only landed in
		# the 0.0.43 installer). Re-materialise the pinned key the install stub
		# imported into the root gpg keyring. (shcp-updater#30.)
		blockers+=("$(jq -nc --arg k "$RELEASE_KEYRING" --arg f "$RELEASE_FPR" '{id: "manifest_unverifiable",
			detail: ("the release keyring \($k) is missing or unreadable — the signed manifest cannot be verified, so no panel release can be installed. The installer writes it (pinned primary \($f)) on every box, so this box is tampered or damaged: reinstall.")}')")
	fi

	# Preflight retains the signed manifest it used. Check this local copy so a
	# failed panel attempt remains explainable without a network fetch in check.
	local offer_run offer_manifest updater_detail
	for offer_run in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$offer_run" || continue
		offer_manifest="${RUNS_DIR}/${offer_run}/manifest.json"
		[[ -r "$offer_manifest" ]] || continue
		updater_detail="$(panel_updater_requirement_detail "$offer_manifest" "$(panel_current_version)" || true)"
		if [[ -n "$updater_detail" ]]; then
			blockers+=("$(jq -nc --arg d "$updater_detail" '{id: "panel_updater_too_old", detail: $d}')")
		fi
		break
	done

	# An absent restart detector makes restart_required permanently empty, which
	# is indistinguishable from a clean run. Advisory rather than blocker: OS
	# packages still install correctly, but nothing will be restarted or even
	# reported, so a patched CVE stays live in every long-running daemon.
	if ! osf_restart_detection_available; then
		advisories+=("$(jq -nc --arg d "$(osf_restart_detector)" '{id: "restart_detection_unavailable",
			detail: ("\($d) is not installed — services still using deleted libraries after an upgrade will be neither restarted nor reported")}')")
	fi
	# The same binary is the kernel/libc reboot verdict (needrestart's KSTA on
	# deb, `needs-restarting -r` on rpm). Without it reboot_required is recorded
	# false on every run — never true (an rpm box used to read the missing binary
	# as "reboot required" and fired the HIGH notification nightly).
	if ! osf_reboot_detection_available; then
		advisories+=("$(jq -nc --arg d "$(osf_restart_detector)" --arg f "$OS_FAMILY" '{id: "reboot_detection_unavailable",
			detail: ("\($d) is not installed — whether a kernel/libc update needs a reboot cannot be determined on this host; reboot_required is recorded false, not measured" + (if $f == "deb" then " (only the /var/run/reboot-required marker is consulted, which no 1.0 Debian box writes)" else "" end))}')")
	fi

	# The operator's update.blacklist could not be read from the panel DB: every
	# run subtracts NOTHING and upgrades the excluded packages (fail toward
	# patching, AD-6 — but never silently).
	if ! blacklist_read >/dev/null 2>&1; then
		advisories+=("$(jq -nc --arg d "$BLACKLIST_READ_DETAIL" --arg db "$SHCP_UPDATE_DB" '{id: "blacklist_unreadable",
			detail: ("update.blacklist could not be read from \($db) (\($d)) — no package is excluded from an update run until the panel database is readable again")}')")
	fi

	# The newest run's restart stage could not converge the SC-050 auth-log
	# protection (the release had the assets; the host refused them). The run
	# was kept — a log-rotation guard is not worth re-exposing a patched CVE —
	# so the fact rides here until a later run converges it.
	local al_run al_jf
	al_run="$(latest_run_id || true)"   # its grep exits 1 on an empty runs dir
	if [[ -n "$al_run" ]]; then
		al_jf="$(journal_path "$al_run")"
		if [[ -f "$al_jf" ]] && jq -e '[.stages[]? | select(.stage == "restart") | .result.authlog.status] | last == "failed"' "$al_jf" >/dev/null 2>&1; then
			advisories+=("$(jq -c --arg r "$al_run" '{id: "authlog_protection_unconverged", run_id: $r,
				detail: ("the newest run (\($r)) could not converge the auth-log protection (SC-050) and continued degraded: \(([.stages[]? | select(.stage == "restart") | .result.authlog.detail] | last) // "no detail recorded")")}' "$al_jf")")
		fi
	fi

	# Probed ONCE and kept: lock_is_held answers held (0), free (1) or unknown
	# (2), and the stale/parked checks below key off the same answer. Unknown —
	# the file could not be opened, e.g. a non-root `check` — is NOT held: it
	# used to be, which both raised a lock_held blocker over nothing and hid a
	# stale running journal behind it.
	local lock_state=0
	lock_is_held || lock_state=$?
	if (( lock_state == 0 )); then
		blockers+=("$(jq -nc '{id: "lock_held",
			detail: "another shcp-update run holds the lock"}')")
	elif (( lock_state == 2 )); then
		log "check: cannot open the run lock ${SHCP_UPDATE_LOCK} to probe it (not root, or its directory is missing) — treating it as not held"
	fi

	# An interrupted webmail activation (WM-6). Not a blocker: it holds webmail's
	# own ingress marker, not the panel's, and an update run does not touch it.
	# The next `webmail tick` recovers it; the advisory says so in case that
	# timer is not running.
	local wm_pending
	wm_pending="$(latest_unfinished_webmail_run)"
	# The link and the distribution-mode record move together; out of step, no
	# trigger acts (A-8), so say so. Only root can read the 0600 mode record.
	if [[ -L "$WEBMAIL_LINK" ]] && { [[ ! -e "$WEBMAIL_MODE_FILE" ]] || [[ -r "$WEBMAIL_MODE_FILE" ]]; }; then
		local wm_mode=legacy wm_kind=legacy
		webmail_mode_package && wm_mode=package
		webmail_live_release >/dev/null 2>&1 && wm_kind=package
		if [[ "$wm_mode" != "$wm_kind" && -z "$wm_pending" ]]; then
			advisories+=("$(jq -nc --arg m "$wm_mode" --arg k "$wm_kind" '{id: "webmail_mode_link_mismatch",
				detail: ("the webmail distribution-mode record says \($m) but /var/www/webmail serves a \($k) tree; no webmail trigger acts until an operator reconciles them")}')")
		fi
	fi
	if [[ -n "$wm_pending" ]] && (( lock_state != 0 )); then
		advisories+=("$(jq -c --arg r "$wm_pending" '{id: "webmail_transaction_pending", run_id: $r,
			phase: (.webmail.phase // null),
			detail: ("webmail activation \($r) stopped at phase \(.webmail.phase // "unknown") and webmail stays in maintenance until it is recovered. Run: shcp-update webmail recover")}' "$(journal_path "$wm_pending")")")
	fi

	local stale
	stale="$(latest_run_id_with_status running)"
	if [[ -n "$stale" ]] && (( lock_state != 0 )); then
		# age_seconds so the PANEL can apply its own threshold. Without it this
		# blocker fires in the millisecond between one engine exiting and the next
		# taking the lock — a false positive by construction. The blocker `id` is
		# deliberately unchanged: the panel may key on it.
		local last_at age=null t0 t1
		last_at="$(jq -r '[.stages[]?.started_at] | last // empty' \
			"$(journal_path "$stale")" 2>/dev/null || true)"
		if [[ -n "$last_at" ]]; then
			t0="$(date -u -d "$last_at" +%s 2>/dev/null || true)"
			t1="$(date -u +%s)"
			[[ "$t0" =~ ^[0-9]+$ ]] && age=$(( t1 - t0 ))
		fi
		blockers+=("$(jq -nc --arg r "$stale" --argjson age "$age" \
			'{id: "stale_running_journal", run_id: $r, age_seconds: $age,
			detail: ("run \($r) is journaled running but no engine holds the lock — resume or investigate")}')")
	fi

	# A run PARKED for a deliberate reboot (cross-reboot resume, shcp-build#97).
	# This is EXPECTED while the box reboots and shcp-update-resume.service brings
	# it back — so it is an advisory, not a blocker, and deliberately NOT the
	# stale_running_journal id (that means "crashed", and a parked run is not).
	# But it must not sit forever: past SHCP_UPDATE_REBOOT_STALE_AFTER the box
	# rebooted and never resumed (boot unit disabled?), or never rebooted at all
	# (reboot command failed) — that IS a blocker, with the remedy named, so a
	# panel stuck in maintenance is not a silent dead end.
	local parked p_age=null p_at
	parked="$(latest_run_id_with_status awaiting_reboot)"
	if [[ -n "$parked" ]] && (( lock_state != 0 )); then
		p_at="$(jq -r '.reboot.awaiting_at // empty' "$(journal_path "$parked")" 2>/dev/null || true)"
		if [[ -n "$p_at" ]]; then
			local pt0 pt1
			pt0="$(date -u -d "$p_at" +%s 2>/dev/null || true)"
			pt1="$(date -u +%s)"
			[[ "$pt0" =~ ^[0-9]+$ ]] && p_age=$(( pt1 - pt0 ))
		fi
		if [[ "$p_age" != "null" ]] && (( p_age > SHCP_UPDATE_REBOOT_STALE_AFTER )); then
			blockers+=("$(jq -nc --arg r "$parked" --argjson age "$p_age" --argjson after "$SHCP_UPDATE_REBOOT_STALE_AFTER" \
				'{id: "awaiting_reboot_stale", run_id: $r, age_seconds: $age, stale_after_seconds: $after,
				detail: ("run \($r) has been awaiting a reboot for \($age)s (> \($after)s) and has not resumed — the box may not have rebooted, or shcp-update-resume.service is disabled. Run: shcp-update resume")}')")
		else
			advisories+=("$(jq -nc --arg r "$parked" --argjson age "$p_age" \
				'{id: "awaiting_reboot", run_id: $r, age_seconds: $age,
				detail: ("run \($r) is paused for a deliberate reboot and will resume automatically at boot")}')")
		fi
	fi

	# The single most important "why can't I update" fact, and check never
	# reported it. After UPD-4 a failed run leaves the gate up BY DESIGN
	# (SC-318 fail-closed), and nothing surfaced that through the blocker view —
	# so the operator saw a panel in maintenance with no explanation of what put
	# it there or how to get out.
	if [[ -f "$MAINT_FLAG" ]]; then
		local m_owner m_at
		m_owner="$(jq -r '.run_id // "unknown"' "$MAINT_FLAG" 2>/dev/null || printf 'unknown')"
		m_at="$(jq -r '.timestamp // "an unrecorded time"' "$MAINT_FLAG" 2>/dev/null || printf 'unknown')"
		blockers+=("$(jq -nc --arg o "$m_owner" --arg a "$m_at" \
			'{id: "maintenance_flag_set",
			  run_id: (if $o == "operator" or $o == "unknown" then null else $o end),
			  detail: ("maintenance gate up since \($a), owned by \($o) — complete the run, roll it back, or clear it with: shcp-update maintenance --clear")}')")
	fi

	# Without this, SC-055's "rollback is one command" is true but undiscoverable:
	# the operator has to already know both the verb and the run id.
	local recoverable
	recoverable="$(latest_recoverable_run)"
	if [[ -n "$recoverable" ]]; then
		local rid rst
		rid="${recoverable%%$'\t'*}"
		rst="${recoverable#*$'\t'}"
		advisories+=("$(jq -nc --arg r "$rid" --arg s "$rst" \
			'{id: "rollback_available", run_id: $r,
			  detail: ("run \($r) ended \($s); recover with: shcp-update rollback \($r)")}')")
	fi

	# Coarse disk check (bytes free where runs/journals/snapshots land).
	# Artifact-sized preflight math is UPD-2/3.
	local free_kb
	free_kb="$(df -Pk "$SHCP_UPDATE_STATE_DIR" 2>/dev/null | awk 'NR==2 {print $4}' || echo "")"
	if [[ -n "$free_kb" && "$free_kb" =~ ^[0-9]+$ && "$free_kb" -lt 512000 ]]; then
		blockers+=("$(jq -nc --arg kb "$free_kb" '{id: "disk_low",
			detail: ("only \($kb) KiB free under the update state dir")}')")
	fi

	# Two appliers on one host (AD-6). Same predicate preflight uses (these two
	# callers used to disagree, and the disagreement WAS the bug) — now
	# family-dispatched: deb reports unattended_upgrades_active, rpm
	# dnf_automatic_active, each with its own remedy. The id is distinct per family
	# so the panel phrases it and offers the deb-only retire button correctly.
	if second_applier_armed; then
		blockers+=("$(jq -nc --arg id "$(second_applier_id)" --arg d "$(second_applier_detail)" \
			'{id: $id, detail: $d}')")
	fi

	if dpkg_interrupted; then
		blockers+=("$(jq -nc '{id: "dpkg_interrupted",
			detail: "a dpkg transaction was interrupted — run `dpkg --configure -a`; every apt operation fails until then"}')")
	fi
	if rpm_transaction_interrupted; then
		blockers+=("$(jq -nc --arg d "$(rpm_transaction_interrupted_detail)" '{id: "rpm_transaction_interrupted", detail: $d}')")
	fi
	if series_pointer_mismatch; then
		blockers+=("$(jq -nc --arg d "$SERIES_POINTER_DETAIL" '{id: "series_pointer_mismatch", scope: "non-security", detail: $d}')")
	fi

	# series_packages_held (updater#159): the engine's series preflight refuses a
	# series run while an apt hold / dnf versionlock / dnf.conf exclude pins an
	# installed SHCP package. It blocks series runs only (`scope: "series"`); a
	# routine run is unaffected. Surfaced here so the panel shows the engine's own
	# answer instead of deriving one. Offline: reads the local package index only.
	if ! series_held_shcp_check; then
		blockers+=("$(jq -nc --arg d "$(series_held_detail)" --arg p "$SERIES_HELD_PKGS" --arg o "$SERIES_HELD_ORIGIN" \
			'{id: "series_packages_held", scope: "series", origin: $o,
			  packages: ($p | split("\n") | map(select(length > 0))), detail: $d}')")
	elif [[ "$SERIES_HELD_ORIGIN" == "uncached" ]]; then
		# shcp-updater#179: right after `dnf clean all` this used to report the box
		# blocked ("unconfirmed") until the cache refilled. An advisory instead: the
		# evaluation did not happen, and a series run re-evaluates at preflight.
		advisories+=("$(jq -nc '{id: "series_packages_held_unverified", scope: "series",
			detail: "dnf.conf or the versionlock list carries hold entries, and the SHCP repo metadata is not cached, so whether they pin an SHCP package was not evaluated. A series upgrade evaluates them at preflight against the target series; run `dnf makecache` to evaluate them here."}')")
	fi

	# series_baseos_conflict (shcp-updater#238): the resolvability twin of
	# series_packages_held. A scope=series apply upgrades the whole upgradable set,
	# so one unrelated base-OS dependency conflict (measured: an AlmaLinux openssh
	# EVR the depsolver cannot install both) would sink the apt stage mid-run.
	# Surfaced here (series-scoped) so the panel names it before offering the run.
	# Offline-safe: passes 'cacheonly' (dnf -C / apt's local cache, no fetch), and
	# a resolver that cannot answer degrades to no blocker rather than a false one.
	if series_baseos_resolvable cacheonly; then :; else
		blockers+=("$(jq -nc --arg d "$SERIES_BASEOS_RESOLVE_DETAIL" --arg p "$SERIES_BASEOS_RESOLVE_PKGS" \
			'{id: "series_baseos_conflict", scope: "series",
			  packages: ($p | split("\n") | map(select(length > 0))), detail: $d}')")
	fi

	# packages_baseos_conflict (shcp-updater#240): the routine twin of
	# series_baseos_conflict. A routine scope=packages (or scope=all) apply upgrades
	# the whole upgradable set too, so the same unrelated base-OS conflict would sink
	# the routine apt/dnf stage mid-run. Surfaced here (packages-scoped) so the panel
	# names it before offering a routine run. Offline-safe: passes 'cacheonly' (dnf
	# -C / apt's local cache, no fetch), and a resolver that cannot answer degrades
	# to no blocker rather than a false one.
	if packages_baseos_resolvable cacheonly; then :; else
		blockers+=("$(jq -nc --arg d "$PACKAGES_BASEOS_RESOLVE_DETAIL" --arg p "$PACKAGES_BASEOS_RESOLVE_PKGS" \
			'{id: "packages_baseos_conflict", scope: "packages",
			  packages: ($p | split("\n") | map(select(length > 0))), detail: $d}')")
	fi

	# Both families (the id keeps its historical name: the panel matches on it).
	local idx_age
	idx_age="$(index_age_seconds 2>/dev/null || true)"
	if [[ -n "$idx_age" && "$idx_age" -gt "$SHCP_APT_INDEX_MAX_AGE" ]]; then
		blockers+=("$(jq -nc --argjson a "$idx_age" '{id: "apt_index_stale",
			detail: ("the package index'"'"'s most recent refresh across every source was \($a / 86400 | floor) day(s) ago — the pending counts are computed from it. This is the freshest signal, so a single source that keeps failing to refresh does not move it and is reported separately as package_index_refresh_failing")}')")
	fi

	# security_lane_downgrade_required (shcp-updater#241): the security apply set —
	# already filtered to strictly-newer builds (SC-319) — cannot be applied because
	# satisfying it would force apt to DOWNGRADE a dependency, which osf_apply refuses
	# (no --allow-downgrades; a security run never moves a package backward). Left
	# unnamed this stalls shcp-update-security.timer nightly with a generic "apt apply
	# failed". Surfaced here, offline (apt-get -s), so the panel names the reason and
	# the packages before the timer run fails. deb-only: the -y downgrade refusal is an
	# apt behaviour (dnf `update` is one-directional and simply never downgrades).
	if [[ "$OS_FAMILY" == "deb" ]]; then
		local sec_down
		sec_down="$(security_apply_downgrades 2>/dev/null | sort -u | paste -sd' ' - || true)"
		if [[ -n "$sec_down" ]]; then
			blockers+=("$(jq -nc --arg p "$sec_down" '{id: "security_lane_downgrade_required", scope: "security",
				packages: ($p | split(" ") | map(select(length > 0))),
				detail: ("the pending security update cannot be applied: apt would downgrade " + $p + ". A security run never moves a package backward (no --allow-downgrades), so the lane refuses the batch. The security channel is serving a dependency build older than the one installed; the OS security suite must publish a build not older than installed, or a pin holding the newer build must be resolved.")}')")
		fi
	fi
	local new_shcp_dep
	if new_shcp_dep="$(security_new_shcp_deps_pending)"; then
		if [[ -n "$new_shcp_dep" ]]; then
			blockers+=("$(jq -nc --arg p "${new_shcp_dep%%$'\n'*}" '{id: "security_new_shcp_dependency", scope: "security", packages: [$p], detail: ("the pending security update would install " + $p + " from the ordinary SHCP suite; use an admin-approved update")}')")
		fi
	fi

	if [[ -e "$REQUEST_FILE" ]] && ! validate_request_file "$REQUEST_FILE"; then
		blockers+=("$(jq -nc --arg e "$REQ_ERROR" '{id: "request_invalid", detail: $e}')")
	fi

	# SC-319: "the engine's own check verb raise[s CRITICAL] when [staleness]
	# exceeds the threshold (3 days)" — the panel's UpdateCheckCommand already
	# sweeps this from the imported journals, but an operator running the CLI
	# verb directly (no panel, no cron) saw nothing. An advisory, not a
	# blocker: it names a host that has stopped patching, it does not stop
	# this verb or an apply from proceeding — running one is the fix.
	# SC-319: a security run whose package index could not be fully refreshed still
	# applies what it can, but it is no proof the host is patched and it does not
	# stamp. Say so the same day, naming the source, instead of leaving the operator
	# to meet it three days later as security_patching_stale. A later clean security
	# run clears it.
	local idx_run
	idx_run="$(latest_finished_security_run)"
	if [[ -n "$idx_run" ]] \
			&& jq -e '.index_refresh_failed == true' "$(journal_path "$idx_run")" >/dev/null 2>&1; then
		advisories+=("$(jq -c --arg r "$idx_run" '{id: "package_index_refresh_failing", run_id: $r,
			detail: ("the newest security run (\($r)) could not refresh every package source, so it did not count as proof the host is patched: \(.index_refresh_detail // "no detail recorded") (fix or remove the failing source)")}' \
			"$(journal_path "$idx_run")")")
	fi

	# #321: the newest run held packages back rather than let their upgrade remove a
	# protected package. Each stays pending until the conflict is resolved.
	local held_run
	held_run="$(latest_run_id)"
	if [[ -n "$held_run" ]] \
			&& jq -e '(.packages.held_back // []) | length > 0' "$(journal_path "$held_run")" >/dev/null 2>&1; then
		advisories+=("$(jq -c --arg r "$held_run" '{id: "packages_held_back", run_id: $r,
			packages: [.packages.held_back[].pkg],
			detail: ("run \($r) held back " + ([.packages.held_back[] | "\(.pkg) (\(.reason))"] | join("; ")))}' \
			"$(journal_path "$held_run")")")
	fi

	# The update ledger's own staleness and last result (SC-1041).
	local lad
	while IFS= read -r lad; do [[ -n "$lad" ]] && advisories+=("$lad"); done < <(ledger_check_advisories)

	local sec_proof
	if sec_proof="$(security_proof_find)"; then
		local sec_ts sec_epoch sec_age
		sec_ts="${sec_proof#*$'\t'}"
		sec_epoch="$(date -u -d "$sec_ts" +%s 2>/dev/null || true)"
		if [[ "$sec_epoch" =~ ^[0-9]+$ ]]; then
			sec_age=$(( $(date -u +%s) - sec_epoch ))
			if (( sec_age > SHCP_SECURITY_STALE_SECONDS )); then
				advisories+=("$(jq -nc --arg at "$sec_ts" --argjson age "$sec_age" \
					'{id: "security_patching_stale", last_security_success: $at, age_seconds: $age,
					  detail: ("no security-scope run has converged in \($age / 86400 | floor) day(s) — check shcp-update-security.timer and the engine journal")}')")
			fi
		fi
	else
		# SC-319 ZERO-BASELINE (updater#106). security_proof_find returns 1 when NO
		# run has ever stamped last_security_success, so the aged-out arm above —
		# which can only escalate an EXISTING proof timestamp — stayed silent for a
		# box that has never converged a security run at all. That is precisely the
		# box that most needs the advisory: it is not "recently patched", it has
		# never patched. Age it instead from how long the engine has managed this
		# host — the started_at of its OLDEST run journal, the same journal
		# timestamp the stale_running_journal blocker ages above — and raise the
		# SAME advisory (same id, same advisory severity) once that exceeds the
		# staleness threshold. A box with NO runs at all is a fresh install with
		# nothing yet to be stale about: no age to measure, stay silent, never
		# false-alarm on a box that was only just brought up.
		local first_at first_epoch host_age
		first_at="$(oldest_run_started_at)"
		if [[ -n "$first_at" ]]; then
			first_epoch="$(date -u -d "$first_at" +%s 2>/dev/null || true)"
			if [[ "$first_epoch" =~ ^[0-9]+$ ]]; then
				host_age=$(( $(date -u +%s) - first_epoch ))
				if (( host_age > SHCP_SECURITY_STALE_SECONDS )); then
					advisories+=("$(jq -nc --argjson age "$host_age" \
						'{id: "security_patching_stale", last_security_success: null, age_seconds: $age,
						  detail: ("no security-scope run has EVER converged on this host, under update management for \($age / 86400 | floor) day(s) — check shcp-update-security.timer and the engine journal")}')")
				fi
			fi
		fi
	fi

	# EP-2 (#138, SC-633): a manifest fetch that has been failing longer than the
	# threshold is its OWN loud signal, DISTINCT from SC-319. SC-319 ages
	# last_security_success (apt-origin) and says nothing about the manifest
	# endpoint; an unreachable/mis-certed manifest leaves the panel half silently
	# skippable (the degrade path in cmd_preflight) and the withdrawal lever
	# blind. Aged from failing_since in the manifest-fetch state file so an
	# operator can tell "behind on security patches" (security_patching_stale)
	# from "this box cannot see the update manifest at all"
	# (manifest_fetch_failing). Advisory, not blocker: a dead manifest must not
	# stop apt security patching, exactly as SC-319 is an advisory.
	local mf_since mf_epoch mf_age
	mf_since="$(jq -r '.failing_since // empty' "$MANIFEST_FETCH_STATE_FILE" 2>/dev/null || true)"
	if [[ -n "$mf_since" ]]; then
		mf_epoch="$(date -u -d "$mf_since" +%s 2>/dev/null || true)"
		if [[ "$mf_epoch" =~ ^[0-9]+$ ]]; then
			mf_age=$(( $(date -u +%s) - mf_epoch ))
			if (( mf_age > SHCP_MANIFEST_FETCH_STALE_SECONDS )); then
				advisories+=("$(jq -nc --arg at "$mf_since" --argjson age "$mf_age" \
					'{id: "manifest_fetch_failing", failing_since: $at, age_seconds: $age,
					  detail: ("the versions manifest has been unfetchable or unverifiable for \($age / 86400 | floor) day(s) — panel-update checks and the emergency withdrawal lever are blind until it recovers. Check network egress to the manifest host, the pinned release keyring, and the manifest signature")}')")
			fi
		fi
	fi

	# SC-783 (shcp-updater#273): a box whose initramfs carries no modules runs
	# normally and dies at its next reboot, and a between-runs reboot is exactly
	# when nobody is looking at a run journal. So the verdict of verify-initramfs
	# rides on every check, as `initramfs`, and a broken image is an advisory that
	# says "do not reboot". Advisory, not blocker: the next update run is the
	# repair, so it must not be refused. The panel's units hide the module tree,
	# so from there this reports the last audit of each image, and an image that
	# changed since is unverified rather than assumed good.
	local ir_mode=check ir_json INITRAMFS_CACHE_READONLY=1
	initramfs_modules_hidden && ir_mode=cached
	initramfs_ensure "$ir_mode" 2>/dev/null || true
	ir_json="$(jq -c --arg m "$ir_mode" --arg e "$INITRAMFS_ERROR" \
		'. + {source: (if $m == "cached" then "last-audit" else "audit" end),
		      error: (if $e == "" then null else $e end)}' <<<"$INITRAMFS_RESULT")"
	if jq -e '.broken | length > 0' <<<"$ir_json" >/dev/null; then
		advisories+=("$(jq -c '{id: "initramfs_broken", kernels: .broken,
			detail: ("DO NOT REBOOT: the initramfs of kernel " + (.broken | join(", ")) + " carries no kernel modules, so the box will not mount its root filesystem when it boots that kernel. Repair it first: shcp-update verify-initramfs --repair")}' <<<"$ir_json")")
	fi
	if jq -e '.unverified | length > 0' <<<"$ir_json" >/dev/null; then
		advisories+=("$(jq -c '{id: "initramfs_unverified", kernels: .unverified,
			detail: ("the initramfs of kernel " + (.unverified | join(", ")) + (if .source == "last-audit"
				then " has not been audited since it last changed. The next update run audits it; to check now, run as root: shcp-update verify-initramfs"
				else " could not be audited: neither lsinitramfs nor lsinitrd is installed" end))}' <<<"$ir_json")")
	fi

	local blockers_json advisories_json
	blockers_json="$(printf '%s\n' "${blockers[@]+"${blockers[@]}"}" \
		| jq -s -c 'map(select(type == "object"))')"
	advisories_json="$(printf '%s\n' "${advisories[@]+"${advisories[@]}"}" \
		| jq -s -c 'map(select(type == "object"))')"
	jq -n --argjson b "$blockers_json" --argjson a "$advisories_json" --argjson i "$ir_json" \
		'{blockers: $b, advisories: $a, initramfs: $i}'
}

cmd_rollback() {
	local run_id="" yes=0 force_superseded=0 force_below_floor=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--yes)  yes=1; shift ;;
			--force-superseded) force_superseded=1; shift ;;
			--force-below-floor) force_below_floor=1; shift ;;
			--json) JSON_OUTPUT=1; shift ;;
			-*) die "rollback: unknown argument: $1" ;;
			*)  [[ -z "$run_id" ]] || die "rollback: multiple run ids"
			    run_id="$1"; shift ;;
		esac
	done
	json_claim_stdout
	[[ -n "$run_id" ]] || die "usage: shcp-update rollback <run-id> [--yes]"
	run_id_valid "$run_id" || die "invalid run id '${run_id}'"
	[[ -f "$(journal_path "$run_id")" ]] || die "no journal for run ${run_id}"
	# A webmail run restores its own snapshots through its own verb; this path
	# would downgrade packages and flip the panel for a run that touched neither.
	if journal_is_webmail "$run_id"; then
		die "run ${run_id} is a webmail activation — nothing was changed; use: shcp-update webmail recover"
	fi
	# SC-636: the PANEL starts this verb
	# (shcp-update-rollback@<run-id>.service), and what it downgrades, flips back
	# to and restores is read from this run's journal. Same gate as resume,
	# before the lock and the EXIT trap.
	if ! run_tree_trusted "$run_id"; then
		engine_log "rollback: refusing run ${run_id} — ${RUN_TREE_UNTRUSTED} (fail closed). $(run_tree_escape "$run_id" "shcp-update rollback ${run_id}")"
		die "run ${run_id} failed ownership validation — refusing to roll back; nothing was changed"
	fi

	# SC-071: destructive verb confirms when invoked interactively.
	if [[ $yes -eq 0 && -t 0 ]]; then
		printf 'Roll back run %s? This restores prior packages/panel/DB. Type "yes" to continue: ' \
			"$run_id" >&2
		local answer=""
		read -r answer
		[[ "$answer" == "yes" ]] || die "rollback aborted (confirmation not given)"
	fi

	# THE LOCK. cmd_rollback took none before UPD-5, which was a defect and not a
	# missing feature: without it a manual rollback races a live apply over the
	# same symlink, the same database and the same apt lock. The auto path already
	# holds it (taken in cmd_apply), which is why rollback_run does not take one
	# itself.
	acquire_lock || lock_refused_exit
	CURRENT_RUN_ID="$run_id"
	# The EXIT trap is armed BELOW, after the "nothing was changed" refusals: on
	# a target still journaled `running` (a crashed run — the case this verb is
	# for), on_engine_exit would otherwise turn a `die` for noop/supersession/
	# floor into ".status = failed, engine exited with status 1", a journal write
	# the refusals promise not to make and a reason that is not the real one.
	osf_detect_family

	local status
	status="$(jq -r '.status // empty' "$(journal_path "$run_id")" 2>/dev/null || true)"

	# A noop applied nothing. Refuse before ANY journal write — a refusal is not a
	# rollback attempt and must not look like one in the history.
	if [[ "$status" == "noop" ]]; then
		die "run ${run_id} is a noop — nothing was applied, nothing to roll back"
	fi

	# SUPERSESSION. Refuse before any journal write, for the same reason the noop
	# check does: this is a refusal, not a rollback attempt, and it must not
	# appear in the history as one. See run_superseded_by for what is at stake.
	local newer
	newer="$(run_superseded_by "$run_id")"
	if [[ -n "$newer" ]]; then
		if [[ $force_superseded -eq 0 ]]; then
			log "run ${newer} ran AFTER ${run_id} and applied changes of its own."
			log "  Rolling ${run_id} back would restore its pre-update database snapshot"
			log "  over everything ${newer} did, and over every panel write since."
			log "  Roll back ${newer} first, or — if you have established that is not"
			log "  what will happen here — re-run with --force-superseded."
			die "run ${run_id} is superseded by ${newer} — nothing was changed"
		fi
		engine_log "rollback: ${run_id} is superseded by ${newer}, proceeding under --force-superseded"
	fi

	# VERSION FLOOR (SC-064 / SC-476). A run that
	# finalized health-green advanced the floor to the version it installed
	# (.panel.to). Rolling it back moves the panel to the PRE-run version, which is
	# at/below that floor — and the floor cannot be lowered without defeating SC-064's
	# downgrade-replay defence. So panel_target would then refuse every candidate
	# below the floor, and the next update would drag the box straight back off the
	# version the operator just rolled to: a self-defeating rollback. Refuse before
	# ANY journal write (a refusal is not a rollback attempt), unless overridden.
	local run_target floor_now
	run_target="$(jq -r '.panel.to // empty' "$(journal_path "$run_id")" 2>/dev/null || true)"
	floor_now=""
	[[ -r "$VERSION_FLOOR_FILE" ]] && floor_now="$(tr -d '[:space:]' <"$VERSION_FLOOR_FILE" 2>/dev/null || true)"
	if [[ -n "$run_target" && -n "$floor_now" ]] && ver_valid "$run_target" && ver_valid "$floor_now" \
			&& ! ver_gt "$run_target" "$floor_now"; then
		# floor >= the run's installed target.
		if [[ $force_below_floor -eq 0 ]]; then
			log "run ${run_id} installed panel ${run_target}, but the version floor is now ${floor_now}."
			log "  The floor sits at/above ${run_target} and cannot be lowered without defeating"
			log "  the SC-064 downgrade-replay defence, so rolling ${run_id} back would leave"
			log "  panel_target refusing every update below ${floor_now} — the next check would"
			log "  pull the box straight back off the version you rolled to. Roll FORWARD instead,"
			log "  or — if you have established this is safe — re-run with --force-below-floor."
			die "run ${run_id} target ${run_target} is at/below the version floor ${floor_now} — refused (SC-064). Nothing was changed."
		fi
		engine_log "rollback: ${run_id} target ${run_target} is at/below the floor ${floor_now}; proceeding under --force-below-floor (SC-064 override)"
	fi

	local prior
	prior="$(rollback_last_record "$run_id")"
	if [[ -n "$prior" ]]; then
		local prior_outcome prior_at
		prior_outcome="$(jq -r '.result.outcome // ""' <<<"$prior")"
		prior_at="$(jq -r '.done_at // "an unrecorded time"' <<<"$prior")"
		# A completed rollback is not repeatable: re-running one would downgrade
		# past the prior version, flip a symlink the first pass already moved, and
		# — the unrecoverable one — restore a snapshot over a database the first
		# pass already restored, discarding everything committed in between
		# (SC-377).
		if [[ "$prior_outcome" == "rolled_back" ]]; then
			die "run ${run_id} was already rolled back at ${prior_at} — nothing was changed"
		fi
		# A PARTIAL one is retryable, and retrying is the operator's only recovery
		# path for the pieces that did not come back. Every step recomputes its own
		# gate from the filesystem and every step is idempotent, and the attempt
		# APPENDS a second record rather than editing the first.
		engine_log "run ${run_id} was rolled back PARTIALLY at ${prior_at} (${prior_outcome}) — retrying"
	fi

	# A journal still reading `running` is NOT refused: with the lock now held,
	# `running` means a crashed engine, and rolling back a crashed run is exactly
	# what this verb is for.
	if [[ "$status" == "running" ]]; then
		engine_log "run ${run_id} is journaled running and no engine holds the lock — rolling back a crashed run"
	fi

	trap on_engine_exit EXIT

	# `|| true` is load-bearing. rollback_run returns 1 WITHOUT journaling for its
	# early refusals (the console-identity gates), and under `set -e` a bare call
	# exited the script right here: no --json for the panel, the rollback@ unit
	# failed with one engine-log line, and on_engine_exit relabelled a crashed
	# target run "engine exited with status 1" over the real reason.
	rollback_run "$run_id" "manual rollback requested" manual || true
	if [[ "$ROLLBACK_OUTCOME" == "failed" ]]; then
		# Journal the refusal as a rollback attempt that FAILED — it is one: the
		# operator asked, the engine declined, and the panel importer only sees
		# what the stages array carries. Appended via rollback_stage_start (reuses
		# an open record, otherwise a new one) and closed with the same atomic
		# record+status write rollback_run uses, so the record and the terminal
		# status cannot disagree. `failed` is what auto_rollback writes for a
		# rollback that failed, and it keeps the run in rollback_available.
		local rb_refusal
		rb_refusal="$(jq -r '[.shortfalls[]? | tostring] | join("; ")' <<<"$ROLLBACK_RESULT" 2>/dev/null || true)"
		[[ -n "$rb_refusal" ]] || rb_refusal="rollback_run refused without a recorded reason"
		ROLLBACK_RESULT="$(jq -c --arg reason "manual rollback requested" \
			'. + {reason: $reason, trigger: "manual"}' <<<"$ROLLBACK_RESULT" 2>/dev/null \
			|| printf '{"outcome":"failed","reason":"manual rollback requested","trigger":"manual"}')"
		rollback_stage_start "$run_id"
		journal_rollback_finish "$run_id" "$ROLLBACK_RESULT" "failed" \
			"manual rollback refused: ${rb_refusal}"
		engine_log "rollback of ${run_id}: failed — ${rb_refusal}"
		panel_import_run "$run_id" || true
	fi

	# SC-415 / SC-507: re-track the recovery-tool closure to the version this
	# rollback flipped back TO. stage_finalize re-vendors FORWARD on a healthy
	# update and is REUSED after an auto-rollback WITHIN a run — so an auto-rollback
	# keeps the closure matching the release it rolled back to. A MANUAL rollback of
	# an already-completed, already-re-vendored run has no such cover: this verb
	# deliberately runs no finalize (see below), so without this the .vendored stamp
	# is left pointing at the now-REMOVED forward version — stale. Only on a CONVERGED
	# rollback (a partial leaves the box mid-restore behind the SC-318 gate; its
	# closure is not the question). panel_current_version now reads the rolled-back-to
	# release. Best-effort, exactly like the stage_finalize call site: a re-vendor
	# failure must never turn a converged rollback into a failure, and
	# revendor_recovery_closure is a no-op on a box that never carried the closure
	# (shcp-master#1311).
	if [[ "$ROLLBACK_OUTCOME" == "rolled_back" ]]; then
		local rb_revendor_json
		rb_revendor_json="$(revendor_recovery_closure)"
		journal_update "$run_id" '.rollback_revendor = $r' --argjson r "$rb_revendor_json" 2>/dev/null || true
	fi

	if [[ $JSON_OUTPUT -eq 1 ]]; then
		jq -n --arg run_id "$run_id" --arg outcome "$ROLLBACK_OUTCOME" \
			--argjson result "$ROLLBACK_RESULT" \
			'{run_id: $run_id, outcome: $outcome, result: $result}' >&"$JSON_FD"
	else
		log "rollback of ${run_id}: ${ROLLBACK_OUTCOME}"
	fi
	# A downgrade can fire the initramfs trigger like any other transaction; a
	# rollback that leaves an unbootable kernel did not succeed at its job.
	local bootable=1
	initramfs_gate "$run_id" || bootable=0
	# Deliberately NOT emit_run_result: that reports on the RUN, where
	# `rolled_back` is a failed update. This verb reports on the ROLLBACK, where a
	# converged one succeeded at its own job — and the systemd unit's exit status
	# has to mean that, not the opposite.
	[[ "$ROLLBACK_OUTCOME" == "rolled_back" ]] && (( bootable ))
}

# durable_replace <staged-file> <live-file> [<mode>|keep] -- install already-
# validated bytes with an atomic rename and make both the file and directory entry
# durable.  This is deliberately separate from atomic_write: pointer repair is an
# operator recovery boundary, where reporting success before the rename survives
# power loss would invite package work against the wrong series (SC-029/SC-476).
# The staged file gets <mode> (default 0644); `keep` leaves the mode the caller
# already set (conf_edit copies the live file's).
durable_replace() {
	local staged="$1" live="$2" mode="${3:-0644}" dir
	dir="$(dirname "$live")"
	if [[ "$mode" != keep ]]; then
		chmod "$mode" "$staged" || return 1
	fi
	sync -f "$staged" 2>/dev/null || return 1
	mv -f -- "$staged" "$live" || return 1
	sync -f "$live" 2>/dev/null || return 1
	sync -f "$dir" 2>/dev/null || return 1
}

# repair_series_snapshot_member <tar> <member> <out> -- extract one regular,
# non-link member.  Tar listings are checked before extraction so an archive
# cannot redirect the root write through a symlink/hardlink (SC-115).
repair_series_snapshot_member() {
	local archive="$1" member="$2" out="$3" type count
	[[ -f "$archive" && ! -L "$archive" ]] || return 1
	type="$(tar tvzf "$archive" -- "$member" 2>/dev/null | awk 'NR==1 {print substr($1,1,1)}')"
	count="$(tar tzf "$archive" 2>/dev/null | sed 's#^\./##' | awk -v m="$member" '$0==m {n++} END {print n+0}')"
	[[ "$type" == "-" && "$count" == 1 ]] || return 1
	tar xzf "$archive" -O -- "$member" >"$out" 2>/dev/null && [[ -s "$out" && ! -L "$out" ]]
}

# cmd_repair_series_pointer -- narrowly repair the only residual a partial
# cross-series rollback may leave.  It never calls rollback_run and has no path
# to package, panel, DB, service, worker, maintenance, or journal mutation.
cmd_repair_series_pointer() {
	local run_id="${1:-}" jf ctar mf status mechanism target prior panel_from panel_to
	[[ $# -eq 1 ]] || { log "usage: shcp-update repair-series-pointer <run-id>"; return 1; }
	[[ "$EUID" -eq 0 ]] \
		|| { log "repair-series-pointer must run as root"; return 1; }
	run_id_valid "$run_id" || { log "repair-series-pointer: invalid run id '${run_id}'"; return 1; }
	jf="$(journal_path "$run_id")"
	[[ -f "$jf" && ! -L "$jf" ]] || { log "repair-series-pointer: no regular journal for run ${run_id}"; return 1; }
	if ! run_tree_trusted "$run_id"; then
		log "repair-series-pointer: refused: ${RUN_TREE_UNTRUSTED}"
		return 1
	fi
	if ! acquire_lock; then
		if [[ -z "$LOCK_BUSY_REASON" ]]; then
			log "repair-series-pointer: the run lock could not be taken (SC-500 refusal, see the engine log)"
			return 1
		fi
		log "repair-series-pointer: ${LOCK_BUSY_REASON}"
		return 75
	fi
	osf_detect_family
	local recorded_family recorded_arch
	recorded_family="$(jq -r '.host.os_family // empty' "$jf" 2>/dev/null || true)"
	recorded_arch="$(jq -r '.host.arch // empty' "$jf" 2>/dev/null || true)"
	[[ "$recorded_family" == "$OS_FAMILY" && "$recorded_arch" == "$(uname -m)" ]] \
		|| { log "repair-series-pointer: run family/architecture does not match this host"; return 1; }
	status="$(jq -r '.status // empty' "$jf" 2>/dev/null || true)"
	mechanism="$(jq -r '.series.mechanism // empty' "$jf" 2>/dev/null || true)"
	target="$(jq -r '.series.target // empty' "$jf" 2>/dev/null || true)"
	prior="$(jq -r '.series.from // empty' "$jf" 2>/dev/null || true)"
	panel_from="$(jq -r '.panel.from // empty' "$jf" 2>/dev/null || true)"
	panel_to="$(jq -r '.panel.to // empty' "$jf" 2>/dev/null || true)"
	[[ "$status" == failed || "$status" == rolled_back_partial ]] \
		|| { log "repair-series-pointer: run is not terminal failed/rolled_back_partial"; return 1; }
	jq -e --arg r "$run_id" --arg t "$target" \
		'.run_id==$r and .scope=="series" and .request.series==$t
		 and ((.series.repoint_started//false)==true or (.series.suite_rewritten//false)==true)' \
		"$jf" >/dev/null 2>&1 \
		|| { log "repair-series-pointer: journal does not canonically bind this series transition"; return 1; }
	[[ "$target" =~ ^[0-9]+\.[0-9]+$ ]] \
		|| { log "repair-series-pointer: malformed target series"; return 1; }
	ver_valid "$panel_from" && ver_valid "$panel_to" \
		|| { log "repair-series-pointer: journal lacks a valid from/to panel transition"; return 1; }
	[[ "$(ver_series "$panel_to")" == "$target" ]] \
		|| { log "repair-series-pointer: target series and panel target disagree"; return 1; }
	[[ "$(panel_current_version)" == "$panel_from" ]] \
		|| { log "repair-series-pointer: running panel is not the run's prior release"; return 1; }
	local prior_series; prior_series="$(ver_series "$panel_from")"
	[[ -n "$prior" ]] || prior="$prior_series" # deb journals before the rpm `from` field existed
	[[ "$prior" == "$prior_series" && "$prior" != "$target" ]] \
		|| { log "repair-series-pointer: prior-series binding is ambiguous"; return 1; }
	case "$OS_FAMILY:$mechanism" in
		deb:apt-suite|rpm:dnf-var) ;;
		*) log "repair-series-pointer: journal mechanism '${mechanism}' does not match host family '${OS_FAMILY}'"; return 1 ;;
	esac
	local live_series; live_series="$(series_pointer_series)"
	if [[ "$live_series" == "$prior" ]]; then
		log "series pointer already names ${prior}; nothing changed"
		return 0
	fi
	[[ "$live_series" == "$target" ]] \
		|| { log "repair-series-pointer: live pointer '${live_series:-unreadable}' is not attributable to this run"; return 1; }
	ctar="$(config_tar_path_of_run "$run_id")"
	[[ "$ctar" == "${RUNS_DIR}/${run_id}/config.tar.gz" && -f "$ctar" && ! -L "$ctar" ]] \
		|| { log "repair-series-pointer: snapshot is absent or not the run's canonical config.tar.gz"; return 1; }
	path_uid0_not_writable "$ctar" \
		|| { log "repair-series-pointer: snapshot failed ownership validation"; return 1; }
	mf="${RUNS_DIR}/${run_id}/manifest.json"
	[[ -f "$mf" && ! -L "$mf" ]] \
		|| { log "repair-series-pointer: run has no regular verified-manifest copy"; return 1; }
	# The copy was admitted only after gpgv + pinned-primary + replay checks.  The
	# root-owned run-tree gate protects those accepted bytes afterwards; bind the
	# requested target back to the exact release record before trusting the run.
	jq -e --arg s "$target" --arg v "$panel_to" \
		'any(.series[]?; .series==$s and .version==$v and (.status=="supported" or .status=="security"))' \
		"$mf" >/dev/null 2>&1 \
		|| { log "repair-series-pointer: accepted manifest does not bind the run's target"; return 1; }

	local stage live staged archived_series
	case "$OS_FAMILY" in
		deb)
			live="${CONFIG_ROOT%/}/etc/apt/sources.list.d/shcp.list"
			[[ -f "$live" && ! -L "$live" ]] || { log "repair-series-pointer: live apt pointer is not a regular file"; return 1; }
			stage="$(mktemp -d "$(dirname "$live")/.shcp-pointer-repair.XXXXXX" 2>/dev/null)" || return 1
			staged="${stage}/shcp.list"
			if ! repair_series_snapshot_member "$ctar" etc/apt/sources.list.d/shcp.list "$staged"; then
				rm -rf "$stage"; log "repair-series-pointer: snapshot has no safe apt pointer"; return 1
			fi
			local saved_family="$OS_FAMILY" saved_root="$CONFIG_ROOT"
			CONFIG_ROOT="$stage"; mkdir -p "$stage/etc/apt/sources.list.d"; mv "$staged" "$stage/etc/apt/sources.list.d/shcp.list"
			archived_series="$(series_pointer_series)"; mv "$stage/etc/apt/sources.list.d/shcp.list" "$staged"
			CONFIG_ROOT="$saved_root"; OS_FAMILY="$saved_family"
			[[ "$archived_series" == "$prior" ]] || { rm -rf "$stage"; log "repair-series-pointer: snapshot pointer does not name ${prior}"; return 1; }
			mkdir -p "$stage/lists/partial" "$stage/cache/archives/partial"
			# Same private-probe discipline as series_deb_target_index: the host's
			# update hooks must not run against this scratch index, and only a
			# `#clear` in a config FILE empties apt's hook lists (`-o ...::=`
			# appends an empty element and clears nothing).
			printf '%s\n' '#clear APT::Update::Pre-Invoke;' '#clear APT::Update::Post-Invoke;' \
				'#clear APT::Update::Post-Invoke-Success;' > "${stage}/apt.conf" \
				|| { rm -rf "$stage"; log "repair-series-pointer: could not stage the apt probe config"; return 1; }
			if ! apt-get -c "${stage}/apt.conf" "${APT_LOCK_OPT[@]}" -o Acquire::Retries=0 \
				-o "Dir::Etc::sourcelist=${staged}" -o Dir::Etc::sourceparts=- \
				-o "Dir::State::lists=${stage}/lists" -o "Dir::Cache=${stage}/cache" \
				-o APT::Get::List-Cleanup=0 update >/dev/null 2>&1; then
				rm -rf "$stage"; log "repair-series-pointer: prior apt leaf could not be verified"; return 1
			fi
			durable_replace "$staged" "$live" || { rm -rf "$stage"; log "repair-series-pointer: durable apt pointer write failed"; return 1; }
			rm -rf "$stage" ;;
		rpm)
			local prior_url
			if ! prior_url="$(series_rpm_snapshot_url "$ctar")" || ! series_dnf_prime "$prior_url"; then
				log "repair-series-pointer: prior rpm leaf could not be verified"; return 1
			fi
			[[ "$prior_url" == */"${prior}"/ ]] || { log "repair-series-pointer: snapshot leaf does not bind prior series ${prior}"; return 1; }
			live="${CONFIG_ROOT%/}/etc/dnf/vars/shcpseries"
			[[ -f "$live" && ! -L "$live" ]] || { log "repair-series-pointer: live rpm pointer is not a regular file"; return 1; }
			stage="$(mktemp -d "$(dirname "$live")/.shcp-pointer-repair.XXXXXX" 2>/dev/null)" || return 1
			staged="${stage}/shcpseries"; printf '%s\n' "$prior" >"$staged"
			durable_replace "$staged" "$live" || { rm -rf "$stage"; log "repair-series-pointer: durable rpm pointer write failed"; return 1; }
			rm -rf "$stage" ;;
	esac
	[[ "$(series_pointer_series)" == "$prior" ]] \
		|| { log "repair-series-pointer: pointer parse-back failed"; return 1; }
	log "series pointer repaired: ${target} -> ${prior} for run ${run_id}"
	return 0
}

cmd_status() {
	local follow=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--follow) follow=1; shift ;;
			--json)   JSON_OUTPUT=1; shift ;;
			*) die "status: unknown argument: $1" ;;
		esac
	done
	# The run IN FLIGHT wins over the newest id: a `running` run (an apply, a
	# self-update hop, a bare `resume` of an older crashed run) or a parked one
	# is what "progress" means, and the newest id is only the answer when nothing
	# is in flight. Picking the newest id showed a finished run's status.json
	# while an older run was being resumed underneath it.
	local run_id
	run_id="$(latest_run_id_with_status running)"
	[[ -n "$run_id" ]] || run_id="$(latest_run_id_with_status awaiting_reboot)"
	[[ -n "$run_id" ]] || run_id="$(latest_run_id)"
	[[ -n "$run_id" ]] || die "no runs recorded under ${RUNS_DIR}"
	local sf jf
	sf="$(status_path "$run_id")"
	jf="$(journal_path "$run_id")"

	if [[ $follow -eq 0 ]]; then
		if [[ -f "$sf" ]]; then cat "$sf"; else jq '{run_id, status}' "$jf"; fi
		return 0
	fi

	# --follow: poll status.json (2 s cadence per §4.9) until the journal
	# leaves "running". Survives the writer being killed: a stale file just
	# stops updating and the stale-journal watchdog (check verb) flags it.
	local last=""
	while :; do
		if [[ -f "$sf" ]]; then
			local cur
			cur="$(cat "$sf")"
			if [[ "$cur" != "$last" ]]; then
				printf '%s\n' "$cur"
				last="$cur"
			fi
		fi
		[[ "$(jq -r '.status' "$jf" 2>/dev/null)" == "running" ]] || break
		sleep 2
	done
	jq '{run_id, status}' "$jf"
}

cmd_self_test() {
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--json) JSON_OUTPUT=1; shift ;;
			*) die "self-test: unknown argument: $1" ;;
		esac
	done
	local -a checks=()
	local ok=true tool

	osf_detect_family   # the tool list below is family-specific

	# The list is the set of binaries the engine ACTUALLY invokes. It used to
	# probe `gpg`, which appears nowhere in this file — every signature check
	# goes through `gpgv` (manifest_fetch, artifact_fetch_verify). So the one
	# dependency whose absence silently disables the entire panel half was the
	# one the self-test did not look for, while a green result was reported on
	# the strength of an unrelated binary that happened to be installed.
	# curl and tar are fetch/extract; the restart detector is §4.4's only source
	# on this family.
	local -a selftest_tools=(xdelta3 sqlite3 gpgv curl tar flock jq)
	local detector; detector="$(osf_restart_detector)"
	[[ -n "$detector" ]] && selftest_tools+=("$detector")

	for tool in "${selftest_tools[@]}"; do
		if command -v "$tool" >/dev/null 2>&1; then
			checks+=("$(jq -nc --arg c "dep:${tool}" '{check: $c, ok: true}')")
		else
			checks+=("$(jq -nc --arg c "dep:${tool}" '{check: $c, ok: false, detail: "not found in PATH"}')")
			ok=false
		fi
	done

	# Installer-owned state, and the reason it is checked here: §4.13 establishes
	# that state provisioned at install time is never delivered to an existing
	# box, and this keyring is the single file whose absence silently disables
	# every panel update while the run still reports healthy. A package-only box
	# has no panel to update, so the absent trust anchor is not applicable there.
	if [[ -r "$RELEASE_KEYRING" ]]; then
		checks+=("$(jq -nc --arg k "$RELEASE_KEYRING" '{check: "release_keyring", ok: true, detail: $k}')")
	elif [[ ! -e "$PANEL_LINK" && ! -L "$PANEL_LINK" ]]; then
		checks+=("$(jq -nc --arg p "$PANEL_LINK" '{check: "release_keyring", ok: true, status: "skip",
			detail: ("not applicable — no panel install at \($p)")}')")
	else
		checks+=("$(jq -nc --arg k "$RELEASE_KEYRING" '{check: "release_keyring", ok: false,
			detail: ("missing or unreadable: \($k) — no panel release can be verified or installed")}')")
		ok=false
	fi

	if mkdir -p "$RUNS_DIR" 2>/dev/null \
			&& touch "${RUNS_DIR}/.selftest.$$" 2>/dev/null; then
		rm -f "${RUNS_DIR}/.selftest.$$"
		checks+=("$(jq -nc '{check: "state_dir_writable", ok: true}')")
	else
		checks+=("$(jq -nc --arg d "$RUNS_DIR" '{check: "state_dir_writable", ok: false, detail: $d}')")
		ok=false
	fi

	if mkdir -p "$(dirname "$SHCP_UPDATE_LOCK")" 2>/dev/null \
			&& ( umask 077; touch "$SHCP_UPDATE_LOCK" ) 2>/dev/null; then
		checks+=("$(jq -nc '{check: "lock_path_writable", ok: true}')")
	else
		checks+=("$(jq -nc --arg p "$SHCP_UPDATE_LOCK" '{check: "lock_path_writable", ok: false, detail: $p}')")
		ok=false
	fi

	# Informational only: settings fall toward defaults when unreadable
	# (AD-6), so an absent DB is ok:true with a detail note.
	if [[ -f "$SHCP_UPDATE_DB" ]]; then
		if sqlite3 -readonly "$SHCP_UPDATE_DB" \
				"SELECT 1 FROM system_settings LIMIT 1;" >/dev/null 2>&1; then
			checks+=("$(jq -nc '{check: "settings_db", ok: true}')")
		else
			checks+=("$(jq -nc '{check: "settings_db", ok: true, detail: "unreadable — defaults apply (fail toward patching)"}')")
		fi
	else
		checks+=("$(jq -nc '{check: "settings_db", ok: true, detail: "absent — defaults apply (fail toward patching)"}')")
	fi

	# UPD-4: the snapshot destination must be usable before a run reaches the
	# stage that needs it. Probed only when there IS a panel database, which is
	# exactly when stage_snapshot attempts a snapshot — on a box with no panel
	# there is nothing to protect and a failure here would be a false alarm.
	# Non-mutating on purpose: creating the tree here as root would leave it
	# root-owned, which is the SC-272 defect this engine exists to avoid.
	if [[ -f "$SHCP_UPDATE_DB" ]]; then
		local probe="$DB_BACKUP_DIR"
		while [[ -n "$probe" && ! -d "$probe" ]]; do probe="${probe%/*}"; done
		if ! path_no_symlink "$DB_BACKUP_DIR"; then
			checks+=("$(jq -nc --arg d "$DB_BACKUP_DIR" \
				'{check: "db_backup_dir", ok: false, detail: ("symlinked path component under " + $d)}')")
			ok=false
		elif [[ -n "$probe" && -w "$probe" ]]; then
			checks+=("$(jq -nc '{check: "db_backup_dir", ok: true}')")
		else
			checks+=("$(jq -nc --arg d "$DB_BACKUP_DIR" \
				'{check: "db_backup_dir", ok: false, detail: ("no writable ancestor of " + $d)}')")
			ok=false
		fi
	else
		checks+=("$(jq -nc '{check: "db_backup_dir", ok: true, detail: "no panel database — nothing to snapshot"}')")
	fi

	printf '%s\n' "${checks[@]}" \
		| jq -s --argjson ok "$ok" '{ok: $ok, checks: .}'
	[[ "$ok" == "true" ]]
}

cmd_maintenance() {
	local action=""
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--set)    action=set; shift ;;
			--clear)  action=clear; shift ;;
			--status) action=status; shift ;;
			--json)   JSON_OUTPUT=1; shift ;;
			*) die "maintenance: unknown argument: $1" ;;
		esac
	done
	case "$action" in
		set)
			mkdir -p "$SHCP_UPDATE_STATE_DIR"
			# A bare operator --set must NOT clobber a live run's schema-pending
			# marker (that would drop the panel's GET-gate mid-window and re-open
			# the 500 hazard). Preserve an existing phase; write none if no flag.
			local existing_phase=""
			[[ -f "$MAINT_FLAG" ]] && \
				existing_phase="$(jq -r '.phase // empty' "$MAINT_FLAG" 2>/dev/null || true)"
			maintenance_write "operator" "$existing_phase"
			log "maintenance flag set (${MAINT_FLAG})"
			;;
		clear)
			maintenance_clear
			log "maintenance flag cleared"
			# A run that died with the schema pending, or a rollback that did
			# not converge, left its timers held (shcp-updater#302). Clearing
			# the flag is the operator saying the
			# panel is good, so start them. Only from a root-written run tree.
			local held_run
			for held_run in "$RUNS_DIR"/*/journal.json; do
				held_run="${held_run%/journal.json}"
				held_run="${held_run##*/}"
				run_id_valid "$held_run" && [[ -f "$(journal_path "$held_run")" ]] || continue
				jq -e '(.timers.held // []) | length > 0' "$(journal_path "$held_run")" >/dev/null 2>&1 || continue
				run_tree_trusted "$held_run" || { log "not starting the timers run ${held_run} recorded: ${RUN_TREE_UNTRUSTED}"; continue; }
				panel_timers_release "$held_run" || true
			done
			;;
		status|"")
			if [[ -f "$MAINT_FLAG" ]]; then
				cat "$MAINT_FLAG"
			else
				jq -n '{maintenance: false}'
			fi
			;;
	esac
}


# --- UPD-14: configuration drift DETECTION (SC-432) ---------------------------
# `reconcile-config --check`. It reads what the running software DECLARES it
# requires, asks this box what it actually has, and prints the difference. It
# writes nothing, takes no lock, opens no socket.
#
# WHY A STANDALONE VERB AND NOT A STAGE — each of these was measured, not
# hypothesised:
#   - run_stages short-circuits on an empty work set, so a fully-patched box —
#     which is most of them — would never evaluate drift at all;
#   - stage 0 execs a replacement process, so anything sharing that process is
#     replaced mid-flight;
#   - a change made inside a run lands in that run's .packages.changed, and
#     rollback_run reads that shape as "the run did it, undo it";
#   - SC-432 forbids reaching the reconciler from run_stages outright.
#   - and this verb must run on a box whose panel is broken and whose update
#     timers are ABSENT (that is the drift it is looking for), which is exactly
#     the box that never starts a run.
# stage_health attaches a read-only SUMMARY of the same evaluation to the health
# object (§7.3). That is a report, not a reconciliation: it can set no check, no
# verdict and no rollback.
#
# WHY EXPECTATION IS DECLARED AND NEVER INFERRED. The alternative — diff the
# installer payload — describes what the installer would do NOW, not what THIS
# release needs. Measured, it misses shcp-ssl-check.timer (enabled by a
# non-fatal side path, so absent legitimately on some boxes) and it stomps
# shcp-update-security.timer, which ships in this .deb precisely so security
# patching survives a broken panel (AD-6, SC-319).
#
# TWO HALVES, TWO CARRIERS, because a declaration that rides only the panel
# tarball cannot reach the population this feature exists for. A box missing
# shcp-update-check.timer and shcp-update-auto.timer never self-updates, so it
# can never RECEIVE a panel release carrying a declaration. The engine half
# ships in the same .deb as this verb and therefore reaches it; the release half
# ships with the panel and covers panel-owned expectations. Each half reports
# its own coverage and the report never implies it evaluated the other.
RECONCILE_SCHEMA=1

DECL_ENGINE_FILE="${SHCP_UPDATE_DECL_ENGINE:-/usr/lib/shcp-update/required-state.d/00-engine.json}"
DECL_RELEASE_FILE="${SHCP_UPDATE_DECL_RELEASE:-${PANEL_LINK}/config/system/required-state.json}"
RECONCILE_EXEMPT_FILE="${SHCP_UPDATE_RECONCILE_EXEMPT:-/etc/shcp-update/reconcile-exempt}"
# Apache paths are set by osf_detect_family once the family is known.
APACHE_SITES_DIR=""
SHCPD_UNIT="${SHCP_UPDATE_SHCPD_UNIT:-shcpd}"

MAX_DECLARATION_BYTES=$((64 * 1024))
MAX_EXEMPT_BYTES=$((16 * 1024))
MAX_ENV_FILE_BYTES=$((256 * 1024))
MAX_VHOST_BYTES=$((1024 * 1024))
MAX_RECONCILE_ITEMS_BYTES=$((48 * 1024))
# The engine's health object has NO cap of its own — MAX_HEALTH_REPORT_BYTES
# clamps the PANEL's report on its way in, not this. The drift summary is
# assembled here, so its bound belongs here too.
MAX_DRIFT_SUMMARY_BYTES=$((4 * 1024))
DRIFT_NAMES_MAX=12
# A directive pattern comes out of a file the panel user owns and is handed to
# grep -E. Bound its length, and run grep under a timeout: ERE has no
# backreferences, but "cheap to write, expensive to match" is not a property to
# assume about someone else's file.
MAX_DIRECTIVE_RE_LEN=200
RECONCILE_GREP_TIMEOUT=5

# Exit codes are a contract (§5). 2 is deliberately absent: main() already
# spends it on a zero-argv usage, and "no arguments" must stay distinguishable
# from "could not check".
RECONCILE_EXIT_OK=0
RECONCILE_EXIT_FINDINGS=3
RECONCILE_EXIT_CANNOT_CHECK=4

# Grammars. Nothing that fails one of these is repaired — it is dropped
# (SC-352 discipline, and SC-447).
# No '@': template units cannot be queried at all (`systemctl show foo@.service`
# exits 1, measured), so the whole kind is out of this cut rather than
# half-supported.
#
# \A and \z, NOT ^ and $. These reach jq's test() only — never grep -E, which
# has no such anchors — and jq's Oniguruma `$` is the Ruby one: it matches
# immediately before a trailing newline. Measured on jq 1.7, the ^...$ forms
# these replace ACCEPTED "shcp-x.timer\n", so a JSON string carrying a newline
# passed the grammar and the name reached systemctl. The panel half anchors
# \A...\z in PCRE for exactly this reason and its shared fixture pins the case;
# the two halves disagreed until now, which is the divergence SC-447 exists to
# stop. decl_read's `tr -cd` deliberately keeps \12, so nothing upstream saves us.
DECL_UNIT_RE='\A[A-Za-z0-9._-]+\.(timer|service|socket)\z'
DECL_ENVKEY_RE='\A[A-Z][A-Z0-9_]*\z'
DECL_CONF_RE='\A000-default[A-Za-z0-9._-]*\.conf\z'
DECL_ID_RE='\A[a-z]+:[A-Za-z0-9._-]+\z'

# The `files` kind (UPD-14 payload backfill, updater#109). Grammars for a
# declared installer-owned system file the applier may backfill onto a box that
# never received it. Kept in lock-step with shcp-base RequiredStateDocument's
# PATH_RE/SOURCE_RE/MODE_RE/OWNER_RE (SC-447: the panel refuses to GENERATE a
# document this engine would silently drop half of). `..` is rejected SEPARATELY
# (the charset admits `.`), the destination is confined to an engine-owned root
# allow-list, and the source stays tarball-relative — see decl_read.
DECL_PATH_RE='\A/[A-Za-z0-9._/-]+\z'
DECL_SOURCE_REL_RE='\A[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*\z'
DECL_MODE_RE='\A0[0-7]{3}\z'
DECL_OWNER_RE='\A[a-z_][a-z0-9_-]*:[a-z_][a-z0-9_-]*\z'
# A filesystem path ceiling, distinct from DECL_MAX_NAME_LEN (a unit name is
# 255; a path is not). PATH_MAX on Linux is 4096.
DECL_MAX_PATH_LEN=4096

# The closed allow-list of destination roots the `files` applier may write under.
# Engine-owned (NOT a declaration field): the whole confinement is that a panel-
# writable declaration can only place a signed-tarball file UNDER one of these,
# never at an arbitrary path. Derived from the two seams so the test harness can
# retarget them without touching real /usr/sbin. Anything a declaration names
# outside them is DROPPED at parse (decl_read) and refused again at write time.
RECONCILE_PAYLOAD_ROOTS=("$SHCP_SBIN_DIR" "$SHCP_LIBEXEC_DIR")

# systemd's own unit-name ceiling. The grammars above are unquantified, so
# without this a 10 KiB "unit" matches perfectly and is handed to systemctl —
# measured: a 306-char name the shared fixture lists as a reject case was
# ACCEPTED here. The panel bounds every name at the same 255 (MAX_NAME_LENGTH);
# jq's `length` counts codepoints, but the grammars admit ASCII only, so once a
# name matches, codepoints and bytes are the same number.
DECL_MAX_NAME_LEN=255

# Units this package owns. They can never be declared by either half and are
# dropped at parse time rather than checked and excused, because "excluded by a
# rule nobody ever sees fire" is how shcp-update-security.timer would end up
# reported as drift on every box — and its absence is not the panel's business
# to assert in the first place.
#
# This is EVERY unit under systemd/ in this repo (both packagers ship the same
# set), and it must stay that way: a shipped unit missing from this list — as
# resume/converge were, once — would pass decl_read and be `systemctl enable`d
# by the applier on a panel's say-so. A literal, not a runtime `ls`: the
# installed tree mixes these with every other package's units, and the list has
# to hold on a box whose shcp-updater package is older than the declaration.
# tests/test-updater-reconcile-check.sh diffs it against `ls systemd/`.
RECONCILE_PACKAGE_UNITS_JSON='["shcp-update-apply.service","shcp-update-converge.service","shcp-update-converge.timer","shcp-update-drift.service","shcp-update-drift.timer","shcp-update-ledger-hold.service","shcp-update-ledger-hold.timer","shcp-update-ledger.service","shcp-update-reinstall@.service","shcp-update-resume.service","shcp-update-rollback@.service","shcp-update-security.service","shcp-update-security.timer","shcp-update-webmail.service","shcp-update-webmail.timer"]'

# decl_read <path> — the parsed, filtered declaration on stdout.
#   0  parsed
#   3  absent            (caller: coverage "undeclared" — a REPORTED state)
#   2  unreadable / unparseable / unrecognised shape  (caller: exit 4)
#
# `else empty end` is FORBIDDEN in this filter. Measured: jq prints nothing and
# exits 0 for it, so a `|| return 2` never fires and a version-skewed
# declaration reads as "declared nothing" — i.e. green, on exactly the box that
# is furthest behind. Reject with error(), and belt it with an emptiness test
# the way health_panel_probe already does.
decl_read() {
	local path="$1" raw parsed
	[[ -e "$path" ]] || return 3
	[[ -r "$path" ]] || return 2
	raw="$(head -c "$MAX_DECLARATION_BYTES" -- "$path" 2>/dev/null)" || return 2
	[[ -n "$raw" ]] || return 2
	# The engine-owned destination-root allow-list, as JSON, for the `files` kind's
	# confinement test below. Built from the array (which derives from the two
	# seams) so a retargeted test root reaches the parser too. An empty array => no
	# path can pass => every file entry dropped, which is the safe fail-closed.
	local roots_json
	roots_json="$(printf '%s\n' "${RECONCILE_PAYLOAD_ROOTS[@]+"${RECONCILE_PAYLOAD_ROOTS[@]}"}" \
		| jq -Rsc 'split("\n") | map(select(length > 0))' 2>/dev/null)" || roots_json='[]'
	[[ -n "$roots_json" ]] || roots_json='[]'
	parsed="$(printf '%s' "$raw" | tr -cd '\11\12\15\40-\176' | jq -c \
		--arg u "$DECL_UNIT_RE" --arg e "$DECL_ENVKEY_RE" --arg c "$DECL_CONF_RE" \
		--arg i "$DECL_ID_RE" --argjson x "$RECONCILE_PACKAGE_UNITS_JSON" \
		--argjson m "$DECL_MAX_NAME_LEN" \
		--arg pathre "$DECL_PATH_RE" --arg srcre "$DECL_SOURCE_REL_RE" \
		--arg modere "$DECL_MODE_RE" --arg ownerre "$DECL_OWNER_RE" \
		--argjson pm "$DECL_MAX_PATH_LEN" --argjson roots "$roots_json" '
		# A name must be a bounded string that matches whole. Both halves, or
		# the bound is decorative: test() alone accepts any length (SC-447).
		def named($v; $re): ($v | type) == "string"
		                    and ($v | length) <= $m and ($v | test($re));
		# os_family: ABSENT means every family (the field is a restriction, and
		# the grammar fixtures declare units without it); PRESENT must be a
		# non-empty array drawn from deb|rpm, or the entry is DROPPED like any
		# other grammar failure (SC-352/SC-447) — never coerced, because a
		# family the engine does not know is a family this box might be. Every
		# surviving entry carries the NORMALISED array, so eval/apply gate on it
		# uniformly and the field can no longer be decorative on a unit.
		def fam_ok: (.os_family == null)
		            or ((.os_family | type) == "array" and (.os_family | length) > 0
		                and all(.os_family[]; . == "deb" or . == "rpm"));
		def fam_norm: .os_family = (if .os_family == null then ["deb","rpm"]
		                            else (.os_family | unique) end);
		if type == "object" and (.schema_version | type) == "number"
		   and .schema_version == 1
		then {
		  half: ((.half // "unknown") | tostring),
		  declared_by: ((.declared_by // "") | tostring),
		  units: ((.units // []) | map(select(
		            (type == "object")
		            and named(.id // ""; $i)
		            and named(.unit // ""; $u)
		            and (((.why // "") | length) > 0)
		            and fam_ok
		            and ((.unit) as $n | ($x | index($n)) == null)))
		          | map(fam_norm)),
		  env: ((.env // []) | map(select(
		            (type == "object")
		            and named(.id // ""; $i)
		            and named(.key // ""; $e)
		            and fam_ok
		            and (((.why // "") | length) > 0)))
		          | map(fam_norm)
		          # SC-447 validate-and-carry, NOT pass-through: `generate` selects
		          # which minting routine the applier runs, so it is normalised to
		          # the bounded allow-list here (the same place a name is grammar-
		          # checked) rather than trusted raw downstream. Anything not on the
		          # list becomes null => detect-only, the applier never mints.
		          | map(.generate = (if (.generate.method // "") == "api_key"
		                             then {method: "api_key"} else null end))),
		  vhost: ((.vhost // []) | map(select(
		            (type == "object")
		            and named(.id // ""; $i)
		            and named(.conf // ""; $c)
		            and (((.directive_re // "") | length) > 0)
		            and fam_ok
		            and (((.why // "") | length) > 0)))
		          | map(fam_norm)
		          # SC-447: `apply` names the vhost applier a finding routes to; a
		          # value off the allow-list is dropped to null (coverage gap, no
		          # write) rather than reaching a case that could mis-render.
		          | map(.apply = (if (.apply // "") == "edge_auth_include"
		                          then "edge_auth_include" else null end))),
		  # files (updater#109): a declared installer-owned system file the applier
		  # backfills onto a box that never received it. Every field is validated
		  # HERE and an entry failing any check is DROPPED, never repaired (SC-352 /
		  # SC-447). The DESTINATION carries the risk the other kinds do not — it
		  # names an absolute filesystem path from a panel-writable file — so it is
		  # gated three ways: it matches the path grammar, it contains no `..`
		  # component (the charset admits `.`, so this is a separate test), and it
		  # sits strictly UNDER an engine-owned allow-listed root. The SOURCE stays
		  # tarball-relative (no leading slash, no `..`). mode is 4-digit octal,
		  # owner is user:group.
		  files: ((.files // []) | map(select(
		            (type == "object")
		            and named(.id // ""; $i)
		            and ((.path // "") | type == "string")
		            and (((.path // "") | length) <= $pm)
		            and ((.path // "") | test($pathre))
		            and (((.path // "") | test("(\\A|/)\\.\\.(/|\\z)")) | not)
		            and ((.path // "") as $p
		                 | ($roots | any(. as $r | $p | startswith($r + "/"))))
		            and ((.source // "") | type == "string")
		            and (((.source // "") | length) <= $pm)
		            and ((.source // "") | test($srcre))
		            and (((.source // "") | test("(\\A|/)\\.\\.(/|\\z)")) | not)
		            and ((.mode // "") | type == "string")
		            and ((.mode // "") | test($modere))
		            and ((.owner // "") | type == "string")
		            and ((.owner // "") | test($ownerre))
		            and fam_ok
		            and (((.why // "") | length) > 0)))
		          | map(fam_norm))
		}
		else error("unsupported declaration shape") end' 2>/dev/null)" || return 2
	[[ -n "$parsed" ]] || return 2
	printf '%s' "$parsed"
}

# --- the exemption record -----------------------------------------------------
# /etc/shcp-update/reconcile-exempt, one declaration id per line. It is how an
# operator says "I removed that on purpose". The panel must not be able to write
# it: a file that silences the detector, in a directory the watched account
# owns, is a denial-of-detection primitive. Hence the ownership test — and hence
# a record that FAILS the test is ignored AND REPORTED, never treated as an
# error that suppresses the rest of the run (which would freeze a cached clean
# answer in place, achieving exactly what the unauthorised file wanted).
RECONCILE_EXEMPT_IDS=()
RECONCILE_EXEMPT_REFUSED=""

# path_uid0_not_writable <path> — owned by the expected uid and not group- or
# other-writable. SHCP_UPDATE_EXPECT_UID is the same seam the request-file
# owner check uses (default 0); it exists so this is testable without root.
path_uid0_not_writable() {
	local st uid mode g o
	st="$(stat -c '%u %a' -- "$1" 2>/dev/null)" || return 1
	uid="${st%% *}"
	mode="${st##* }"
	[[ "$uid" == "$SHCP_UPDATE_EXPECT_UID" ]] || return 1
	[[ "$mode" =~ ^[0-7]{3,4}$ ]] || return 1
	mode="${mode: -3}"
	g="${mode:1:1}"
	o="${mode:2:1}"
	(( (g & 2) == 0 && (o & 2) == 0 )) || return 1
	return 0
}

reconcile_exempt_load() {
	RECONCILE_EXEMPT_IDS=()
	RECONCILE_EXEMPT_REFUSED=""
	[[ -e "$RECONCILE_EXEMPT_FILE" ]] || return 0
	local dir="${RECONCILE_EXEMPT_FILE%/*}"
	if ! path_uid0_not_writable "$dir" || ! path_uid0_not_writable "$RECONCILE_EXEMPT_FILE"; then
		RECONCILE_EXEMPT_REFUSED="${RECONCILE_EXEMPT_FILE} is not owned by uid ${SHCP_UPDATE_EXPECT_UID}, or it or its directory is group/other-writable — every exemption in it was IGNORED"
		return 0
	fi
	local line id
	while IFS= read -r line || [[ -n "$line" ]]; do
		line="${line%%#*}"
		line="${line#"${line%%[![:space:]]*}"}"
		line="${line%"${line##*[![:space:]]}"}"
		[[ -n "$line" ]] || continue
		[[ "$line" =~ ^[a-z]+:[A-Za-z0-9._-]+$ ]] || continue
		id="$line"
		RECONCILE_EXEMPT_IDS+=("$id")
	done < <(head -c "$MAX_EXEMPT_BYTES" -- "$RECONCILE_EXEMPT_FILE" 2>/dev/null || true)
	return 0
}

reconcile_is_exempt() {
	local want="$1" id
	for id in ${RECONCILE_EXEMPT_IDS[@]+"${RECONCILE_EXEMPT_IDS[@]}"}; do
		[[ "$id" == "$want" ]] && return 0
	done
	return 1
}

# --- predicates ---------------------------------------------------------------
# Every one of these is consumed as `if ! p; then` or as `out="$(p)" || rc=$?`.
# NEVER as a bare `x="$(p)"`: this script runs under `set -euo pipefail`, and
# the "thing is missing" case is the non-zero case for grep, for stat and for
# systemctl on an unshowable name. A bare capture makes the drift branch abort
# the process at exit 1 — i.e. a box that ACQUIRES drift stops reporting drift.

# reconcile_unit_state <unit> — enabled | disabled | masked | absent |
# unanswerable | unknown.
#
# `unanswerable` is its own word because the CALLER has to react to it and this
# function cannot: it is consumed in a command substitution, so any flag it sets
# dies with the subshell. A systemctl that EXISTS and fails — no dbus, a
# container, the SC-356 MAC denial — is not the same fact as a box with no
# systemd, and it is certainly not evidence about the unit.
#
# Absence is keyed on LoadState=not-found and nothing else. `systemctl show` on
# an absent unit exits 0 with non-empty output (measured: LoadState=not-found,
# ActiveState=inactive, UnitFileState= empty), so an exit-code or emptiness test
# can never see it. An empty UnitFileState is NOT absence either: static,
# indirect, generated and transient units legitimately have one.
reconcile_unit_state() {
	local unit="$1" out="" rc=0 load="" file="" k v
	command -v systemctl >/dev/null 2>&1 || { printf 'unknown'; return 0; }
	out="$(systemctl show --property=LoadState --property=UnitFileState -- "$unit" 2>/dev/null)" || rc=$?
	if (( rc != 0 )) || [[ -z "$out" ]]; then
		printf 'unanswerable'
		return 0
	fi
	while IFS='=' read -r k v; do
		case "$k" in
			LoadState) load="$v" ;;
			UnitFileState) file="$v" ;;
		esac
	done <<<"$out"
	case "$file" in
		masked|masked-runtime) printf 'masked'; return 0 ;;
		disabled)              printf 'disabled'; return 0 ;;
	esac
	case "$load" in
		not-found) printf 'absent' ;;
		masked)    printf 'masked' ;;
		loaded)    printf 'enabled' ;;
		*)         printf 'unknown' ;;
	esac
	return 0
}

# reconcile_unit_kind <unit> — timer | service | socket. Declaration parsing has already
# admitted only these two suffixes before a unit reaches reconciliation.
reconcile_unit_kind() {
	case "$1" in
		*.timer)   printf 'timer' ;;
		*.service) printf 'service' ;;
		*.socket)  printf 'socket' ;;
		*)         return 1 ;;
	esac
}

# env_file_lookup <file> <KEY> — 0 + the raw value when the key is set in that
# layer. Last assignment in the file wins, as dotenv does.
env_file_lookup() {
	local f="$1" key="$2" line val="" found=1
	[[ -r "$f" ]] || return 1
	while IFS= read -r line || [[ -n "$line" ]]; do
		line="${line#"${line%%[![:space:]]*}"}"
		[[ "$line" == \#* ]] && continue
		[[ "$line" == "export "* ]] && line="${line#export }"
		[[ "$line" == "${key}="* ]] || continue
		val="${line#"${key}="}"
		found=0
	done < <(head -c "$MAX_ENV_FILE_BYTES" -- "$f" 2>/dev/null || true)
	(( found == 0 )) || return 1
	printf '%s' "$val"
	return 0
}

# env_unit_lookup <KEY> — the value from the shcpd unit's Environment=.
#
# `systemctl show -p Environment` prints the assignments space-separated and
# SHELL-QUOTED (shell_maybe_quote): an assignment whose value carries
# whitespace comes out as "KEY=a b" — the WHOLE token in double quotes, with
# \" and \\ escaped inside. A `read -a` word-split (what this once did) turned
# that into `"KEY=a` and `b"`, so a quoted value never matched `KEY=` and read
# as unresolved — a finding, and a mint over a value the unit already had.
# env_unit_tokenize below is the small tokenizer that honours that quoting.
env_unit_lookup() {
	command -v systemctl >/dev/null 2>&1 || return 1
	local out="" rc=0 tok
	out="$(systemctl show -p Environment --value -- "$SHCPD_UNIT" 2>/dev/null | head -c "$MAX_ENV_FILE_BYTES")" || rc=$?
	(( rc == 0 )) || return 1
	out="${out%$'\n'}"
	[[ -n "$out" ]] || return 1
	while IFS= read -r -d '' tok; do
		[[ "$tok" == "${1}="* ]] || continue
		printf '%s' "${tok#"${1}="}"
		return 0
	done < <(env_unit_tokenize "$out")
	return 1
}

# env_unit_tokenize <line> — split one Environment= line into NUL-terminated
# tokens, honouring systemd's shell quoting: whitespace separates tokens
# outside quotes; inside "…" whitespace is literal and \" / \\ (any \x) are
# escapes; inside '…' everything is literal; quotes themselves are dropped.
# Bash character walk, no eval, no external tool: the input is another
# process's output and never touches the shell parser.
env_unit_tokenize() {
	local s="$1" i c tok="" q="" have=0 n
	n=${#s}
	for (( i = 0; i < n; i++ )); do
		c="${s:i:1}"
		if [[ -n "$q" ]]; then
			if [[ "$c" == "$q" ]]; then
				q=""
			elif [[ "$q" == '"' && "$c" == "\\" ]] && (( i + 1 < n )); then
				i=$((i + 1)); tok+="${s:i:1}"
			else
				tok+="$c"
			fi
			continue
		fi
		case "$c" in
			'"'|"'") q="$c"; have=1 ;;
			' '|$'\t'|$'\n')
				if (( have )); then printf '%s\0' "$tok"; fi
				tok=""; have=0 ;;
			*) tok+="$c"; have=1 ;;
		esac
	done
	if (( have )); then printf '%s\0' "$tok"; fi
	return 0
}

env_strip_quotes() {
	local v="$1"
	v="${v%"${v##*[![:space:]]}"}"
	if (( ${#v} >= 2 )); then
		if [[ "${v:0:1}" == '"' && "${v: -1}" == '"' ]]; then
			v="${v:1:${#v}-2}"
		elif [[ "${v:0:1}" == "'" && "${v: -1}" == "'" ]]; then
			v="${v:1:${#v}-2}"
		fi
	fi
	printf '%s' "$v"
}

# env_resolved <KEY> — 0 resolved, 1 UNRESOLVED.
#
# Unresolved is not "absent from panel.env". It is absent from every layer, OR
# empty after quote-strip, OR still carrying the installer's !PLACEHOLDER!
# substitution marker. That third clause is the one that matters: the shipped
# .env sets APP_SECRET, BACKUP_CREDENTIAL_KEY and SMARTHOST_CREDENTIAL_KEY to
# deliberately-invalid placeholders so a box that never received the real key
# fails loudly — and a presence-and-non-empty predicate reports every one of
# them satisfied on exactly that box.
env_resolved() {
	local key="$1" f val="" got=1 raw
	for f in "${PANEL_LINK}/.env" "${PANEL_LINK}/.env.local" \
			"${PANEL_LINK}/.env.prod" "${PANEL_LINK}/.env.prod.local"; do
		raw="$(env_file_lookup "$f" "$key")" || continue
		val="$raw"
		got=0
	done
	raw="$(env_unit_lookup "$key")" || raw=""
	if [[ -n "$raw" ]]; then
		val="$raw"
		got=0
	fi
	(( got == 0 )) || return 1
	val="$(env_strip_quotes "$val")"
	[[ -n "$val" ]] || return 1
	[[ "$val" =~ ^!.*!$ ]] && return 1
	return 0
}

# vhost_directive_present <conf> <ere>
#   0  present
#   1  absent — the conf was read and does not match (or is not there at all)
#   2  could not read the conf
#   3  the declared pattern is over MAX_DIRECTIVE_RE_LEN and was never applied
#   4  the declared pattern is not a valid ERE — refused when compiled, or by
#      grep itself; either way it was never applied to the conf
#   5  the match did not finish inside RECONCILE_GREP_TIMEOUT
#
# THE FOUR NON-ZERO CODES ARE NOT PEDANTRY, they are two measured bugs. Folding
# every non-zero grep exit into `absent` turns one mistyped character in the
# committed declaration into a finding on 100% of boxes that no operator action
# clears: `grep -E` exits 2 on an invalid ERE, and `directive_re: "a{1,"` scored
# missing_directive/finding against a conf that DID contain the directive.
# Folding over-length into "could not read" printed a detail about a perfectly
# readable file that was simply untrue. A pattern the box cannot apply is a gap
# in COVERAGE, never evidence about the box — see the caller.
vhost_directive_present() {
	local f="${APACHE_SITES_DIR}/${1}" re="$2" sz="" rc=0 vrc=0
	(( ${#re} >= 1 && ${#re} <= MAX_DIRECTIVE_RE_LEN )) || return 3
	# COMPILE THE PATTERN BEFORE TRUSTING A NON-MATCH, because grep's exit code
	# alone cannot tell a typo from an absent directive. GNU grep only refuses
	# SOME malformed EREs (`a[`, `a(`, `a\`, `[[:foo:]]` — exit 2); others it
	# accepts as a LITERAL string and exits 1, indistinguishable from "the conf
	# does not contain this". Measured on grep 3.11: `a{1,` took that second
	# path and scored missing_directive/finding against a conf that did contain
	# the directive. bash's own regcomp is the strict POSIX ERE — it returns 2
	# for exactly the patterns grep waves through, agrees with grep on every
	# valid one including the GNU escapes (\s \b \w \<), and costs no fork.
	# shellcheck disable=SC2319   # the condition IS the command here: [[ ]] returns
	# 0 match, 1 no match, 2 the pattern would not compile — and 2 is the answer
	# being read. The subject is empty on purpose; only compilation is in question.
	[[ "" =~ $re ]] 2>/dev/null || vrc=$?
	(( vrc <= 1 )) || return 4
	# An absent conf is not "could not look" — the directive is definitively not
	# in effect. Reported as missing_directive naming the file.
	[[ -e "$f" ]] || return 1
	[[ -f "$f" && -r "$f" ]] || return 2
	sz="$(stat -c '%s' -- "$f" 2>/dev/null)" || return 2
	[[ "$sz" =~ ^[0-9]+$ ]] || return 2
	(( sz <= MAX_VHOST_BYTES )) || return 2
	if command -v timeout >/dev/null 2>&1; then
		timeout "$RECONCILE_GREP_TIMEOUT" grep -Eq -- "$re" "$f" || rc=$?
	else
		grep -Eq -- "$re" "$f" || rc=$?
	fi
	case "$rc" in
		0)   return 0 ;;
		1)   return 1 ;;
		124) return 5 ;;   # timeout(1)'s code, not grep's
		# grep exit 2. A BACKSTOP NO TEST CAN REACH, and it stays: the compile
		# above uses glibc's regcomp and this uses grep's own matcher, so the
		# day they disagree about a pattern, the answer must still be "we could
		# not look" and never "the directive is absent".
		*)   return 4 ;;
	esac
}

# --- evaluation ---------------------------------------------------------------
RECONCILE_ITEMS=()
RECONCILE_CAVEATS=()
RECONCILE_N_FINDINGS=0
RECONCILE_N_ADVISORIES=0
RECONCILE_N_SKIPPED=0
RECONCILE_N_OK=0
RECONCILE_N_UNKNOWN=0
RECONCILE_DOC=""

# severity is ok | finding | advisory | skip. The four verdicts are the
# contract (R6); the panel's three-value DTO enum maps anything it does not
# recognise to Skip and still COUNTS it, so an `ok` severity from a newer engine
# degrades toward silence-about-a-passing-item, which is the safe direction.
reconcile_add() {   # <id> <kind> <name> <state> <severity> <detail> <why>
	local id="$1" kind="$2" name="$3" state="$4" sev="$5" detail="$6" why="$7"
	case "$sev" in
		finding)  RECONCILE_N_FINDINGS=$((RECONCILE_N_FINDINGS + 1)) ;;
		advisory) RECONCILE_N_ADVISORIES=$((RECONCILE_N_ADVISORIES + 1)) ;;
		skip)     RECONCILE_N_SKIPPED=$((RECONCILE_N_SKIPPED + 1)) ;;
		ok)       RECONCILE_N_OK=$((RECONCILE_N_OK + 1)) ;;
	esac
	if [[ "$state" == "unknown" ]]; then
		RECONCILE_N_UNKNOWN=$((RECONCILE_N_UNKNOWN + 1))
	fi
	# Clamped for the reason health_check_add clamps: jq --arg refuses invalid
	# UTF-8 and this text is built from other people's files.
	detail="$(printf '%s' "$detail" | tr -cd '\11\40-\176' | cut -c1-512)"
	why="$(printf '%s' "$why" | tr -cd '\11\40-\176' | cut -c1-512)"
	RECONCILE_ITEMS+=("$(jq -nc --arg i "$id" --arg k "$kind" --arg n "$name" \
		--arg s "$state" --arg v "$sev" --arg d "$detail" --arg w "$why" \
		'{id: $i, kind: $k, name: $n, state: $s, severity: $v, detail: $d, why: $w}')")
	return 0
}

reconcile_caveat() {
	local c="$1" have
	for have in ${RECONCILE_CAVEATS[@]+"${RECONCILE_CAVEATS[@]}"}; do
		[[ "$have" == "$c" ]] && return 0
	done
	RECONCILE_CAVEATS+=("$c")
	return 0
}

# Layers a FILE READER genuinely cannot see. Declared rather than papered over:
# a compiled .env.local.php short-circuits Dotenv entirely, and an
# EnvironmentFile= on the unit is invisible from here. Where either exists every
# env item is `unknown`, never `ok`.
RECONCILE_ENV_BLIND=0
reconcile_env_blind_probe() {
	RECONCILE_ENV_BLIND=0
	if [[ -e "${PANEL_LINK}/.env.local.php" ]]; then
		RECONCILE_ENV_BLIND=1
		reconcile_caveat "compiled-dotenv"
	fi
	if command -v systemctl >/dev/null 2>&1; then
		local out="" rc=0
		out="$(systemctl show "$SHCPD_UNIT" --property=EnvironmentFiles 2>/dev/null)" || rc=$?
		if (( rc == 0 )) && [[ -n "${out#EnvironmentFiles=}" ]]; then
			RECONCILE_ENV_BLIND=1
			reconcile_caveat "environment-file"
		fi
	fi
	return 0
}

# reconcile_eval_half <parsed-declaration>
reconcile_eval_half() {
	local decl="$1"
	local sep=$'\x1f'
	local id name optional why state sev detail rc kind

	local n_env=0
	n_env="$(jq -r '(.env // []) | length' <<<"$decl" 2>/dev/null)" || n_env=0
	[[ "$n_env" =~ ^[0-9]+$ ]] || n_env=0
	if (( n_env > 0 )); then
		reconcile_env_blind_probe
	fi

	while IFS="$sep" read -r id name optional why; do
		[[ -n "$id" ]] || continue
		kind="$(reconcile_unit_kind "$name")" || continue
		if reconcile_is_exempt "$id"; then
			reconcile_add "$id" "$kind" "$name" exempt skip \
				"exempted on this box by ${RECONCILE_EXEMPT_FILE}" "$why"
			continue
		fi
		state="$(reconcile_unit_state "$name")"
		case "$state" in
			enabled)
				reconcile_add "$id" "$kind" "$name" ok ok "unit present and enabled" "$why" ;;
			masked)
				# The one removal an operator can express in systemd's own
				# vocabulary. It needs no exemption line.
				reconcile_add "$id" "$kind" "$name" masked skip \
					"masked — the operator disabled this deliberately" "$why" ;;
			disabled)
				# NOT skip. A swallowed `systemctl enable` failure leaves exactly
				# this state, so `disabled` is not proof of intent; it is proof
				# something is worth a look.
				reconcile_add "$id" "$kind" "$name" disabled advisory \
					"unit is installed but disabled — mask it or add '${id}' to ${RECONCILE_EXEMPT_FILE} if that was deliberate" "$why" ;;
			absent)
				if [[ "$optional" == "1" ]]; then
					reconcile_add "$id" "$kind" "$name" absent advisory \
						"no unit fragment on this box (LoadState=not-found); this entry is optional" "$why"
				else
					reconcile_add "$id" "$kind" "$name" absent finding \
						"no unit fragment on this box (LoadState=not-found)" "$why"
				fi ;;
			unanswerable)
				# MEASURED, and the reason this branch is not folded into the
				# one below: a shimmed systemctl exiting 1 for everything scored
				# four unknown items, zero caveats, exit 0 — a green "In sync"
				# badge over a check that learned nothing about any unit. The
				# caveat is what stops the document reading complete (the panel's
				# coverage.isComplete() requires an empty caveat list), and it is
				# raised HERE because reconcile_unit_state runs in a subshell.
				reconcile_caveat "systemd-unanswerable"
				reconcile_add "$id" "$kind" "$name" unknown advisory \
					"systemd is present but could not answer for this unit; not evaluated" "$why" ;;
			*)
				reconcile_add "$id" "$kind" "$name" unknown advisory \
					"systemd could not answer for this unit" "$why" ;;
		esac
	# os_family gates in the jq `select` FIRST, exactly as the files kind below:
	# a unit declared for a family this box is not does not apply here, so no
	# item is emitted (not-applicable is not a finding, and not an `ok` either).
	# decl_read normalised the field, so an absent one already reads as every
	# family. The applier's walk gates identically (detect coverage == apply
	# coverage), which is what stops a lying os_family from being decorative.
	done < <(jq -r --arg s "$sep" --arg fam "$OS_FAMILY" '(.units // [])[]
		| select(.os_family | index($fam)) | [
			(.id), (.unit), (if .optional == true then "1" else "0" end),
			((.why // "") | gsub("[[:cntrl:]]"; " "))
		] | join($s)' <<<"$decl" 2>/dev/null || true)

	while IFS="$sep" read -r id name optional why; do
		[[ -n "$id" ]] || continue
		if reconcile_is_exempt "$id"; then
			reconcile_add "$id" env "$name" exempt skip \
				"exempted on this box by ${RECONCILE_EXEMPT_FILE}" "$why"
			continue
		fi
		if (( RECONCILE_ENV_BLIND == 1 )); then
			reconcile_add "$id" env "$name" unknown advisory \
				"a layer this check cannot read is in play (see coverage.caveats) — not evaluated" "$why"
			continue
		fi
		# The detail names the KEY and a verdict word. Never a value, never a
		# diff, never a candidate (SC-044/SC-042).
		if env_resolved "$name"; then
			reconcile_add "$id" env "$name" ok ok "resolved to a non-placeholder value" "$why"
		elif [[ "$optional" == "1" ]]; then
			reconcile_add "$id" env "$name" empty advisory \
				"unresolved after layering (absent, empty, or still the installer placeholder); this entry is optional" "$why"
		else
			reconcile_add "$id" env "$name" empty finding \
				"unresolved after layering (absent, empty, or still the installer placeholder)" "$why"
		fi
	done < <(jq -r --arg s "$sep" '(.env // [])[] | [
			(.id), (.key), (if .optional == true then "1" else "0" end),
			((.why // "") | gsub("[[:cntrl:]]"; " "))
		] | join($s)' <<<"$decl" 2>/dev/null || true)

	local re apply_m
	while IFS="$sep" read -r id name re apply_m optional why; do
		[[ -n "$id" ]] || continue
		if reconcile_is_exempt "$id"; then
			reconcile_add "$id" vhost "$name" exempt skip \
				"exempted on this box by ${RECONCILE_EXEMPT_FILE}" "$why"
			continue
		fi
		# F1 / SC-480: for an edge-auth entry the presence
		# oracle is the FULL managed directive set, NOT the single Include the
		# directive_re matches. A box carrying the Include but missing the SC-424
		# <Location> unsets reads `ok` to the single-regex oracle while forwarding
		# the edge secret into the GPL webmail/phpMyAdmin apps (SC-423/SC-424) — a
		# finding no run ever repairs. reconcile_edge_fullset_ok is the SAME oracle
		# the applier converges against, so detect coverage == apply coverage.
		if [[ "$apply_m" == "edge_auth_include" ]]; then
			if reconcile_edge_fullset_ok "${APACHE_SITES_DIR}/${name}"; then
				reconcile_add "$id" vhost "$name" ok ok "the full SC-423 edge-auth directive set is present" "$why"
			elif [[ "$optional" == "1" ]]; then
				reconcile_add "$id" vhost "$name" missing_directive advisory \
					"the full SC-423 edge-auth block is not present in ${APACHE_SITES_DIR}/${name}; this entry is optional" "$why"
			else
				reconcile_add "$id" vhost "$name" missing_directive finding \
					"the full SC-423 edge-auth block is not present in ${APACHE_SITES_DIR}/${name}" "$why"
			fi
			continue
		fi
		rc=0
		vhost_directive_present "$name" "$re" || rc=$?
		case "$rc" in
			0) reconcile_add "$id" vhost "$name" ok ok "directive present" "$why" ;;
			1)
				if [[ "$optional" == "1" ]]; then
					reconcile_add "$id" vhost "$name" missing_directive advisory \
						"no matching directive in ${APACHE_SITES_DIR}/${name}; this entry is optional" "$why"
				else
					reconcile_add "$id" vhost "$name" missing_directive finding \
						"no matching directive in ${APACHE_SITES_DIR}/${name}" "$why"
				fi ;;
			# 3/4/5 say nothing about the box — the check never ran. They are
			# unknown + a caveat, so the document reports reduced coverage
			# rather than a finding no operator action can clear, and each names
			# its own reason so the fix lands on the DECLARATION where it
			# belongs. The pattern itself never reaches the detail: it comes
			# from a file the panel user owns, and details are rendered.
			3)
				reconcile_caveat "unusable-directive-pattern"
				reconcile_add "$id" vhost "$name" unknown advisory \
					"the declared directive_re is longer than ${MAX_DIRECTIVE_RE_LEN} characters — not evaluated" "$why" ;;
			4)
				reconcile_caveat "unusable-directive-pattern"
				reconcile_add "$id" vhost "$name" unknown advisory \
					"the declared directive_re is not a valid extended regular expression; not evaluated" "$why" ;;
			5)
				reconcile_caveat "unusable-directive-pattern"
				reconcile_add "$id" vhost "$name" unknown advisory \
					"matching the declared directive_re against ${APACHE_SITES_DIR}/${name} did not finish within ${RECONCILE_GREP_TIMEOUT}s — not evaluated" "$why" ;;
			*) reconcile_add "$id" vhost "$name" unknown advisory \
					"could not read ${APACHE_SITES_DIR}/${name} (unreadable, or over ${MAX_VHOST_BYTES} bytes)" "$why" ;;
		esac
	done < <(jq -r --arg s "$sep" '(.vhost // [])[] | [
			(.id), (.conf), (.directive_re), (.apply // ""), (if .optional == true then "1" else "0" end),
			((.why // "") | gsub("[[:cntrl:]]"; " "))
		] | join($s)' <<<"$decl" 2>/dev/null || true)

	# files (updater#109) — a declared installer-owned system file backfilled onto a
	# box that never received it. Fetch-free like every other detector kind: it asks
	# only whether the destination EXISTS, never opening the tarball. os_family gates
	# in the jq `select` FIRST — a file declared for a family this box is not simply
	# does not apply here (the box legitimately lacks it), so no item is emitted, and
	# detect coverage therefore equals apply coverage (which gates identically).
	# Presence is the whole oracle; a present file's content and mode are not this
	# check's business (additive — reconcile never overwrites one, SC-432), so a file
	# reads `ok` whether the installer or an operator put it there.
	local fpath
	while IFS="$sep" read -r id fpath optional why; do
		[[ -n "$id" ]] || continue
		if reconcile_is_exempt "$id"; then
			reconcile_add "$id" file "$fpath" exempt skip \
				"exempted on this box by ${RECONCILE_EXEMPT_FILE}" "$why"
			continue
		fi
		if [[ -e "$fpath" || -L "$fpath" ]]; then
			reconcile_add "$id" file "$fpath" ok ok "the declared installer-owned file is present" "$why"
		elif [[ "$optional" == "1" ]]; then
			reconcile_add "$id" file "$fpath" absent advisory \
				"not present on this box; this entry is optional" "$why"
		else
			reconcile_add "$id" file "$fpath" absent finding \
				"the declared installer-owned file is not present on this box" "$why"
		fi
	done < <(jq -r --arg s "$sep" --arg fam "$OS_FAMILY" '(.files // [])[]
		| select(.os_family | index($fam)) | [
			(.id), (.path), (if .optional == true then "1" else "0" end),
			((.why // "") | gsub("[[:cntrl:]]"; " "))
		] | join($s)' <<<"$decl" 2>/dev/null || true)

	return 0
}

# reconcile_evaluate — sets RECONCILE_DOC and returns the verb's exit code.
# Precedence 1 > 4 > 3 > 0, EXCEPT that a half reporting `undeclared` is not a
# cannot-check: it is cached and rendered. Making it exit 4 would discard the
# very document carrying the "this release predates drift declarations" banner,
# so the banner could never be shown on the boxes that need it.
# reconcile_eval_keyring — DETECT a missing release keyring (the signed-manifest
# trust anchor). Bespoke engine infra, NOT a declared required-state kind: its
# pinned fingerprint must never be a panel-writable declaration field. The
# installer writes the keyring file on every box, so a missing one is a
# tampered or damaged box — a FINDING the engine never repairs: materialising a
# trust anchor from root's gpg keyring (or anywhere else) is not the engine's
# call (SC-530). Without it every panel update refuses manifest_unverifiable,
# so the remedy is a reinstall.
#   present+readable  -> ok (a present keyring's CONTENT is cmd_check's gpgv job, not
#                        ours; we NEVER overwrite one)
#   absent            -> finding (coverage gap; reinstall)
reconcile_eval_keyring() {
	if [[ -r "$RELEASE_KEYRING" ]]; then
		reconcile_add "keyring:shcp-release" keyring "$RELEASE_KEYRING" ok ok \
			"the release keyring is present and readable" \
			"the signed-manifest trust anchor"
		return 0
	fi
	reconcile_add "keyring:shcp-release" keyring "$RELEASE_KEYRING" absent finding \
		"the release keyring is missing — the installer writes it on every box, so this box is tampered or damaged; reconcile never materialises a trust anchor: reinstall" \
		"without the trust anchor every panel update refuses and the box cannot self-update to recover"
	return 0
}


# reconcile_resolve_apache_paths — resolve the Apache facts the vhost/HSTS drift
# oracle reads from OS_FAMILY (SC-046/SC-047/SC-436). deb keeps the panel vhost in
# sites-available under /etc/apache2; EL httpd has no sites-available/-enabled split,
# so the file's presence in /etc/httpd/conf.d IS the enable and the edge-auth conf
# sits at the /etc/httpd conf root. Mirrors shcp-installer config.sh exactly
# (APACHE_SITES_AVAILABLE_DIR -> conf.d on rpm; APACHE_EDGE_AUTH_CONF ->
# ${APACHE_CONF_ROOT}/shcp-edge-auth.conf) — never invent paths. A test override
# (SHCP_UPDATE_APACHE_SITES / SHCP_UPDATE_EDGE_AUTH_CONF) always wins so the suite
# can point either family at a fixture. The reconcile path never shells
# `apachectl -M` (dead on EL), only `apachectl configtest`, which is valid on both
# families — so there is nothing below the family level to branch on here.
reconcile_resolve_apache_paths() {
	local conf_root
	case "$OS_FAMILY" in
		rpm)
			conf_root="/etc/httpd"
			APACHE_SITES_DIR="${SHCP_UPDATE_APACHE_SITES:-/etc/httpd/conf.d}"
			;;
		*)
			conf_root="/etc/apache2"
			APACHE_SITES_DIR="${SHCP_UPDATE_APACHE_SITES:-/etc/apache2/sites-available}"
			;;
	esac
	APACHE_EDGE_AUTH_CONF="${SHCP_UPDATE_EDGE_AUTH_CONF:-${conf_root}/shcp-edge-auth.conf}"
}

reconcile_evaluate() {
	RECONCILE_ITEMS=()
	RECONCILE_CAVEATS=()
	RECONCILE_N_FINDINGS=0
	RECONCILE_N_ADVISORIES=0
	RECONCILE_N_SKIPPED=0
	RECONCILE_N_OK=0
	RECONCILE_N_UNKNOWN=0
	RECONCILE_DOC=""

	local cov_engine="" cov_release="" cannot=0

	osf_detect_family
	# SC-046/SC-047/SC-436 parity: family detection resolves the Apache facts
	# before either the vhost/HSTS drift oracle or the applier reads them.

	reconcile_exempt_load
	if [[ -n "$RECONCILE_EXEMPT_REFUSED" ]]; then
		reconcile_add "exemptions" exemptions "$RECONCILE_EXEMPT_FILE" refused advisory \
			"$RECONCILE_EXEMPT_REFUSED" \
			"a record that can silence findings must be root-owned, or the account being watched can switch the detector off"
	fi

	local decl_engine="" decl_release="" rc n_engine=0 n_release=0

	rc=0
	decl_engine="$(decl_read "$DECL_ENGINE_FILE")" || rc=$?
	case "$rc" in
		0) cov_engine="evaluated" ;;
		3) cov_engine="undeclared" ;;
		*) cov_engine="unreadable"; cannot=1 ;;
	esac

	rc=0
	decl_release="$(decl_read "$DECL_RELEASE_FILE")" || rc=$?
	case "$rc" in
		0) cov_release="evaluated" ;;
		3) cov_release="undeclared" ;;
		*) cov_release="unreadable"; cannot=1 ;;
	esac

	if [[ "$cov_engine" == "evaluated" ]]; then
		n_engine="$(jq -r '((.units // []) | length) + ((.env // []) | length) + ((.vhost // []) | length) + ((.files // []) | length)' <<<"$decl_engine" 2>/dev/null)" || n_engine=0
		reconcile_eval_half "$decl_engine"
	fi
	if [[ "$cov_release" == "evaluated" ]]; then
		n_release="$(jq -r '((.units // []) | length) + ((.env // []) | length) + ((.vhost // []) | length) + ((.files // []) | length)' <<<"$decl_release" 2>/dev/null)" || n_release=0
		reconcile_eval_half "$decl_release"
	fi
	[[ "$n_engine" =~ ^[0-9]+$ ]] || n_engine=0
	[[ "$n_release" =~ ^[0-9]+$ ]] || n_release=0

	# No systemd at all is not "no drift": every unit item would be unknown and
	# the answer would be an assertion about a layer we never reached.
	if ! command -v systemctl >/dev/null 2>&1; then
		if (( n_engine + n_release > 0 )); then
			reconcile_caveat "no-systemd"
			cannot=1
		fi
	fi

	# THE INVARIANT, and the only one that holds for a kind nobody has written
	# yet: an item nobody could evaluate must never be able to sit inside a
	# document that reads complete. Every specific probe above is a promise some
	# future kind will forget to make — the env kind had one, timer and vhost
	# did not, and the gap shipped as a green badge over four unevaluated units.
	# This is deliberately NOT `cannot=1`: exit 4 makes the panel DISCARD the
	# document and keep its previous cache, which on a box that was clean
	# yesterday is the same false green by a longer route.
	if (( RECONCILE_N_UNKNOWN > 0 )); then
		reconcile_caveat "items-not-evaluated"
	fi

	# The release keyring is the manifest trust anchor — engine infra that lives
	# OUTSIDE the two declarations, so its pinned fingerprint is never a
	# panel-writable declaration field (SC-530). The installer writes the anchor at
	# the same /usr/share/keyrings path on both families, so this runs on rpm too.
	reconcile_eval_keyring

	reconcile_doc_build "$cov_engine" "$cov_release" "$n_engine" "$n_release"

	if (( cannot == 1 )); then
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi
	if (( RECONCILE_N_FINDINGS > 0 )); then
		return "$RECONCILE_EXIT_FINDINGS"
	fi
	# Advisories alone exit 0 on purpose. A signal that is red on every box is
	# an alert nobody reads, and an unread alert is how phantom units survive.
	return "$RECONCILE_EXIT_OK"
}

reconcile_doc_build() {   # <cov-engine> <cov-release> <n-engine> <n-release>
	local cov_e="$1" cov_r="$2" n_e="$3" n_r="$4"
	local items='[]' truncated=false caveats='[]' engine_ver="" panel_ver=""

	if (( ${#RECONCILE_ITEMS[@]} > 0 )); then
		items="$(printf '%s\n' "${RECONCILE_ITEMS[@]}" | jq -sc '.' 2>/dev/null)" || items='[]'
	fi
	if (( ${#items} > MAX_RECONCILE_ITEMS_BYTES )); then
		# Drop the passing items first — they are the ones nobody acts on — and
		# say so, rather than silently shortening a list a surface will render.
		items="$(jq -c '[.[] | select(.severity != "ok")] | .[0:200]' <<<"$items" 2>/dev/null)" || items='[]'
		truncated=true
	fi
	if (( ${#RECONCILE_CAVEATS[@]} > 0 )); then
		caveats="$(printf '%s\n' "${RECONCILE_CAVEATS[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))' 2>/dev/null)" || caveats='[]'
	fi
	# The installed engine version, resolved per family (dpkg-query on deb, rpm -q on
	# rpm) — the same helper self-update uses, so the drift doc reports it on EL too.
	engine_ver="$(osf_selfupdate_installed)"
	panel_ver="$(panel_current_version)"

	RECONCILE_DOC="$(jq -nc \
		--argjson sv "$RECONCILE_SCHEMA" \
		--arg now "$(now_utc)" \
		--arg ev "$engine_ver" --arg pv "$panel_ver" --arg fam "$OS_FAMILY" \
		--arg ce "$cov_e" --arg cr "$cov_r" \
		--arg pe "$DECL_ENGINE_FILE" --arg pr "$DECL_RELEASE_FILE" \
		--argjson de "$n_e" --argjson dr "$n_r" \
		--argjson cav "$caveats" \
		--argjson f "$RECONCILE_N_FINDINGS" --argjson a "$RECONCILE_N_ADVISORIES" \
		--argjson s "$RECONCILE_N_SKIPPED" --argjson o "$RECONCILE_N_OK" \
		--argjson u "$RECONCILE_N_UNKNOWN" \
		--argjson items "$items" --argjson tr "$truncated" '
		{schema_version: $sv, generated_at: $now,
		 engine_version: (if $ev == "" then null else $ev end),
		 panel_version: (if $pv == "" then null else $pv end),
		 os_family: $fam,
		 coverage: {
		   engine:  {state: $ce, path: $pe, declared: $de},
		   release: {state: $cr, path: $pr, declared: $dr},
		   caveats: $cav,
		   scope: "this box only"
		 },
		 summary: {findings: $f, advisories: $a, skipped: $s, ok: $o, unknown: $u},
		 items: $items,
		 truncated: $tr}')" || RECONCILE_DOC=""
	if [[ -z "$RECONCILE_DOC" ]]; then
		# Last resort: a document that says it could not describe itself is still
		# better than empty stdout, which a consumer cannot distinguish from a
		# crash.
		RECONCILE_DOC='{"schema_version":1,"coverage":{"engine":{"state":"unreadable"},"release":{"state":"unreadable"},"caveats":["report-assembly-failed"],"scope":"this box only"},"summary":{"findings":0,"advisories":0,"skipped":0,"ok":0,"unknown":0},"items":[],"truncated":true}'
	fi
	return 0
}

# reconcile_health_summary — the §7.3 attachment, and NOTHING more. It is not a
# checks[] entry, it does not pass through health_add_diffed, it cannot touch
# HEALTH_ALL_OK and it cannot arm an auto-rollback. Drift is a standing property
# of the box; rolling a release back does not fix it, and a `false` outside the
# resource checks is what arms the rollback of the release that INTRODUCED the
# requirement.
reconcile_health_summary() {
	local rc=0 out=""
	reconcile_evaluate >/dev/null 2>&1 || rc=$?
	[[ -n "$RECONCILE_DOC" ]] || return 1
	out="$(jq -c --argjson max "$DRIFT_NAMES_MAX" '
		{findings: (.summary.findings // 0),
		 advisories: (.summary.advisories // 0),
		 skipped: (.summary.skipped // 0),
		 coverage: {engine: (.coverage.engine.state // "unknown"),
		            release: (.coverage.release.state // "unknown")},
		 names: ([.items[]? | select(.severity == "finding") | .name] | .[0:$max]),
		 truncated: ((([.items[]? | select(.severity == "finding")] | length) > $max)
		             or (.truncated // false))}' <<<"$RECONCILE_DOC" 2>/dev/null)" || return 1
	[[ -n "$out" ]] || return 1
	if (( ${#out} > MAX_DRIFT_SUMMARY_BYTES )); then
		out="$(jq -c '.names = [] | .truncated = true' <<<"$out" 2>/dev/null)" || return 1
	fi
	printf '%s' "$out"
	return 0
}

# =============================================================================
# UPD-14 task 2 — the applier (`reconcile-config --apply`), the WRITE half.
# =============================================================================
# The sibling of `--check` above. It computes the absent set with the SAME
# declaration and predicates the detector uses (decl_read / reconcile_evaluate /
# reconcile_unit_state / env_resolved / vhost_directive_present) and then writes
# — but ONLY the additive, operator-intent-preserving cases SC-432 permits, and
# ONLY where the detector reads (SC-480). Where it
# cannot target what the detector reads, or cannot fully cover a multi-part
# control, it changes nothing and records a COVERAGE GAP.
#
# The step numbers are §4.13's, and the ORDER is the safety property:
#   0  take the run flock (refuse under a live run — the run may be about to
#      change the very release we reconcile against)
#   1  fetch + signature/sha verify the installer artifact BEFORE reading a byte
#      of it (SC-064/207); an unverified artifact aborts changing nothing
#   2  archive every file the verb will touch, write-once, completion marker
#      LAST (SC-319/AD-5) — a complete archive lets an interrupted run continue
#   3  apply the additive cases (units / env / vhost / files), each writing where the
#      detector reads
#   6  re-run the REAL detector; a WRITE that did not converge ABORTS the run
#      rather than being retried on the next timer (SC-319)
#   7  a durable record OUTSIDE runs/ (run_prune deletes run dirs)
#
# NEVER reachable from run_stages (SC-432): run_stages short-circuits an empty
# work set to finalize — skipping the applier on exactly the quiet fully-patched
# boxes furthest behind — stage_self_update execs a replacement process, and
# auto-rollback reverts the apt diff, which does not model config at all.

# Durable record + the write-once archive live OUTSIDE runs/: run_prune deletes
# run dirs past RUN_KEEP, so a record kept there is gone within days and "why
# does this box carry an engine-minted secret" becomes archaeology. Mirrors the
# UU migration record under migrations/.
RECONCILE_APPLY_DIR="${SHCP_UPDATE_RECONCILE_DIR:-${SHCP_UPDATE_STATE_DIR}/reconcile}"

# The persistent unit-install root `systemctl enable` writes enable-symlinks
# into: under it live the <target>.wants/ and <target>.requires/ directories that
# hold `WantedBy=`/`RequiredBy=` install links. Same path on deb and rpm.
# Overridable so the suite exercises the ledger + orphan cleanup against a fixture
# tree without touching (or even reading) the real host.
RECONCILE_SYSTEMD_ETC="${SHCP_UPDATE_SYSTEMD_ETC:-/etc/systemd/system}"

# The root-only 0640 file that holds the whole edge-auth RequestHeader pair
# (SC-423). Installer path is ${APACHE_CONF_ROOT}/shcp-edge-auth.conf; on deb
# APACHE_CONF_ROOT is /etc/apache2. It must NOT live in conf-available/conf-
# enabled — those are included at SERVER level and would stamp the header on
# tenant vhosts, handing the secret to tenant PHP. Overridable for tests.
APACHE_EDGE_AUTH_CONF=""

# The apachectl/httpd config-parse gate for the vhost rewrite (SC-029).
# Overridable so the suite can point it at a shim without a live Apache.
APACHE_CTL="${SHCP_UPDATE_APACHECTL:-apachectl}"

# The one template the applier renders from the verified artifact — the file
# that IS the secret. Read only AFTER artifact_fetch_verify. Relative to the
# tarball root; the vhost directive block is engine-authored (as the installer's
# insert_edge_auth_block is), so it is the ONLY template text ever extracted.
RECONCILE_EDGE_TEMPLATE_REL="samples/apache/shcp-edge-auth.conf"

# Generator allow-list (SC-447). A declaration's env `generate.method` selects
# the routine that mints an ABSENT key; a literal value is NEVER honoured —
# secrets do not ride a tarball. `api_key` = 32 alphanumeric, matching the
# installer's api_key_length=32 (NOT the ≥24 floor: that is the ACCEPT bound for
# a value already present, not the LENGTH to mint).
RECONCILE_APIKEY_LEN=32

# A single archived file is capped: these are config files, not tarballs, and an
# unbounded copy of an attacker-grown vhost would be a memory/disk foot-gun.
MAX_RECONCILE_ARCHIVE_BYTES=$((4 * 1024 * 1024))

# Populated across an apply. Declared here so a `set -u` reference before the
# first append does not abort.
RECONCILE_APPLY_ACTIONS=()
RECONCILE_APPLY_ABORT=""
RECONCILE_TEMPLATE_DIR=""

# reconcile_action <id> <kind> <name> <action> <detail>
# action is one of: enabled | minted | inserted | installed | skip | coverage_gap.
# enabled/minted/inserted/installed are WRITES (convergence must
# confirm them); skip is operator intent left alone; coverage_gap is a fail-closed
# non-write the detector will keep reporting until the real fix lands.
#
# `id` is the declaration id (P2). Convergence matches a written item back to the
# detector's finding BY id, not by name: `name` collides within a kind (a second
# vhost entry on the same 000-default*.conf — shcp-updater#20 — would spuriously
# abort every run), whereas the declaration id is unique per entry.
reconcile_action() {
	local id="$1" kind="$2" name="$3" action="$4" detail="$5"
	detail="$(printf '%s' "$detail" | tr -cd '\11\40-\176' | cut -c1-512)"
	RECONCILE_APPLY_ACTIONS+=("$(jq -nc --arg i "$id" --arg k "$kind" --arg n "$name" \
		--arg a "$action" --arg d "$detail" \
		'{id: $i, kind: $k, name: $n, action: $a, detail: $d}')")
	return 0
}

# reconcile_installer_artifact <manifest-file> — emit "url<TAB>sig_url<TAB>sha256"
# for the RUNNING series' installer half of THIS OS family, or return 1. Every
# gate mirrors panel_target: https only, a sha256 that is one, and the field is
# signed data, never trusted data — a malformed one fails here, not as a curl
# argument.
reconcile_installer_artifact() {
	local manifest="$1" current series entry deb url sig sha
	current="$(panel_current_version)"
	ver_valid "$current" || { engine_log "reconcile: current panel version unknown — cannot pick the installer series"; return 1; }
	series="$(ver_series "$current")"
	entry="$(jq -c --arg s "$series" \
		'[.series[]? | select(.series == $s)] | first // empty' "$manifest" 2>/dev/null || true)"
	[[ -n "$entry" ]] || { engine_log "reconcile: series ${series} is not in the manifest"; return 1; }
	deb="$(jq -c --arg f "$OS_FAMILY" '.installer[$f] // empty' <<<"$entry" 2>/dev/null || true)"
	[[ -n "$deb" ]] || { engine_log "reconcile: manifest carries no installer.${OS_FAMILY} half for series ${series}"; return 1; }
	url="$(jq -r '.url // empty' <<<"$deb")"
	sig="$(jq -r '.sig_url // empty' <<<"$deb")"
	sha="$(jq -r '.sha256 // empty' <<<"$deb")"
	if [[ "$url" != https://* || "$sig" != https://* ]]; then
		engine_log "reconcile: non-https installer artifact URL in the manifest"; return 1
	fi
	if [[ ! "$sha" =~ ^[0-9a-fA-F]{64}$ ]]; then
		engine_log "reconcile: malformed installer sha256 in the manifest"; return 1
	fi
	printf '%s\t%s\t%s' "$url" "$sig" "$sha"
	return 0
}

# installer_artifact_links_absent <held artifact> — 0 iff the verified installer
# tarball carries no symlink and no hard link. Every consumer reads the extracted
# tree BY PATH as root (templates rendered, payloads installed, shcp-converge
# executed), so a link member is a way to point that read at something the
# signature never covered. Refused before extraction (SC-693; shared with
# converge-installer, shcp-updater#186). A failed listing is a refusal too.
installer_artifact_links_absent() {
	local types
	types="$(tar -tvzf "$1" 2>/dev/null | cut -c1 | sort -u | tr -d '\n')" || return 1
	[[ -n "$types" && "$types" != *l* && "$types" != *h* ]]
}

# installer_tree_root <extracted dir> — print the installer tree root inside an
# extracted installer artifact. shcp-build publishes the tree under ONE top-level
# shcp-installer/ directory (every member of the 0.0.59 deb and rpm tarballs is
# shcp-installer/…; tests/fixtures/installer-artifact/ holds that listing), so a
# consumer that reads <extracted>/samples/… finds nothing on a real box
# (shcp-updater#186). Accepted, in this order:
#   - shcp-installer/ as the ONLY top-level entry, a real directory -> that dir;
#   - a flat tree (no shcp-installer entry, not a lone directory)  -> <extracted>.
# Refused (1, nothing printed): an empty extraction; shcp-installer beside any
# other top-level entry (which one is the tree is a guess); a shcp-installer that
# is a symlink or not a directory; a lone top-level directory of any other name
# (a wrapper we do not know is not a flat tree either).
installer_tree_root() {
	local dir="$1" wrap n ndirs
	[[ -d "$dir" && ! -L "$dir" ]] || return 1
	n="$(find "$dir" -mindepth 1 -maxdepth 1 -printf '.' 2>/dev/null | wc -c)" || return 1
	wrap="${dir}/shcp-installer"
	if [[ -e "$wrap" || -L "$wrap" ]]; then
		if (( n != 1 )); then
			engine_log "installer artifact: shcp-installer/ is not the only top-level entry — ambiguous layout, refusing"
			return 1
		fi
		if [[ -L "$wrap" || ! -d "$wrap" ]]; then
			engine_log "installer artifact: top-level shcp-installer is not a plain directory — refusing"
			return 1
		fi
		printf '%s' "$wrap"
		return 0
	fi
	if (( n == 0 )); then
		engine_log "installer artifact: extracted to nothing — refusing"
		return 1
	fi
	ndirs="$(find "$dir" -mindepth 1 -maxdepth 1 -type d -printf '.' 2>/dev/null | wc -c)" || return 1
	if (( n == 1 && ndirs == 1 )); then
		engine_log "installer artifact: wrapped in a top-level directory other than shcp-installer/ — unknown layout, refusing"
		return 1
	fi
	printf '%s' "$dir"
	return 0
}

# reconcile_fetch_templates <dest-dir> — fetch the signed manifest, resolve the
# installer artifact, verify it (SC-064/207), extract it and put the installer
# TREE ROOT at dest-dir (installer_tree_root: the published shcp-installer/
# wrapper is unwrapped, so dest-dir/samples/… is what callers read). 0 on success
# (dest-dir is the tree root), 1 on any failure — in which case NOTHING on the
# box has been read or changed and the caller aborts. dest-dir must not exist
# yet. Callers read the extracted tree by path and install from it as root, so
# dest-dir belongs inside a private_scratch_dir; the manifest, the artifact and
# the extraction land beside it.
reconcile_fetch_templates() {
	local dest="$1" manifest tarball spec url sig sha extract root
	[[ ! -e "$dest" && ! -L "$dest" ]] || { engine_log "reconcile: ${dest} already exists — refusing to extract over it"; return 1; }
	mkdir -p -- "$(dirname -- "$dest")" || return 1
	# manifest_fetch writes the VERIFIED manifest to the path we hand it (it does
	# not echo it): fetch, gpgv against the pinned primary, shape + SC-472 replay.
	manifest="${dest}.manifest.json"
	if ! manifest_fetch "$manifest"; then
		engine_log "reconcile: manifest fetch/verify failed"
		rm -f "$manifest" 2>/dev/null || true
		return 1
	fi
	if ! spec="$(reconcile_installer_artifact "$manifest")"; then
		rm -f "$manifest" 2>/dev/null || true
		return 1
	fi
	rm -f "$manifest" 2>/dev/null || true
	IFS=$'\t' read -r url sig sha <<<"$spec"
	tarball="${dest}.artifact"
	# artifact_fetch_verify is the ONE trust boundary: size cap, gpgv against the
	# pinned release primary, sha256, gzip mime, no traversing members — in that
	# order, before tar reads a byte.
	if ! artifact_fetch_verify "$url" "$sig" "$sha" "$tarball"; then
		rm -f "$tarball" "${tarball}.sig" 2>/dev/null || true
		return 1
	fi
	if ! installer_artifact_links_absent "$ARTIFACT_HELD"; then
		artifact_close
		engine_log "reconcile: the verified installer artifact carries a symlink or hard link — refusing to read templates from it"
		rm -f "$tarball" "${tarball}.sig" 2>/dev/null || true
		return 1
	fi
	# Extract the inode artifact_fetch_verify checked, never the path.
	extract="${dest}.extract"
	local extract_rc=0
	rm -rf -- "$extract" 2>/dev/null || true
	install -d -m 0700 -- "$extract" || { artifact_close; rm -f "$tarball" "${tarball}.sig" 2>/dev/null || true; return 1; }
	# --no-same-permissions as well as --no-same-owner (converge-installer passes
	# both): root's tar defaults to -p, so a tarball member's recorded mode would
	# otherwise land verbatim — a 0666/setuid member under a 0700 scratch dir —
	# and reconcile_apply_payload installs from this tree at the DECLARED mode
	# only; the extracted mode must never be one it could mistake for that.
	tar xzf "$ARTIFACT_HELD" -C "$extract" --no-same-owner --no-same-permissions 2>/dev/null || extract_rc=$?
	artifact_close
	rm -f "$tarball" "${tarball}.sig" 2>/dev/null || true
	if (( extract_rc != 0 )); then
		engine_log "reconcile: installer artifact did not extract"
		rm -rf -- "$extract" 2>/dev/null || true
		return 1
	fi
	if ! root="$(installer_tree_root "$extract")" || ! mv -T -- "$root" "$dest"; then
		engine_log "reconcile: no usable installer tree in the artifact"
		rm -rf -- "$extract" "$dest" 2>/dev/null || true
		return 1
	fi
	rm -rf -- "$extract" 2>/dev/null || true
	return 0
}

# --- write-once archive (SC-319 / AD-5) ---------------------------------------
# Archive the pre-change content of every file the verb may touch BEFORE any
# applier writes, so what is archived is true pre-state.
#
# ONE ARCHIVE DIRECTORY PER APPLY ATTEMPT SERIES, not one for the box's
# lifetime. The first cut kept a single archive/ whose COMPLETE marker made
# every later reconcile_archive_run return 0 — so only the FIRST --apply ever
# run on a box archived anything, and every apply after it (a new release
# declaring a new vhost block, payload or env key, months later) rewrote the
# vhost, re-rendered the edge conf and appended to panel.env with NOTHING
# archived. The comment conflated "re-run after an interruption" (must resume
# and must not re-snapshot changed files) with "any later run" (a new series:
# new pre-state, new archive).
#
#   archive/current      names the series in progress (a bare dir name). While
#                        it exists, every apply attempt RESUMES that archive:
#                        members already indexed are skipped (write-once; the
#                        SC-432/SC-319 convergence rule), new candidates are
#                        appended, COMPLETE is written once all are in.
#   archive/<stamp>/     one series. files/<n> or files/<n>.absent per member,
#                        index.json (the authority on what is in), COMPLETE.
#
# The series ENDS when the apply converges and its record is written
# (reconcile_archive_close clears `current`); the next apply opens a fresh
# stamped directory. An abort (non-convergence, archive or fetch failure)
# leaves `current` in place so the retry continues the same series. The last
# RECONCILE_ARCHIVE_KEEP series are kept, older ones pruned.
RECONCILE_ARCHIVE_DIR=""
RECONCILE_ARCHIVE_KEEP=10
# The only shape a series name can have. `current`'s content and every
# directory name under archive/ are held to it before they are joined to a path
# or handed to rm -rf: the tree is root-only (SC-663), but a name is still data.
RECONCILE_ARCHIVE_NAME_RE='^[0-9]{8}T[0-9]{6}Z(-[0-9]{1,6})?$'

# reconcile_archive_open — print the archive directory for THIS apply attempt:
# the series `current` names when it is intact (resume), else a fresh stamped
# one, which becomes `current`. 1 on any failure (nothing printed).
reconcile_archive_open() {
	local root="${RECONCILE_APPLY_DIR}/archive" cur name="" cand n=0
	cur="${root}/current"
	install -d -m 0700 "$RECONCILE_APPLY_DIR" "$root" || return 1
	if [[ -f "$cur" && ! -L "$cur" ]]; then
		name="$(head -c 64 -- "$cur" 2>/dev/null | tr -d '\n')" || name=""
		if [[ "$name" =~ $RECONCILE_ARCHIVE_NAME_RE && -d "${root}/${name}" && ! -L "${root}/${name}" ]]; then
			printf '%s' "${root}/${name}"
			return 0
		fi
		# A pointer that fails the grammar or names nothing is not resumed —
		# there is no archive to resume INTO. A fresh series replaces it.
		engine_log "reconcile: archive/current does not name an intact series — starting a new one"
	fi
	name="$(date -u +%Y%m%dT%H%M%SZ)"
	cand="$name"
	while [[ -e "${root}/${cand}" || -L "${root}/${cand}" ]]; do
		n=$((n + 1))
		cand="${name}-${n}"
	done
	install -d -m 0700 "${root}/${cand}" || return 1
	printf '%s\n' "$cand" | atomic_write "$cur" 0600 || return 1
	printf '%s' "${root}/${cand}"
	return 0
}

# reconcile_archive_close — END the series: the apply converged and its record
# is on disk, so the next apply gets a fresh archive. Then prune to the last
# RECONCILE_ARCHIVE_KEEP series. Never called on an abort.
reconcile_archive_close() {
	rm -f -- "${RECONCILE_APPLY_DIR}/archive/current" 2>/dev/null || true
	reconcile_archive_prune
	return 0
}

# reconcile_archive_prune — keep the newest RECONCILE_ARCHIVE_KEEP series (names
# are UTC stamps, so lexical order is age order) plus whichever `current` still
# names; remove the rest. Only names matching the grammar are ever removed.
reconcile_archive_prune() {
	local root="${RECONCILE_APPLY_DIR}/archive" open_name="" name d n=0
	[[ -d "$root" ]] || return 0
	if [[ -f "${root}/current" && ! -L "${root}/current" ]]; then
		open_name="$(head -c 64 -- "${root}/current" 2>/dev/null | tr -d '\n')" || open_name=""
	fi
	while IFS= read -r name; do
		[[ "$name" =~ $RECONCILE_ARCHIVE_NAME_RE ]] || continue
		[[ "$name" == "$open_name" ]] && continue
		n=$((n + 1))
		(( n > RECONCILE_ARCHIVE_KEEP )) || continue
		d="${root}/${name}"
		[[ -d "$d" && ! -L "$d" ]] || continue
		rm -rf -- "$d" 2>/dev/null || engine_log "reconcile: could not prune archive ${d}"
	done < <(find "$root" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | sort -r)
	return 0
}

# reconcile_archive_run <archive-dir> <path>... — bring the series' archive up
# to the candidate list. The INDEX is the authority on what is archived: a
# member is in the archive only once its index entry is on disk, and the index
# is rewritten atomically after EACH copy, so a copy cut off mid-write is taken
# again and a finished one is never overwritten with post-change bytes. Members
# already indexed are skipped (write-once); new ones are appended with the next
# slot number; COMPLETE is written once, after everything is in. Safe on a
# resume because the appliers only run after this returns 0 — a member that is
# NOT yet archived is one no applier has touched.
reconcile_archive_run() {   # reconcile_archive_run <archive-dir> <path>...
	local adir="$1"; shift
	local files_dir="${adir}/files" marker="${adir}/COMPLETE" index="${adir}/index.json"
	install -d -m 0700 "$adir" "$files_dir" || return 1
	local idx='[]' n=0 have
	if [[ -f "$index" && ! -L "$index" ]]; then
		# An index that does not parse is treated as EMPTY: nothing in it can
		# be trusted, and nothing has been applied yet, so re-taking every
		# member is the safe answer.
		idx="$(jq -c 'if type == "array" then . else error("shape") end' "$index" 2>/dev/null)" || idx='[]'
		n="$(jq -r 'map(.n // 0) | max // 0' <<<"$idx" 2>/dev/null)" || n=0
		[[ "$n" =~ ^[0-9]+$ ]] || n=0
	fi
	local p sz sha existed slot
	for p in "$@"; do
		[[ -n "$p" ]] || continue
		# Already archived by an earlier pass of this series: indexed AND its
		# slot is on disk. Keep it — those are the pre-change bytes.
		have="$(jq -r --arg p "$p" '[.[] | select(.path == $p) | .n] | first // empty' <<<"$idx" 2>/dev/null)" || have=""
		if [[ "$have" =~ ^[0-9]+$ ]] && [[ -e "${files_dir}/${have}" || -e "${files_dir}/${have}.absent" ]]; then
			continue
		fi
		n=$((n + 1))
		slot="${files_dir}/${n}"
		if [[ -f "$p" ]]; then
			sz="$(stat -c '%s' -- "$p" 2>/dev/null || echo 0)"
			if [[ ! "$sz" =~ ^[0-9]+$ ]] || (( sz > MAX_RECONCILE_ARCHIVE_BYTES )); then
				engine_log "reconcile: ${p} is too large to archive safely — aborting before any change"
				return 1
			fi
			cp -p -- "$p" "$slot" || return 1
			sha="$(sha256sum "$slot" 2>/dev/null | awk '{print $1}')"
			existed=true
		else
			: > "$slot.absent"
			sha=""
			existed=false
		fi
		idx="$(jq -c --argjson x "$idx" --arg p "$p" --argjson e "$existed" \
			--arg s "$sha" --argjson n "$n" \
			'$x + [{n: $n, path: $p, existed: $e, sha256: (if $s == "" then null else $s end)}]' \
			<<<"null" 2>/dev/null)" || return 1
		# The index lands atomically AFTER the slot, so the entry and the bytes
		# it vouches for are never on disk without each other.
		printf '%s\n' "$idx" | atomic_write "$index" 0600 || return 1
	done
	# COMPLETE is the LAST thing written, and only after the index is on disk.
	if [[ ! -f "$marker" ]]; then
		printf '%s\n' "$(now_utc)" > "$marker" || return 1
	fi
	return 0
}

# reconcile_archive_complete <archive-dir> — 0 iff a finished archive exists.
reconcile_archive_complete() {
	[[ -f "${1}/COMPLETE" ]]
}

# --- appliers (stubs until S2/S3/S4) ------------------------------------------
# Each is handed one declared, still-absent entry and either performs the
# additive write (recording enabled/minted/inserted) or records a
# coverage_gap. In this scaffold every kind is a coverage_gap: the dispatch is
# deliberately empty so the write paths land one reviewable slice at a time,
# and a half-built applier can never silently half-write.

# reconcile_apply_unit <id> <unit> <why> — enable a never-seen declared unit.
#
# The decision to be here was made on the PRE-reload state being exactly
# `absent` (systemctl reported LoadState=not-found): that guarantees no operator
# enable/disable symlink and no [Install] the operator ever touched, so enabling
# cannot override an operator choice (SC-432). A not-found verdict also covers a
# fragment dpkg has DROPPED but systemd has not yet loaded — the engine's update
# path only daemon-reloads, never enables (§4.13) — so reload first to make such
# a fragment visible, then enable.
#
# The verb NEVER writes a unit file (SC-432): if no fragment exists to enable,
# `systemctl enable` fails and this is a COVERAGE GAP, not a hand-authored unit.
# The installer .deb owns the file; delivering it is a different problem, out of
# scope here.
reconcile_apply_unit() {
	local id="$1" unit="$2" kind
	kind="$(reconcile_unit_kind "$unit")" || return 1
	if ! command -v systemctl >/dev/null 2>&1; then
		reconcile_action "$id" "$kind" "$unit" coverage_gap "systemd is not present — cannot enable"
		return 0
	fi
	systemctl daemon-reload >/dev/null 2>&1 || true
	# Split enable from start so a fragment that IS installed but crashes on start
	# is not misdiagnosed as "fragment not installed": only `enable` failing means
	# there is no [Install]/fragment to enable (the installer .deb must deliver it).
	if ! systemctl enable -- "$unit" >/dev/null 2>&1; then
		reconcile_action "$id" "$kind" "$unit" coverage_gap \
			"systemctl enable failed — no unit fragment to enable on this box; the installer .deb must deliver it first"
		return 0
	fi
	# The detector's verdict is enablement (UnitFileState), not liveness, so the
	# unit is now converged whether or not it starts cleanly. Start it too, but a
	# start failure is a note on an enabled unit, never a "fragment missing" gap.
	if systemctl start -- "$unit" >/dev/null 2>&1; then
		reconcile_action "$id" "$kind" "$unit" enabled "enabled and started a never-seen declared unit (was LoadState=not-found)"
	else
		reconcile_action "$id" "$kind" "$unit" enabled "enabled a never-seen declared unit; it did not start cleanly — enabled for next boot, check its status"
	fi
	return 0
}

# reconcile_env_target — echo the canonical file the detector reads AND the
# applier may write, or return 1. THE master-invariant chokepoint (F1,
# SC-480): the detector resolves a key from the
# ${PANEL_LINK}/.env* layers, reaching /etc/shcp/panel.env ONLY through the
# `.env.local -> panel.env` symlink a full apply-flip creates. So panel.env is a
# valid write target ONLY when that symlink is in place and points at it; write
# anywhere else and the post-apply --check still reads the key absent, the run
# never converges, and the next timer mints a NEW value — rotating the secret
# every run. A box whose canonical panel.env symlink layout is not in place (a
# real <release>/.env.local, no panel.env) has no such target: it returns 1 and
# the caller fails closed to a coverage finding.
reconcile_env_target() {
	local envlocal="${PANEL_LINK}/.env.local"
	# Identity by inode (-ef), not by the symlink's literal text (#26): a RELATIVE
	# `.env.local -> panel.env` resolves to the same file as the absolute symlink the
	# engine creates, so an exact-string readlink compare would fail-close a perfectly
	# good box to a permanent coverage_gap the detector never agrees with. `-L` keeps
	# us on the symlink layout (a real file at .env.local is not the canonical
	# layout and is never a target); the `-f "$PANEL_ENV"` guard keeps -ef from
	# matching a missing/dangling target.
	if [[ -L "$envlocal" && -f "$PANEL_ENV" && "$envlocal" -ef "$PANEL_ENV" ]]; then
		printf '%s' "$PANEL_ENV"
		return 0
	fi
	return 1
}

# reconcile_gen_apikey — 32 alphanumeric, matching the installer's
# api_key_length=32 (generate_random_string 'A-Za-z0-9'). A secret NEVER ships in
# a tarball, so the value is minted here from the declared method, never read
# from a manifest field.
reconcile_gen_apikey() {
	LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c "$RECONCILE_APIKEY_LEN" || true
}

# reconcile_apply_env <id> <key> <generate_method> <why> — mint an ABSENT
# declared key into the canonical env, additively (SC-432).
# --- Shared panel.env write lock (SC-480 residual #21) -----------------------
# Same rendezvous the installer (functions/utils.sh) and the panel (Symfony Lock)
# take: one flock(2) on /etc/shcp/panel.env.lock, so a bash flock and a PHP flock
# exclude. Defence-in-depth (SC-538): refuse a
# symlink or a non-root lock rather than flock through it.

# panel_env_lock_ensure <lock> — make the rendezvous exist, safely, or return 1.
panel_env_lock_ensure() {
	local lock="${1:?}" dir owner
	dir="$(dirname -- "$lock")"
	[[ -d "$dir" ]] || { engine_log "panel.env lock dir $dir is missing"; return 1; }
	if [[ -L "$lock" ]]; then
		engine_log "panel.env lock $lock is a symlink — refusing"
		return 1
	fi
	if [[ ! -e "$lock" ]]; then
		( umask 027; : > "$lock" ) 2>/dev/null || { engine_log "panel.env lock $lock: create failed"; return 1; }
		[[ "$(id -u 2>/dev/null)" == "0" ]] && chown root:root "$lock" 2>/dev/null || true
		chmod 0640 "$lock" 2>/dev/null || true
	fi
	[[ -f "$lock" && ! -L "$lock" ]] || { engine_log "panel.env lock $lock is not a regular file"; return 1; }
	if [[ "$(id -u 2>/dev/null)" == "0" ]]; then
		owner="$(stat -c '%U:%G' "$lock" 2>/dev/null || echo '')"
		[[ "$owner" == "root:root" ]] || { engine_log "panel.env lock $lock owner $owner, not root:root — refusing"; return 1; }
	fi
	return 0
}

# panel_env_lock_acquire — ensure + flock(2) LOCK_EX, bounded wait, fail closed.
panel_env_lock_acquire() {
	local lock="$PANEL_ENV_LOCK" wait="$PANEL_ENV_LOCK_WAIT"
	panel_env_lock_ensure "$lock" || return 1
	exec {PANEL_ENV_LOCK_FD}<>"$lock" || { PANEL_ENV_LOCK_FD=""; return 1; }
	if ! flock -w "$wait" "$PANEL_ENV_LOCK_FD"; then
		exec {PANEL_ENV_LOCK_FD}<&-
		PANEL_ENV_LOCK_FD=""
		engine_log "panel.env lock $lock: not acquired within ${wait}s — failing closed"
		return 1
	fi
	return 0
}

panel_env_lock_release() {
	[[ -n "$PANEL_ENV_LOCK_FD" ]] || return 0
	exec {PANEL_ENV_LOCK_FD}<&-
	PANEL_ENV_LOCK_FD=""
	return 0
}

reconcile_apply_env() {
	local id="$1" key="$2" method="$3" target
	# Only mint where the declaration says to, and only the allowed method. A
	# null/absent generate is detect-only — the applier never invents a value it
	# was not told how to make.
	if [[ "$method" != "api_key" ]]; then
		reconcile_action "$id" env "$key" coverage_gap \
			"no generator declared for this key (generate is null) — reported, never invented"
		return 0
	fi
	if ! target="$(reconcile_env_target)"; then
		# F1: the canonical panel.env symlink layout is not in place. Minting into
		# panel.env here would write where the detector cannot read, so the write
		# would never converge and every run would mint again — rotating the
		# secret. Change nothing.
		reconcile_action "$id" env "$key" coverage_gap \
			"no canonical panel.env the detector reads from (canonical panel.env symlink layout not in place) — not minted; a blind write would rotate every run (F1)"
		return 0
	fi
	# Serialise the whole absent-check → append → read-back against every other
	# panel.env writer (SC-480 #21). The window this closes is exactly: another
	# writer's temp+rename landing between our absence check and our append, so
	# our append lands on a file that is about to be replaced. Bounded wait, fail
	# closed — we mint nothing rather than append unlocked.
	if ! panel_env_lock_acquire; then
		reconcile_action "$id" env "$key" coverage_gap \
			"could not acquire the shared panel.env lock (${PANEL_ENV_LOCK}) within ${PANEL_ENV_LOCK_WAIT}s — not minted (fail-closed)"
		return 0
	fi
	_reconcile_apply_env_locked "$id" "$key" "$target"
	panel_env_lock_release
	return 0
}

# _reconcile_apply_env_locked <id> <key> <target> — the additive write, run with
# the shared panel.env lock held by the caller.
_reconcile_apply_env_locked() {
	local id="$1" key="$2" target="$3" val mode owner last separator=""
	# NEVER overwrite an existing key (it may be a rotated secret): the detector
	# reported it UNRESOLVED, but unresolved covers a present-but-empty or
	# still-placeholder value too. If the key exists in the target at all, leave
	# it — the detector keeps reporting it, which is the honest fail-closed state.
	if env_file_lookup "$target" "$key" >/dev/null 2>&1; then
		reconcile_action "$id" env "$key" coverage_gap \
			"key already present in ${target} but unresolved — never overwritten (may be a rotated secret)"
		return 0
	fi
	val="$(reconcile_gen_apikey)"
	# The charset guard the value is about to cross into config with. Refuse
	# rather than write a short/garbled secret (SC-423 posture: 24+ alnum is the
	# accept floor; we MINT 32).
	if [[ ! "$val" =~ ^[A-Za-z0-9]{24,}$ ]]; then
		reconcile_action "$id" env "$key" coverage_gap "secret generation produced an unusable value — not written"
		return 0
	fi
	# P1 / SC-423 / SC-029: enforce 0640 BEFORE the secret lands (mode-before-content,
	# mirroring reconcile_render_edge_conf's F5 discipline) so the value never sits
	# in a wider-mode file even briefly.
	#
	# No-follow (SC-663, shcp-build#398): db_chmod_nofollow refuses anything but a
	# lone regular file, so a panel.env swapped for a symlink or hardlink cannot
	# redirect a root chmod — or the secret — to its referent. A failure was once
	# `2>/dev/null || true`: silent, and the append went ahead into a file whose
	# mode nothing had set. Now it is logged and nothing is written, because the
	# mode could not be put in place before the content.
	if ! db_chmod_nofollow 0640 "$target"; then
		engine_log "reconcile: could not chmod ${target} 0640 no-follow (not a lone regular file, or python3 absent) — ${key} not minted, SC-423/SC-663"
		reconcile_action "$id" env "$key" coverage_gap \
			"could not set ${target} to 0640 before writing (not a lone regular file, or python3 absent) — not minted"
		return 0
	fi
	# Unprivileged runs (the suites) cannot chown and need not; as root a failure
	# is caught by the post-write owner assert below, which refuses `minted`.
	chown -h "$PANEL_ENV_OWNER" "$target" 2>/dev/null || true
	# Append by the TARGET path, never through the .env.local symlink (F3): a
	# mktemp+rename onto .env.local would replace the symlink with a regular file
	# and break the layout. dotenv takes the last assignment, and the key is
	# provably absent above, so an append is the additive write. A missing final
	# newline needs a separator or this assignment joins the previous value.
	if [[ -s "$target" ]]; then
		if ! last="$(tail -c 1 -- "$target" 2>/dev/null && printf '.')"; then
			reconcile_action "$id" env "$key" coverage_gap "could not read end of ${target} — not minted"
			return 0
		fi
		[[ "$last" == $'\n.' ]] || separator=$'\n'
	fi
	if ! printf '%s%s=%s\n' "$separator" "$key" "$val" >> "$target" 2>/dev/null; then
		reconcile_action "$id" env "$key" coverage_gap "could not append ${key} to ${target}"
		return 0
	fi
	# P1: STAT-assert the mode the secret now sits under (and owner when root). A
	# secret in a file we cannot confirm is 0640 is a disclosure risk — do NOT
	# record `minted`; the detector keeps reporting it until it can be fixed.
	mode="$(stat -c '%a' "$target" 2>/dev/null || echo '')"
	if [[ "$mode" != "640" ]]; then
		reconcile_action "$id" env "$key" coverage_gap \
			"appended ${key} to ${target} but its mode is ${mode}, not 640 — refusing to claim a minted secret"
		return 0
	fi
	if [[ "$(id -u 2>/dev/null)" == "0" ]]; then
		owner="$(stat -c '%U:%G' "$target" 2>/dev/null || echo '')"
		if [[ "$owner" != "$PANEL_ENV_OWNER" ]]; then
			reconcile_action "$id" env "$key" coverage_gap \
				"appended ${key} to ${target} but owner is ${owner}, not ${PANEL_ENV_OWNER} — refusing to claim a minted secret"
			return 0
		fi
	fi
	# Read-back verify under the flock we already hold: the REAL detector must now
	# resolve it via the REAL read path, or we refuse to claim we fixed anything.
	if env_resolved "$key"; then
		reconcile_action "$id" env "$key" minted "minted a ${RECONCILE_APIKEY_LEN}-char api_key into ${target} (the layer the detector reads)"
	else
		reconcile_action "$id" env "$key" coverage_gap \
			"wrote ${key} to ${target} but the detector still cannot resolve it — refusing to claim convergence"
	fi
	return 0
}
# --- S4: the SC-423 edge-auth applier -----------------------------------------
# Three parts, and the convergence check is bound to the SAME directive set the
# writer emits (SC-480 / F2): a block that lands the
# Include but not the <Location> unsets reads healthy to the detector (which
# greps only the Include) while forwarding the edge secret to the two GPL tenant
# apps — the exact disclosure SC-423 prevents. So the block is written as ONE
# marker-anchored, idempotent, configtest-gated whole-block replace, and
# convergence checks EVERY directive the block carries.

RECONCILE_EDGE_MARK_BEGIN="    # >>> shcp-update reconcile edge-auth (SC-423/SC-406/SC-424) — managed block; do not edit between markers"
RECONCILE_EDGE_MARK_END="    # <<< shcp-update reconcile edge-auth"

# The SSL vhost opening (#24). The edge-auth block protects the :443 vhost, so both
# the insert anchor and the convergence oracle key off THIS one pattern (SC-480
# parity: the block is verified where it is placed). `:443` is matched as a WHOLE
# listener token (followed by whitespace or `>`), so a multi-listener opening like
# `<VirtualHost *:443 *:80>` is recognized too — not only `:443>` at the very end.
# Matches `<VirtualHost *:443>`, `1.2.3.4:443`, `_default_:443`, `[::1]:443`,
# `*:443 *:80`. Does NOT match `:4430`/`:4433` (a different port) or a `:80`-only vhost.
RECONCILE_SSL_VHOST_RE='^[[:space:]]*<VirtualHost[[:space:]][^>]*:443([[:space:]>])'

# Registry of EVERY managed vhost-block's markers. A future presence-based vhost
# applier appends its BEGIN and END here. This is the input to the mutual-non-substring
# assertion below (reconcile_vhost_markers_distinct), which every block-writer runs
# before it strips (SC-480 / SC-423 / SC-424).
RECONCILE_VHOST_BLOCK_MARKERS=(
	"$RECONCILE_EDGE_MARK_BEGIN"
	"$RECONCILE_EDGE_MARK_END"
)

# reconcile_vhost_markers_distinct — 0 iff no registered marker is a substring of any
# OTHER registered marker (equal strings included: a duplicate is a substring of itself).
# WHY (security, SC-423/SC-424 disclosure via SC-480 marker-anchoring): the strip in
# reconcile_vhost_block_ensure now matches markers whole-LINE, which alone stops one
# block's strip from deleting another block's region. This assertion is the belt to that
# suspenders and fails LOUD: if two managed blocks ever ship markers in a substring
# relationship, a strip keyed on the shorter marker would (under any regression back
# toward substring matching) match the longer block's marker line, enter the replace
# path, and awk-DELETE the SC-423 edge-auth block — configtest still passes, restore-on-
# fail never fires, X-SHCP-Edge-Auth becomes forgeable. Refusing at the door means that
# collision can never land silently. O(n^2) over a tiny fixed list — run per apply.
reconcile_vhost_markers_distinct() {
	local n=${#RECONCILE_VHOST_BLOCK_MARKERS[@]} i j a b
	for (( i = 0; i < n; i++ )); do
		a="${RECONCILE_VHOST_BLOCK_MARKERS[i]}"
		for (( j = 0; j < n; j++ )); do
			(( i == j )) && continue
			b="${RECONCILE_VHOST_BLOCK_MARKERS[j]}"
			case "$b" in *"$a"*) return 1 ;; esac
		done
	done
	return 0
}

# reconcile_vhost_fullset_ok <vhost-file> <block-text-cmd> <ssl-vhost-re> — the SHARED
# per-vhost convergence oracle (extracted from reconcile_edge_fullset_ok, #67). 0 iff
# EVERY <VirtualHost ...> matching <ssl-vhost-re> carries the FULL directive set the
# block installs. <block-text-cmd> emits the managed block (markers + directives) on
# stdout; its directive lines (markers and blanks dropped, leading indent stripped)
# are the required set — so apply-coverage and convergence-coverage are provably ONE
# set (SC-480): add a directive to the block and this check demands it too. PER-VHOST,
# never a union (#67): a `</VirtualHost>` resets the seen set, so a block in vhost #1
# can NOT mask a bare vhost #2 (a panel/webmail vhost) that would forward the edge
# secret into the GPL apps (SC-423/SC-424 disclosure). Falls back to the WHOLE file
# when the conf has no matching vhost. The SAME oracle drives the detector, the
# apply-decision, and post-write convergence (SC-480 parity, one oracle).
reconcile_vhost_fullset_ok() {
	local f="$1" block_cmd="$2" patt="$3" reqfile rc
	reqfile="$(mktemp)" || return 1
	# Required lines: the block's directives, markers and blank lines dropped, leading
	# indentation stripped (matched as substrings below, indentation-insensitive).
	"$block_cmd" | awk '
		/>>> shcp-update/ || /<<< shcp-update/ { next }
		{ sub(/^[[:space:]]+/, ""); if (length) print }
	' > "$reqfile" || { rm -f "$reqfile"; return 1; }
	awk -v patt="$patt" -v reqfile="$reqfile" '
		BEGIN {
			n = 0
			while ((getline l < reqfile) > 0) need[++n] = l
			close(reqfile)
		}
		$0 ~ patt { insl = 1; any443 = 1; for (i = 1; i <= n; i++) seen[i] = 0; next }
		insl && /^[[:space:]]*<\/VirtualHost>/ {
			for (i = 1; i <= n; i++) if (!seen[i]) bad = 1
			insl = 0; next
		}
		insl { for (i = 1; i <= n; i++) if (!seen[i] && index($0, need[i])) seen[i] = 1; next }
		# outside any matching vhost — collect for the no-match whole-file fallback only
		{ for (i = 1; i <= n; i++) if (!gseen[i] && index($0, need[i])) gseen[i] = 1 }
		END {
			if (!any443) for (i = 1; i <= n; i++) if (!gseen[i]) bad = 1
			exit (bad ? 1 : 0)
		}
	' "$f"
	rc=$?
	rm -f "$reqfile"
	return "$rc"
}

# reconcile_vhost_block_ensure <vhost-file> <block-text-cmd> <begin> <end> <ssl-vhost-re>
# The SHARED marker-anchored, idempotent, configtest-gated whole-block applier extracted
# from reconcile_edge_vhost_block (SC-480). Strips EVERY managed region (matched WHOLE-
# LINE on <begin>/<end>), then inserts a fresh block after EACH vhost matching
# <ssl-vhost-re> — falling back to the first <VirtualHost> only when the conf has none.
# configtest-gated (SC-029), restore-on-fail. A future presence-based vhost applier gets
# all of this by construction; only its render/gating stays in its own apply arm.
#   0 written+configtest-clean   1 io error   2 no vhost file   3 anchor not found
#   4 configtest failed (restored)   5 malformed markers (refused, unchanged)
#   6 marker collision in the registry (refused, unchanged)
reconcile_vhost_block_ensure() {
	local vhost="$1" block_cmd="$2" B="$3" E="$4" sslpatt="$5"
	[[ -f "$vhost" ]] || return 2
	# Fail closed if any two registered block markers are in a substring relationship:
	# whole-line matching below is the primary defense, this is the loud backstop.
	reconcile_vhost_markers_distinct || return 6
	local blockfile="${vhost}.block.$$" tmp="${vhost}.reconcile.$$" backup="${vhost}.pre-reconcile.$$"
	"$block_cmd" > "$blockfile" || { rm -f "$blockfile"; return 1; }
	# The block must reach EVERY matching vhost — an operator who adds a second
	# <VirtualHost *:443> (a panel/webmail vhost) must not be left with one hardened and
	# one forwarding the edge secret (SC-423/SC-424). Strip every managed block region —
	# wherever it landed — then insert a fresh block after EACH matching opening. Fall back to the first <VirtualHost> only
	# when the conf has no matching opening.
	#
	# Markers are matched WHOLE-LINE ($0 == B / $0 == E), never as substrings: a second
	# managed block whose begin-marker is a SUBSTRING of this one would, under substring
	# matching, be treated as this block's marker and let the strip swallow/DELETE this
	# block silently (configtest passes on the parseable result, so restore never fires)
	# — SC-423 edge-auth removed, X-SHCP-Edge-Auth forgeable. Whole-line equality plus
	# the reconcile_vhost_markers_distinct registry guard close that.
	#
	# #25/SC-029: strip is safe only for WELL-FORMED markers. The markers must strictly
	# alternate BEGIN,END,BEGIN,END,… — an unpaired BEGIN, a stray/mis-ordered END, or a
	# duplicate BEGIN (an operator edited between the managed markers) is refused
	# (return 5) with NO write; a lone marker would otherwise let the strip swallow past
	# the intended region and merge sibling vhosts, and configtest can pass on the
	# mangled-but-parseable result, so it is not a backstop. Never rewrite to a guess.
	local vh_anchor='^[[:space:]]*<VirtualHost[[:space:]]'
	local rewritten="" rc_awk=0
	rewritten="$(awk -v bf="$blockfile" \
			-v B="$B" -v E="$E" \
			-v sslpatt="$sslpatt" -v vhpatt="$vh_anchor" '
		BEGIN {
			bn = 0
			while ((getline l < bf) > 0) blk[++bn] = l
			close(bf)
			expect_begin = 1
		}
		$0 == B { if (!expect_begin) malformed = 1; expect_begin = 0; instrip = 1; next }
		$0 == E { if (expect_begin)  malformed = 1; expect_begin = 1; instrip = 0; next }
		instrip { next }
		{ keep[++kn] = $0 }
		END {
			if (malformed || !expect_begin) exit 5
			has443 = 0
			for (i = 1; i <= kn; i++) if (keep[i] ~ sslpatt) { has443 = 1; break }
			ins = 0
			for (i = 1; i <= kn; i++) {
				print keep[i]
				if (has443) {
					if (keep[i] ~ sslpatt) { for (j = 1; j <= bn; j++) print blk[j]; ins++ }
				} else if (!ins && keep[i] ~ vhpatt) {
					for (j = 1; j <= bn; j++) print blk[j]; ins++
				}
			}
			if (!ins) exit 3
			exit 0
		}' "$vhost")" || rc_awk=$?
	rm -f "$blockfile"
	case "$rc_awk" in
		0) ;;
		3) return 3 ;;
		5) return 5 ;;
		*) return 1 ;;
	esac
	# The rewrite must carry the ORIGINAL's mode and owner. A `printf > tmp`
	# creates the temp under this process's umask (0644 as root) and the mv then
	# replaces the vhost inode with it: an operator-tightened 0640 silently
	# became 0644 and a non-root owner was lost. cp -p first (mode, owner as
	# root, timestamps), then truncate-and-write INTO that inode: the rewritten
	# file is born with the original's identity, not the umask's.
	cp -p -- "$vhost" "$tmp" || { rm -f "$tmp"; return 1; }
	printf '%s\n' "$rewritten" > "$tmp" || { rm -f "$tmp"; return 1; }
	cp -p "$vhost" "$backup" || { rm -f "$tmp"; return 1; }
	mv -f "$tmp" "$vhost" || { rm -f "$tmp" "$backup"; return 1; }
	# configtest gates the change. A failure restores the pristine backup so Apache is
	# never left unparseable by this verb (SC-029).
	if "$APACHE_CTL" configtest >/dev/null 2>&1; then
		rm -f "$backup"
		return 0
	fi
	mv -f "$backup" "$vhost" 2>/dev/null || engine_log "reconcile: CRITICAL — could not restore ${vhost} from ${backup}"
	return 4
}

# reconcile_env_value <key> — the RESOLVED value on stdout, or return 1. Mirrors
# env_resolved's layers EXACTLY (parity), but returns the value the applier needs
# to substitute. Used ONLY internally; the value never reaches a reconcile_action
# detail (SC-044/SC-042 — a value, diff or candidate never leaves this verb).
reconcile_env_value() {
	local key="$1" f val="" got=1 raw
	for f in "${PANEL_LINK}/.env" "${PANEL_LINK}/.env.local" \
			"${PANEL_LINK}/.env.prod" "${PANEL_LINK}/.env.prod.local"; do
		raw="$(env_file_lookup "$f" "$key")" || continue
		val="$raw"; got=0
	done
	raw="$(env_unit_lookup "$key")" || raw=""
	if [[ -n "$raw" ]]; then val="$raw"; got=0; fi
	(( got == 0 )) || return 1
	val="$(env_strip_quotes "$val")"
	[[ -n "$val" ]] || return 1
	[[ "$val" =~ ^!.*!$ ]] && return 1
	printf '%s' "$val"
	return 0
}

# reconcile_edge_block_text — the managed block, markers included. Substitution-
# free except the Include PATH (which the engine knows), so it is engine-authored
# like the installer's insert_edge_auth_block — the ONE template text read from
# the signed artifact is the edge-auth conf itself, rendered separately. This is
# the single source both the writer and the convergence check derive from.
reconcile_edge_block_text() {
	printf '%s\n' "$RECONCILE_EDGE_MARK_BEGIN"
	cat <<EOF
    RequestHeader unset "X-Forwarded-For"
    RequestHeader unset "X_Forwarded_For"
    RequestHeader unset "X_Forwarded_Proto"
    RequestHeader unset "X_Forwarded_Host"
    RequestHeader unset "X_SHCP_Edge_Auth"
    RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
    RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS}
    Include ${APACHE_EDGE_AUTH_CONF}
    <Location "/webmail/">
        RequestHeader unset "X-SHCP-Edge-Auth"
    </Location>
    <Location "/phpmyadmin/">
        RequestHeader unset "X-SHCP-Edge-Auth"
    </Location>
EOF
	printf '%s\n' "$RECONCILE_EDGE_MARK_END"
}

# reconcile_edge_fullset_ok <vhost-file> — the SC-423 edge-auth convergence oracle: the
# FIRST caller of the shared per-vhost oracle (reconcile_vhost_fullset_ok), bound to the
# edge block text and the :443 vhost pattern. 0 iff EVERY <VirtualHost ...:443> in the
# conf carries the FULL edge directive set (F2, #24, #67). All the machinery — required-
# set derived from the block text, per-vhost seen-reset, whole-file fallback — lives in
# the shared oracle; only the edge-specific block/pattern binding is here.
reconcile_edge_fullset_ok() {
	reconcile_vhost_fullset_ok "$1" reconcile_edge_block_text "$RECONCILE_SSL_VHOST_RE"
}

# reconcile_render_edge_conf <secret> — render the root-only 0640 edge-auth conf
# from the VERIFIED template, secret substituted. Mode is set BEFORE content
# (F5, mirroring installer apache.sh:626-646): apply_template-style renderers
# install via rename and would otherwise leave the secret world-readable for the
# window between rename and a post-hoc chmod. Create 0640-from-birth, render into
# it, then STAT-assert. 1 on any failure — the caller coverage-gaps, unchanged.
reconcile_render_edge_conf() {
	local secret="$1" tmpl content mode owner
	tmpl="${RECONCILE_TEMPLATE_DIR}/${RECONCILE_EDGE_TEMPLATE_REL}"
	[[ -f "$tmpl" ]] || { engine_log "reconcile: edge-auth template missing from the artifact"; return 1; }
	content="$(cat "$tmpl")" || return 1
	content="${content//'!EDGE_PROXY_SECRET!'/$secret}"
	# Still carrying the placeholder means the template shape changed under us —
	# refuse rather than ship a conf that authenticates nothing.
	[[ "$content" == *'!EDGE_PROXY_SECRET!'* ]] && { engine_log "reconcile: edge-auth template did not substitute"; return 1; }
	install -d -m 0755 "$(dirname "$APACHE_EDGE_AUTH_CONF")" 2>/dev/null || true
	( umask 077; : > "$APACHE_EDGE_AUTH_CONF" ) || return 1
	chown root:root "$APACHE_EDGE_AUTH_CONF" 2>/dev/null || true
	chmod 0640 "$APACHE_EDGE_AUTH_CONF" 2>/dev/null || true
	printf '%s\n' "$content" > "$APACHE_EDGE_AUTH_CONF" || return 1
	# Prove it — this file's mode IS the whole control (a 0644 hands every tenant
	# the ability to forge the edge header). Owner is asserted only where we can
	# set it (running as root); the mode is asserted always.
	mode="$(stat -c '%a' "$APACHE_EDGE_AUTH_CONF" 2>/dev/null || echo '')"
	[[ "$mode" == "640" ]] || { engine_log "reconcile: edge-auth conf is ${mode}, not 640 — refusing"; return 1; }
	if [[ "$(id -u 2>/dev/null)" == "0" ]]; then
		owner="$(stat -c '%U:%G' "$APACHE_EDGE_AUTH_CONF" 2>/dev/null || echo '')"
		[[ "$owner" == "root:root" ]] || { engine_log "reconcile: edge-auth conf owner ${owner}, not root:root"; return 1; }
	fi
	return 0
}

# reconcile_edge_vhost_block <vhost-file> — bring EVERY <VirtualHost ...:443> in the
# conf up to the full SC-423 edge-auth block, idempotently. The FIRST caller of the
# shared applier (reconcile_vhost_block_ensure), bound to the edge block text, the edge
# BEGIN/END markers, and the :443 vhost pattern. All the generic machinery — whole-line
# marker strip, pairing guard, per-:443 insert with first-<VirtualHost> fallback,
# configtest gate (SC-029), restore-on-fail, marker-collision refusal — lives in the
# shared primitive; only the edge binding is here.
#   0 written+configtest-clean   1 io error   2 no vhost file   3 anchor not found
#   4 configtest failed (restored)   5 malformed markers (refused, unchanged)
#   6 marker collision in the registry (refused, unchanged)
reconcile_edge_vhost_block() {
	reconcile_vhost_block_ensure "$1" reconcile_edge_block_text \
		"$RECONCILE_EDGE_MARK_BEGIN" "$RECONCILE_EDGE_MARK_END" "$RECONCILE_SSL_VHOST_RE"
}

# reconcile_apply_vhost <id> <conf> <apply_method> <why>
reconcile_apply_vhost() {
	local id="$1" conf="$2" method="$3" secret vhost="${APACHE_SITES_DIR}/${2}" rc
	if [[ "$method" != "edge_auth_include" ]]; then
		reconcile_action "$id" vhost "$conf" coverage_gap \
			"no applier for this vhost id (apply is null or unknown) — reported, never rendered"
		return 0
	fi
	# P3: on an env-blind box (compiled .env.local.php, or an EnvironmentFile= on
	# the shcpd unit) the flat .env* layers reconcile_env_value reads are exactly
	# what the blind probe says cannot be trusted as shcpd's runtime value. A conf
	# rendered from a diverging secret makes edge-auth silently fail — so refuse,
	# matching the env-mint half which already gates on RECONCILE_ENV_BLIND.
	if [[ "${RECONCILE_ENV_BLIND:-0}" == "1" ]]; then
		reconcile_action "$id" vhost "$conf" coverage_gap \
			"an env layer this check cannot read is in play (compiled-dotenv/EnvironmentFile) — cannot safely recover shcpd's runtime EDGE_PROXY_SECRET; not rendered"
		return 0
	fi
	# The secret is RESOLVED from the canonical env (the env applier minted it
	# earlier in this same run if it was absent+generatable); NEVER re-minted
	# here. Unresolvable => the env half could not fix it (canonical panel.env
	# layout not in place, or not generatable) => coverage gap, no render.
	if ! secret="$(reconcile_env_value EDGE_PROXY_SECRET)"; then
		reconcile_action "$id" vhost "$conf" coverage_gap \
			"EDGE_PROXY_SECRET is unresolved — cannot render the edge-auth conf without it"
		return 0
	fi
	# Charset guard on ANY secret about to be substituted, minted OR recovered
	# (F5): a value with a quote or space renders a syntactically valid Apache
	# directive that silently never fires.
	if [[ ! "$secret" =~ ^[A-Za-z0-9]{24,}$ ]]; then
		reconcile_action "$id" vhost "$conf" coverage_gap \
			"the resolved edge secret is not 24+ alphanumeric — refusing to substitute it into a directive"
		return 0
	fi
	if ! reconcile_render_edge_conf "$secret"; then
		reconcile_action "$id" vhost "$conf" coverage_gap "could not render ${APACHE_EDGE_AUTH_CONF} at 0640 root:root"
		return 0
	fi
	rc=0
	reconcile_edge_vhost_block "$vhost" || rc=$?
	case "$rc" in
		0) ;;
		2) reconcile_action "$id" vhost "$conf" coverage_gap "${vhost} is not present — no panel vhost to harden"; return 0 ;;
		3) reconcile_action "$id" vhost "$conf" coverage_gap "no <VirtualHost> anchor in ${vhost} — refusing to append the block at EOF"; return 0 ;;
		4) reconcile_action "$id" vhost "$conf" coverage_gap "apachectl configtest failed with the edge-auth block — ${vhost} restored, Apache untouched"; return 0 ;;
		5) reconcile_action "$id" vhost "$conf" coverage_gap "the edge-auth BEGIN marker in ${vhost} has no matching END marker — refusing to rewrite (an edit between the managed markers); remove both markers and re-run to restore the block"; return 0 ;;
		6) reconcile_action "$id" vhost "$conf" coverage_gap "two managed vhost-block markers are in a substring relationship — refusing to rewrite ${vhost} until the markers are made mutually distinct (SC-480)"; return 0 ;;
		*) reconcile_action "$id" vhost "$conf" coverage_gap "could not rewrite ${vhost}"; return 0 ;;
	esac
	# Convergence against the FULL directive set, not the single Include the
	# detector greps (F2). A block that landed partially is NOT a success.
	if reconcile_edge_fullset_ok "$vhost"; then
		reconcile_action "$id" vhost "$conf" inserted "installed the full SC-423 edge-auth block (Include + SC-406/SC-424 unsets), configtest-clean"
	else
		reconcile_action "$id" vhost "$conf" coverage_gap \
			"wrote the edge-auth block but the full directive set is not present in ${vhost} — refusing to claim convergence"
	fi
	return 0
}

# --- files applier (updater#109) ---------------------------------------------
# reconcile_payload_dest_ok <path> — the AUTHORITATIVE destination gate, run
# right before the write as root. It belts decl_read's parse-time drop (both the
# detector and the applier consume decl_read's output, so a bad path never
# reaches here — but the write is where being wrong is expensive, so it is
# re-checked at the boundary). A path passes ONLY if it is absolute, carries no
# `..` component (the grammar's charset admits `.`, so this is a separate test),
# and sits strictly UNDER one of the engine-owned RECONCILE_PAYLOAD_ROOTS. The
# roots are NOT a declaration field: that is the whole confinement — a panel-
# writable declaration can place a signed-tarball file under one of them, never
# at an arbitrary path (the new risk this feature adds; see SECURITY_CHECKPOINTS).
reconcile_payload_dest_ok() {
	local p="$1" r
	[[ "$p" == /* ]] || return 1
	# `..` as a whole path component: leading ../, trailing /.., or …/../…
	case "/$p/" in
		*/../*) return 1 ;;
	esac
	for r in ${RECONCILE_PAYLOAD_ROOTS[@]+"${RECONCILE_PAYLOAD_ROOTS[@]}"}; do
		[[ -n "$r" ]] || continue
		[[ "$p" == "$r"/* ]] && return 0
	done
	return 1
}

# reconcile_payload_source_ok <template-dir> <source> — the source is tarball-
# relative and must stay inside the verified, extracted installer tree. Reject an
# absolute source, a `..` component, a non-regular file, and — via realpath — a
# source an intermediate symlinked directory would resolve OUT of the tree. The
# tarball itself was gpgv+sha verified before extraction (SC-064/207); this stops
# a member from redirecting the copy to something outside what was verified.
reconcile_payload_source_ok() {
	local dir="$1" src="$2" full rp base
	[[ -n "$src" ]] || return 1
	[[ "$src" != /* ]] || return 1
	case "/$src/" in
		*/../*) return 1 ;;
	esac
	full="${dir}/${src}"
	[[ -f "$full" && ! -L "$full" ]] || return 1
	rp="$(realpath -- "$full" 2>/dev/null)" || return 1
	base="$(realpath -- "$dir" 2>/dev/null)" || return 1
	[[ "$rp" == "$base"/* ]] || return 1
	return 0
}

# reconcile_apply_payload <id> <path> <source> <mode> <owner> <why> — backfill
# one declared installer-owned file the box has never had. Additive to the bone
# (SC-432): a destination that already exists — a real file OR a symlink, put
# there by the installer or hand-edited by an operator — is LEFT UNTOUCHED and
# recorded as a skip; only a never-present destination is written. Fails closed
# to a coverage_gap on any doubt (no tree, a destination outside the allow-list,
# a missing/unsafe source, a failed copy, a post-write mode mismatch) — never a
# blind or partial write.
reconcile_apply_payload() {
	local id="$1" path="$2" source="$3" mode="$4" owner="$5" src got_mode
	if [[ -e "$path" || -L "$path" ]]; then
		reconcile_action "$id" file "$path" skip \
			"present — left untouched (additive, never overwritten; SC-432)"
		return 0
	fi
	if [[ -z "$RECONCILE_TEMPLATE_DIR" || ! -d "$RECONCILE_TEMPLATE_DIR" ]]; then
		reconcile_action "$id" file "$path" coverage_gap \
			"no verified installer tree available — cannot backfill"
		return 0
	fi
	# The destination confinement — the security gate (reconcile_payload_dest_ok).
	if ! reconcile_payload_dest_ok "$path"; then
		reconcile_action "$id" file "$path" coverage_gap \
			"declared destination is not absolute, contains '..', or is outside the allowed roots — refusing to write"
		return 0
	fi
	if ! reconcile_payload_source_ok "$RECONCILE_TEMPLATE_DIR" "$source"; then
		reconcile_action "$id" file "$path" coverage_gap \
			"declared source is missing, a symlink, or escapes the verified installer tree — not installed"
		return 0
	fi
	src="${RECONCILE_TEMPLATE_DIR}/${source}"
	# NEVER write the destination in place. `install -D src path` opens the
	# declared path and streams into it, so a crash mid-copy (or a full disk)
	# leaves a PARTIAL file at the declared path — which the next run's presence
	# oracle reads as "present, left untouched (SC-432)" and never repairs: a
	# truncated recovery tool, permanently. Stage into a temp in the SAME
	# directory at the declared mode, chown it there, then rename: the declared
	# path is either absent or the whole file, and a crash leaves only a temp
	# the next run removes.
	local dir tmp
	dir="${path%/*}"
	install -d -m 0755 -- "$dir" 2>/dev/null || {
		reconcile_action "$id" file "$path" coverage_gap "could not create ${dir} for ${path}"
		return 0
	}
	rm -f -- "${dir}"/.shcp-reconcile.*.tmp 2>/dev/null || true
	if ! tmp="$(mktemp -- "${dir}/.shcp-reconcile.XXXXXX.tmp" 2>/dev/null)"; then
		reconcile_action "$id" file "$path" coverage_gap "could not stage a temp beside ${path}"
		return 0
	fi
	if ! install -m "$mode" -- "$src" "$tmp" 2>/dev/null; then
		rm -f -- "$tmp" 2>/dev/null || true
		reconcile_action "$id" file "$path" coverage_gap "could not install ${path} at mode ${mode}"
		return 0
	fi
	# Owner is applied only as root; the non-root harness already owns the file it
	# just wrote (mirrors revendor_closure_apply), so a chown we cannot do is not a
	# failure of an otherwise-correct install.
	if [[ "$(id -u 2>/dev/null)" == "0" ]]; then
		chown "$owner" -- "$tmp" 2>/dev/null || true
	fi
	if ! mv -T -- "$tmp" "$path" 2>/dev/null; then
		rm -f -- "$tmp" 2>/dev/null || true
		reconcile_action "$id" file "$path" coverage_gap "could not move the staged file into place at ${path}"
		return 0
	fi
	# Post-write mode assertion. stat drops the
	# leading zero (0755 -> 755), so compare on the last three digits.
	got_mode="$(stat -c '%a' -- "$path" 2>/dev/null || echo '')"
	if [[ "$got_mode" != "${mode: -3}" ]]; then
		reconcile_action "$id" file "$path" coverage_gap \
			"installed ${path} but its mode is ${got_mode:-unknown}, not ${mode}"
		return 0
	fi
	reconcile_action "$id" file "$path" installed \
		"installed a never-present declared installer-owned file from the verified tarball at mode ${mode}"
	return 0
}

# reconcile_apply_half <parsed-declaration>
# Mirrors reconcile_eval_half's walk, but DECIDES-then-APPLIES instead of
# reporting. It reproduces the detector's absent test for each kind so apply
# coverage equals detect coverage, then dispatches only the additive case.
reconcile_apply_half() {
	local decl="$1"
	local sep=$'\x1f'
	local id name gen why state apply_m dre kind

	# units — enable ONLY a unit the box has never seen (state exactly `absent`)
	# that the RUNNING release still declares (this walk iterates the CURRENT
	# declaration, so a unit whose declaration vanished is never reached — F4).
	# Everything else is left alone and the reason recorded, because the whole
	# safety property of this verb is what it REFUSES to touch (SC-432):
	#   disabled / masked -> operator intent, never re-enabled;
	#   unanswerable / unknown -> systemd could not read it, so never act blind;
	#   absent + optional -> a box may legitimately lack it (the detector's own
	#     advisory-not-finding rule), so not enabled;
	#   enabled -> already satisfied, nothing recorded.
	local optional
	while IFS="$sep" read -r id name optional why; do
		[[ -n "$id" ]] || continue
		kind="$(reconcile_unit_kind "$name")" || continue
		reconcile_is_exempt "$id" && continue
		state="$(reconcile_unit_state "$name")"
		case "$state" in
			absent)
				if [[ "$optional" == "1" ]]; then
					reconcile_action "$id" "$kind" "$name" skip \
						"absent but optional — a box may legitimately lack it; not enabled"
				else
					reconcile_apply_unit "$id" "$name" "$why"
				fi ;;
			disabled|masked)
				reconcile_action "$id" "$kind" "$name" skip \
					"left ${state} — operator intent, never re-enabled (SC-432)" ;;
			unanswerable|unknown)
				reconcile_action "$id" "$kind" "$name" skip \
					"systemd could not answer for this unit; not enabled" ;;
		esac
	# os_family gates here exactly as in reconcile_eval_half: a unit the
	# declaration does not claim for THIS family is never reached, so it can
	# never be enabled on the strength of a field the detector ignored.
	done < <(jq -r --arg s "$sep" --arg fam "$OS_FAMILY" '(.units // [])[]
		| select(.os_family | index($fam)) | [
			(.id), (.unit), (if .optional == true then "1" else "0" end),
			((.why // "") | gsub("[[:cntrl:]]"; " "))
		] | join($s)' <<<"$decl" 2>/dev/null || true)

	# env — mint an absent key ONLY where the declaration marks it generatable
	# AND does not mark it optional. The detector raises an unresolved optional
	# key as an ADVISORY, not a finding (mirroring the units walk, where an
	# absent optional unit is skipped): minting it here would write a secret
	# --check never asked for, on the strength of a field this walk once ignored.
	local optional_env
	if [[ "${RECONCILE_ENV_BLIND:-0}" != "1" ]]; then
		while IFS="$sep" read -r id name gen optional_env why; do
			[[ -n "$id" ]] || continue
			reconcile_is_exempt "$id" && continue
			env_resolved "$name" && continue
			if [[ "$optional_env" == "1" ]]; then
				reconcile_action "$id" env "$name" skip \
					"unresolved but optional — the detector raised only an advisory; not minted"
				continue
			fi
			reconcile_apply_env "$id" "$name" "$gen" "$why"
		done < <(jq -r --arg s "$sep" '(.env // [])[] | [
				(.id), (.key), (.generate.method // ""),
				(if .optional == true then "1" else "0" end),
				((.why // "") | gsub("[[:cntrl:]]"; " "))
			] | join($s)' <<<"$decl" 2>/dev/null || true)
	fi

	# vhost — apply the declared additive control ONLY where its directive is
	# absent AND a known applier is named. code 1 is "read, not present"; 2-5 say
	# nothing about the box (coverage), never a blind write.
	local rc
	while IFS="$sep" read -r id name dre apply_m why; do
		[[ -n "$id" ]] || continue
		reconcile_is_exempt "$id" && continue
		# F1: for an edge-auth entry the absent test is the FULL directive set, the
		# SAME oracle the detector uses — so a box with the Include but missing the
		# SC-424 <Location> unsets is repaired, not read healthy and left alone. The
		# applier whole-block-replaces, so re-running on a partial block converges it.
		if [[ "$apply_m" == "edge_auth_include" ]]; then
			if ! reconcile_edge_fullset_ok "${APACHE_SITES_DIR}/${name}"; then
				reconcile_apply_vhost "$id" "$name" "$apply_m" "$why"
			fi
			continue
		fi
		rc=0
		vhost_directive_present "$name" "$dre" || rc=$?
		if (( rc == 1 )); then
			reconcile_apply_vhost "$id" "$name" "$apply_m" "$why"
		fi
	done < <(jq -r --arg s "$sep" '(.vhost // [])[] | [
			(.id), (.conf), (.directive_re), (.apply // ""),
			((.why // "") | gsub("[[:cntrl:]]"; " "))
		] | join($s)' <<<"$decl" 2>/dev/null || true)

	# files (updater#109) — backfill a declared installer-owned file the box has
	# NEVER received. os_family gates in the jq `select` exactly as the detector's
	# does, so apply coverage equals detect coverage. The dispatch only ROUTES; the
	# applier decides (present -> skip, absent -> install, unsafe -> coverage_gap),
	# so there is no presence special-case here to drift from the detector's.
	local fsrc fmode fowner
	while IFS="$sep" read -r id name fsrc fmode fowner why; do
		[[ -n "$id" ]] || continue
		reconcile_is_exempt "$id" && continue
		reconcile_apply_payload "$id" "$name" "$fsrc" "$fmode" "$fowner" "$why"
	done < <(jq -r --arg s "$sep" --arg fam "$OS_FAMILY" '(.files // [])[]
		| select(.os_family | index($fam)) | [
			(.id), (.path), (.source), (.mode), (.owner),
			((.why // "") | gsub("[[:cntrl:]]"; " "))
		] | join($s)' <<<"$decl" 2>/dev/null || true)
	return 0
}

# reconcile_apply_converge — a WRITE that did not take is not a partial success,
# it is an abort (SC-319 / SC-480). Re-run the REAL
# detector and confirm every id we actually CHANGED now reads clean. Coverage
# gaps are expected to remain findings — they did not write — so only ids with a
# write action are checked. 0 converged, 1 a change did not converge.
reconcile_apply_converge() {
	local changed
	# P2: collect the ids we WROTE, not the names — a name collides within a kind
	# and would match a sibling entry's detector item.
	changed="$(printf '%s\n' "${RECONCILE_APPLY_ACTIONS[@]+"${RECONCILE_APPLY_ACTIONS[@]}"}" \
		| jq -rs '[.[] | select(.action=="enabled" or .action=="minted"
		           or .action=="inserted" or .action=="installed") | .id] | unique | .[]' 2>/dev/null || true)"
	[[ -n "$changed" ]] || return 0   # nothing was written — trivially converged
	local rc=0
	reconcile_evaluate >/dev/null 2>&1 || rc=$?
	local id sev
	while IFS= read -r id; do
		[[ -n "$id" ]] || continue
		sev="$(printf '%s\n' "${RECONCILE_ITEMS[@]+"${RECONCILE_ITEMS[@]}"}" \
			| jq -rs --arg i "$id" '[.[] | select(.id==$i) | .severity] | (index("finding") // -1)' 2>/dev/null || echo -1)"
		[[ "$sev" != "-1" ]] && return 1
	done <<<"$changed"
	return 0
}

# reconcile_record_write <doc> — the durable record, OUTSIDE runs/. Not
# write-once: each apply overwrites it with its latest outcome (the archive is
# the write-once artifact). atomic, 0600.
reconcile_record_write() {
	local doc="$1"
	install -d -m 0700 "$RECONCILE_APPLY_DIR" 2>/dev/null || return 1
	printf '%s\n' "$doc" | atomic_write "${RECONCILE_APPLY_DIR}/last-apply.json" 0600
	return 0
}

# reconcile_apply_doc — assemble the apply result document on stdout.
reconcile_apply_doc() {   # <aborted-or-empty> <converged-bool>
	local aborted="$1" converged="$2" actions='[]' changed=0 gaps=0
	if (( ${#RECONCILE_APPLY_ACTIONS[@]} > 0 )); then
		actions="$(printf '%s\n' "${RECONCILE_APPLY_ACTIONS[@]}" | jq -sc '.' 2>/dev/null)" || actions='[]'
	fi
	changed="$(jq -r '[.[] | select(.action=="enabled" or .action=="minted" or .action=="inserted" or .action=="installed")] | length' <<<"$actions" 2>/dev/null || echo 0)"
	gaps="$(jq -r '[.[] | select(.action=="coverage_gap")] | length' <<<"$actions" 2>/dev/null || echo 0)"
	jq -nc --arg now "$(now_utc)" --arg ab "$aborted" --argjson conv "$converged" \
		--argjson acts "$actions" --argjson ch "$changed" --argjson gp "$gaps" \
		--arg fam "${OS_FAMILY:-}" \
		'{schema_version: 1, mode: "apply", generated_at: $now, os_family: $fam,
		  aborted: (if $ab == "" then null else $ab end),
		  changed: $ch, coverage_gaps: $gp,
		  converged: $conv, actions: $acts}'
	return 0
}
# cmd_reconcile_apply — the applier orchestration (§4.13 steps 0-7). Emits one
# JSON apply document on stdout; exit 0 converged/no-op, 3 coverage gaps remain,
# 4 aborted (a run holds the lock, the declaration cannot be read, no private
# scratch dir could be made, the artifact did not verify, or the archive could not
# be written) — changing nothing in every abort case.
cmd_reconcile_apply() {
	RECONCILE_APPLY_ACTIONS=()
	RECONCILE_APPLY_ABORT=""

	# Step 0: the run flock. REFUSE under a live run rather than queue behind it —
	# the run may be about to change the very release whose expectations we
	# reconcile against, and a lock held for the length of an apply must not turn
	# the daily drift check into a silent no-op either.
	if ! acquire_lock; then
		printf '%s\n' "$(reconcile_apply_doc "run-in-progress" false)"
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi

	# The detector's own gates decide whether there is anything to do — and they
	# are the SAME gates --check uses, so apply-coverage can never exceed detect-
	# coverage (the verb runs on both OS families). An unreadable declaration is
	# CANNOT_CHECK; zero findings is a clean box and the whole apply is skipped
	# (no fetch, no archive, no touch — the no-op that is the point on a
	# fully-patched box).
	local rc=0
	reconcile_evaluate >/dev/null 2>&1 || rc=$?
	if (( rc == RECONCILE_EXIT_CANNOT_CHECK )); then
		printf '%s\n' "$(reconcile_apply_doc "cannot-check" false)"
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi

	if (( rc == RECONCILE_EXIT_OK )); then
		# A clean box records nothing and writes no durable record — Section C's
		# no-op invariant.
		printf '%s\n' "$(reconcile_apply_doc "" true)"
		return "$RECONCILE_EXIT_OK"
	fi

	# rc == FINDINGS: there is additive work. Read the declarations the applier
	# walks with the SAME reader the detector used (parity). exemptions + the
	# env-blind probe were loaded by reconcile_evaluate above.
	local decl_engine="" decl_release="" drc
	drc=0; decl_engine="$(decl_read "$DECL_ENGINE_FILE")" || drc=$?
	(( drc == 0 )) || decl_engine=""
	drc=0; decl_release="$(decl_read "$DECL_RELEASE_FILE")" || drc=$?
	(( drc == 0 )) || decl_release=""

	# A missing release keyring is a detector finding the applier never heals
	# (reconcile_eval_keyring): the step-1 fetch below needs the keyring to
	# verify the installer artifact, so on such a box the fetch aborts having
	# changed nothing, and the finding stands until the box is reinstalled.

	# Step 1: fetch + verify the installer artifact BEFORE reading a byte of it
	# (SC-064/207). An unverified artifact ABORTS having changed nothing. The
	# appliers install files from this tree by path as root, so it lives in a
	# private scratch dir (private_scratch_dir proves every ancestor root-held),
	# not under RECONCILE_APPLY_DIR: that tree is root-owned too since SC-663,
	# but it is durable state, and verified bytes staged for a root-as-path read
	# belong in a per-run scratch that is removed whatever happens next.
	local scratch work
	if ! scratch="$(private_scratch_dir)"; then
		# Not an artifact verdict: nothing was fetched. A TMPDIR this process
		# inherited (/run/user/<uid>, a panel-owned tree) is the usual cause.
		printf '%s\n' "$(reconcile_apply_doc "scratch-unavailable" false)"
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi
	work="${scratch}/installer"
	if ! reconcile_fetch_templates "$work"; then
		rm -rf "$scratch" 2>/dev/null || true
		printf '%s\n' "$(reconcile_apply_doc "artifact-unverified" false)"
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi
	RECONCILE_TEMPLATE_DIR="$work"

	# Step 2: archive every candidate file an applier may touch, write-once,
	# COMPLETE marker LAST. Over-archiving a file left untouched is harmless;
	# missing one is not — so archive the candidate classes that exist (panel.env,
	# the edge conf, every declared vhost conf, and every declared payload
	# destination). The archive belongs to THIS apply attempt series: an
	# interrupted attempt's archive (archive/current still set) is resumed —
	# members it already holds are kept, new candidates appended — and a
	# converged apply ends the series, so the next apply archives afresh.
	if ! RECONCILE_ARCHIVE_DIR="$(reconcile_archive_open)"; then
		rm -rf "$scratch" 2>/dev/null || true
		RECONCILE_TEMPLATE_DIR=""
		printf '%s\n' "$(reconcile_apply_doc "archive-failed" false)"
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi
	local -a candidates=("$PANEL_ENV" "$APACHE_EDGE_AUTH_CONF")
	local vc
	while IFS= read -r vc; do
		[[ -n "$vc" ]] && candidates+=("${APACHE_SITES_DIR}/${vc}")
	done < <(printf '%s\n%s\n' "$decl_engine" "$decl_release" \
		| jq -r '(.vhost // [])[]?.conf // empty' 2>/dev/null | sort -u || true)
	# Payload destinations are absolute paths already grammar- and allow-list-
	# confined by decl_read, so they are archived verbatim. A never-present file
	# archives as `.absent`; a present operator file archives its pre-state.
	local pf
	while IFS= read -r pf; do
		[[ -n "$pf" ]] && candidates+=("$pf")
	done < <(printf '%s\n%s\n' "$decl_engine" "$decl_release" \
		| jq -r '(.files // [])[]?.path // empty' 2>/dev/null | sort -u || true)
	if ! reconcile_archive_run "$RECONCILE_ARCHIVE_DIR" "${candidates[@]}"; then
		rm -rf "$scratch" 2>/dev/null || true
		printf '%s\n' "$(reconcile_apply_doc "archive-failed" false)"
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi

	# Step 3: apply. The walk re-derives env-blindness itself so a resumed
	# process (which never ran reconcile_evaluate) still refuses to mint into a
	# layer it cannot read back. NOTE: the action list is NOT re-cleared here — it
	# was initialised at function entry; clearing here would drop any action an
	# earlier step recorded into it.
	reconcile_env_blind_probe
	[[ -n "$decl_engine" ]] && reconcile_apply_half "$decl_engine"
	[[ -n "$decl_release" ]] && reconcile_apply_half "$decl_release"

	# Step 6: convergence. A write that did not take ABORTS the run; it is never
	# left to be retried on the next timer (SC-319).
	local converged=true abort=""
	if ! reconcile_apply_converge; then
		converged=false
		abort="non-convergence"
	fi

	rm -rf "$scratch" 2>/dev/null || true
	RECONCILE_TEMPLATE_DIR=""

	# Step 7: durable record OUTSIDE runs/, then emit.
	local doc; doc="$(reconcile_apply_doc "$abort" "$converged")"
	reconcile_record_write "$doc" || engine_log "reconcile: could not write the durable apply record"
	printf '%s\n' "$doc"

	if [[ "$converged" != true ]]; then
		# The series stays open: archive/current is kept so the retry resumes
		# this archive rather than snapshotting files the aborted attempt may
		# already have changed.
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi
	# Converged and recorded: the series is over. The next apply — however
	# many releases later — opens a fresh archive and captures ITS pre-state.
	reconcile_archive_close
	local gaps
	gaps="$(jq -r '.coverage_gaps' <<<"$doc" 2>/dev/null || echo 0)"
	if [[ "$gaps" =~ ^[0-9]+$ ]] && (( gaps > 0 )); then
		return "$RECONCILE_EXIT_FINDINGS"
	fi
	return "$RECONCILE_EXIT_OK"
}

cmd_reconcile_config() {
	local check=0 apply=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--check) check=1; shift ;;
			# UPD-14 task 2: the WRITE half. A SEPARATE flag from --check so the
			# daily drift unit (which passes --check) can never fix by accident,
			# and so `--apply` is always a deliberate word. Its whole trust
			# boundary — flock, signed-artifact verify, archive+marker,
			# convergence — is in cmd_reconcile_apply.
			--apply) apply=1; shift ;;
			# ACCEPTED AND IGNORED, on purpose, and it must stay accepted: the
			# panel sends it (UpdateDriftService::CHECK_ARGV) and an unknown
			# argument here is a die(). This verb has exactly one output format
			# because its only consumers are a JSON parser and jq — it does not
			# set JSON_OUTPUT, because that would advertise a text mode that has
			# never existed and hand the next person a default to "fix".
			# tests/test-updater-reconcile-check.sh pins both forms to JSON.
			--json)  shift ;;
			*) die "reconcile-config: unknown argument: $1" ;;
		esac
	done
	# --check and --apply are opposite verbs; asking for both at once is an
	# operator error, not a "detect then fix" — refuse rather than guess an order.
	if (( check == 1 && apply == 1 )); then
		die "reconcile-config: --check and --apply are mutually exclusive"
	fi
	if (( apply == 1 )); then
		cmd_reconcile_apply
		return $?
	fi
	# A bare `reconcile-config` still refuses: the word must never mean "and fix
	# it" by accident on a box where nothing can.
	(( check == 1 )) || die "reconcile-config: --check or --apply is required — a bare reconcile-config DETECTS nothing and CHANGES nothing"

	# THE `||` IS LOAD-BEARING, and it is the single most important character in
	# this verb. The script runs under `set -euo pipefail`, and every predicate's
	# "the thing is missing" case IS the non-zero case — grep finding nothing,
	# stat on an absent file, systemctl on an unshowable name. Consuming
	# reconcile_evaluate as part of an `||` list suppresses errexit for the whole
	# dynamic extent of the evaluation, which is what lets a predicate answer "no"
	# instead of killing the process.
	#
	# Change this to `reconcile_evaluate; rc=$?` and the FIRST drifted box exits at
	# the return-3 with no document on stdout: the consumer sees an empty parse,
	# classifies it as an error, preserves its last CLEAN cache — and a box that
	# acquires drift silently stops reporting drift. Measured: 41 of this verb's
	# tests go red on exactly that one-character change.
	local rc=0
	reconcile_evaluate || rc=$?
	printf '%s\n' "$RECONCILE_DOC"
	return "$rc"
}

# cmd_fix_db_ownership — re-assert shcp ownership on the panel DB and its
# sidecars after a root process (e.g. the drift-refresh console call in
# shcp-update-drift.service) materialized root-owned sidecars. Symlink/hardlink-
# safe (SC-663 / R4): db_fix_ownership refuses any sidecar that is not a lone
# regular file, so a panel-planted symlink at a sidecar name cannot redirect the
# chown onto an arbitrary file.
#
# DELIBERATELY UNLOCKED — the one verb that writes without the run flock. It is
# the drift unit's ExecStartPost, so it can overlap an apply's DB swap (or a
# rollback's restore) that holds the lock. That is accepted, not overlooked:
# it takes no lock because a lock wait would hold a `-`-prefixed ExecStartPost
# for the length of an apply and the unit has RuntimeMaxSec; it is safe because
# every target is re-validated as a lone regular file at the moment it is
# touched (chown -h, no-follow chmod, post-verify), a swap it races leaves at
# worst a sidecar the apply's own db stage re-owns, and it always exits 0 so it
# can neither fail the unit nor be mistaken for a verdict. Does not touch the
# journal. Prints nothing (a one-line JSON document under --json).
cmd_fix_db_ownership() {
	local arg
	for arg in "$@"; do
		case "$arg" in
			# The global flag, honoured after the verb too (main() takes it
			# before). This verb's only output is the document below.
			--json) JSON_OUTPUT=1 ;;
			*) die "fix-db-ownership: unknown argument '${arg}'" ;;
		esac
	done
	db_fix_ownership "$SHCP_UPDATE_DB"
	if (( JSON_OUTPUT == 1 )); then
		jq -nc --arg db "$SHCP_UPDATE_DB" \
			'{schema_version: 1, verb: "fix-db-ownership", result: "ok", db: $db}'
	fi
	return 0
}

# usage — the operator-facing help, a STATIC heredoc on stdout. It used to
# scrape this file's own header (`sed -n '3,${/^# State machine/q;p}' "$0"`),
# which (a) broke the moment the sentinel paragraph was reworded, (b) printed
# the CALLER's header when the engine is sourced by the suite ($0 is the test),
# (c) died under set -e when $0 was not a readable file, and (d) split the text
# across stdout (header) and stderr (Usage). Contract now:
#   --help / -h / help   the full text below on STDOUT, exit 0
#   no verb              usage_short on STDERR, exit 2 (main)
#   unknown verb         die() → 1
# The verb list is asserted against main()'s case arms by
# tests/test-updater-reconcile-check.sh, so it cannot drift from the dispatch.
# --- webmail transaction (WM-6, shcp-updater#292) -----------------------------
#
# `webmail activate` moves webmail from what it serves now (a tarball-era legacy
# tree, or an older package release) to the installed shcp-webmail payload, as
# one journaled transaction that either commits or puts every piece back. The
# design and its review record: shcp-master docs/webmail-updater-transaction-plan.md.
#
# The engine owns four things: the verified copy of the payload, the database
# and config snapshots, the /var/www/webmail link and the journal. Everything
# that knows a family's paths — parking and rendering the FPM pool, rendering
# config, the schema migration, the FPM reload, the health probe and opening
# ingress — is the installer's /usr/sbin/shcp-webmail-control, driven verb by
# verb (SC-867). The engine resolves no distro path.
#
# Nothing in this section runs before a journal exists unless it can only
# refuse: every check that can say no runs first, and a refusal (exit 2)
# leaves no journal and no change.

# Test seam: a prefix for every path below, so the suites can build a box in a
# temp dir. Production leaves it empty.
WM_ROOT="${SHCP_UPDATE_WEBMAIL_ROOT:-}"
WEBMAIL_PACKAGE_DIR="${WM_ROOT}/usr/share/shcp-webmail"
WEBMAIL_PAYLOAD_DIR="${WEBMAIL_PACKAGE_DIR}/payload"
WEBMAIL_MANIFEST="${WEBMAIL_PACKAGE_DIR}/payload-manifest.json"
WEBMAIL_RELEASE_ROOT="${WM_ROOT}/opt/shcp-webmail/releases"
WEBMAIL_LINK="${WM_ROOT}/var/www/webmail"
WEBMAIL_LEGACY_PARENT="${WM_ROOT}/var/www"
WEBMAIL_CONTROL_CLI="${WM_ROOT}/usr/sbin/shcp-webmail-control"
WEBMAIL_STATE_DIR="${WM_ROOT}/var/lib/shcp-webmail-control"
WEBMAIL_SNAPSHOT_ROOT="${WEBMAIL_STATE_DIR}/snapshots"
WEBMAIL_LAST_FAILURE="${WEBMAIL_STATE_DIR}/last-failure.json"
WEBMAIL_CONFIG_DIR="${WM_ROOT}/etc/shcp-roundcube"
WEBMAIL_MODE_FILE="${WEBMAIL_CONFIG_DIR}/distribution-mode"
WEBMAIL_DES_KEY_FILE="${WEBMAIL_CONFIG_DIR}/des_key"
WEBMAIL_DATA_DIR="${WM_ROOT}/var/lib/roundcube"
WEBMAIL_DB="${WEBMAIL_DATA_DIR}/roundcube.db"
WEBMAIL_ENIGMA_DIR="${WEBMAIL_DATA_DIR}/enigma"
WEBMAIL_USER="shcp-webmail"
# A real manifest is ~0.7 MiB for ~3,200 files; the cap only bounds a hostile one.
WEBMAIL_MANIFEST_MAX_BYTES=$((16 * 1024 * 1024))
# Per filesystem, on top of what the transaction is computed to need (F6).
WEBMAIL_HEADROOM_KB="${SHCP_UPDATE_WEBMAIL_HEADROOM_KB:-65536}"
WEBMAIL_RELEASE_ID_RE='^webmail-[0-9][0-9.]*-shcp\.[1-9][0-9]*$'
# The release dir carries its own signed manifest, so a later run can prove a
# tree is still the release it claims to be (the rollback target, A-7).
WEBMAIL_MANIFEST_COPY=".shcp-manifest.json"

WEBMAIL_REFUSAL=""
WEBMAIL_STAGE_DIR=""
WEBMAIL_RELEASE_ID=""
WEBMAIL_RELEASE_DIR=""
WEBMAIL_MANIFEST_SHA256=""
WEBMAIL_PREV_KIND=""
WEBMAIL_PREV_TARGET=""
WEBMAIL_PREV_ROOT=""
WEBMAIL_PREV_MODE=""

# webmail_refuse <reason> — record why the run cannot start. Always returns 1,
# so callers write `… || { webmail_refuse "…"; return 1; }`.
webmail_refuse() {
	WEBMAIL_REFUSAL="$*"
	engine_log "webmail: refusing: $*"
	return 1
}

webmail_release_id_valid() { [[ "$1" =~ $WEBMAIL_RELEASE_ID_RE ]]; }

# webmail_uid — the webmail account's uid. SHCP_UPDATE_WEBMAIL_UID is the test
# seam, as SHCP_UPDATE_EXPECT_UID is for root.
webmail_uid() {
	local u="${SHCP_UPDATE_WEBMAIL_UID:-}"
	[[ -n "$u" ]] || u="$(id -u -- "$WEBMAIL_USER" 2>/dev/null)" || return 1
	[[ "$u" =~ ^[0-9]+$ && "$u" != 0 ]] || return 1
	printf '%s' "$u"
}

# webmail_file_trusted <path> — a lone regular file, owned by root and
# unwritable by anyone else. Link count 1: a hard link is a second name some
# other directory controls.
webmail_file_trusted() {
	[[ -f "$1" && ! -L "$1" ]] || return 1
	[[ "$(stat -c %h -- "$1" 2>/dev/null)" == 1 ]] || return 1
	path_uid0_not_writable "$1"
}

# webmail_dir_chain_trusted <dir> [stop] — <dir> and every directory above it,
# up to and including <stop> (default /), is a real directory owned by root and
# unwritable by others. Any one writable link in the chain lets its owner swap
# everything below it.
webmail_dir_chain_trusted() {
	local d="$1" stop="${2:-/}"
	while :; do
		[[ -d "$d" && ! -L "$d" ]] || return 1
		path_uid0_not_writable "$d" || return 1
		[[ "$d" == "$stop" || "$d" == / ]] && return 0
		d="${d%/*}"
		[[ -n "$d" ]] || d=/
	done
}

# webmail_control_trusted — the control CLI root runs on every state change
# (SC-867): root's, unwritable, executable, and so is
# every directory above it.
webmail_control_trusted() {
	webmail_file_trusted "$WEBMAIL_CONTROL_CLI" && [[ -x "$WEBMAIL_CONTROL_CLI" ]] \
		&& webmail_dir_chain_trusted "${WEBMAIL_CONTROL_CLI%/*}" "${WM_ROOT:-/}"
}

webmail_relabel() {   # <path>… — reassert file_contexts recursively; no-op without SELinux
	command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled || return 0
	command -v restorecon >/dev/null 2>&1 || return 1
	webmail_nolock restorecon -RF -- "$@"
}

# webmail_test_hook <point> [args…] — the suites' fault seam (a payload file
# swapped between copy and verify, and the like). Never runs as root.
webmail_test_hook() {
	[[ -n "${SHCP_UPDATE_WEBMAIL_TEST_HOOK:-}" ]] || return 0
	(( EUID != 0 )) || return 0
	"$SHCP_UPDATE_WEBMAIL_TEST_HOOK" "$@"
}

# --- the signed manifest ---

webmail_manifest_signed() {   # <manifest> <detached-asc> — signed by the pinned release primary
	local status
	[[ "$RELEASE_FPR" =~ ^[0-9A-F]{40}$ && -r "$RELEASE_KEYRING" ]] || return 1
	status="$(webmail_nolock gpgv --keyring "$RELEASE_KEYRING" --status-fd 1 "$2" "$1" 2>/dev/null)" || return 1
	grep -qE "^\[GNUPG:\] VALIDSIG .*[[:space:]]${RELEASE_FPR}[[:space:]]*$" <<<"$status"
}

# webmail_manifest_shape_ok <manifest> — a document this engine can act on.
# Paths are relative, use only the characters a real payload does, and never
# name `.`, `..` or the manifest copy; modes carry no setid bit and no group or
# other write.
webmail_manifest_shape_ok() {
	jq -e --arg re "$WEBMAIL_RELEASE_ID_RE" --arg copy "$WEBMAIL_MANIFEST_COPY" '
		type == "object" and .format == 1
		and (.release_id | type == "string" and test($re))
		and .protocols.updater_webmail == 1
		and (.files | type == "object" and length > 0)
		and all(.files | to_entries[];
			(.key | test("^[A-Za-z0-9._+@-]+(/[A-Za-z0-9._+@-]+)*$"))
			and (.key | split("/") | all(. != "." and . != ".."))
			and (.key | startswith($copy) | not)
			and (.value.sha256 | type == "string" and test("^[0-9a-f]{64}$"))
			and (.value.size | type == "number" and . >= 0 and . == floor)
			and (.value.mode | type == "string" and test("^0[0-7][0145][0145]$")))
	' "$1" >/dev/null 2>&1
}

# webmail_tree_expected <manifest> <uid> / webmail_tree_actual <dir> — one line
# per entry, in the same shape: what an exact copy contains, and what <dir>
# contains. Directories are exactly the ancestors of manifest files (the payload
# has no empty one); anything that is neither a file nor a directory is an `x`
# line, which nothing expects.
webmail_tree_expected() {
	jq -r --arg u "$2" --arg copy "$WEBMAIL_MANIFEST_COPY" '
		(.files | to_entries[] | "f \(.value.mode | ltrimstr("0")) 1 \($u) \(.key)"),
		([.files | keys[] | split("/") | .[:-1] | select(length > 0)
			| . as $p | range(1; length + 1) | $p[:.] | join("/")] | unique[] | "d 755 \($u) \(.)"),
		"f 644 1 \($u) \($copy)", "f 644 1 \($u) \($copy).asc"' "$1" | LC_ALL=C sort
}
webmail_tree_actual() {
	find "$1" -mindepth 1 \( -type f -printf 'f %m %n %U %P\n' \) \
		-o \( -type d -printf 'd %m %U %P\n' \) -o -printf 'x %y %P\n' | LC_ALL=C sort
}

# webmail_tree_verify <dir> — <dir> holds exactly what its own manifest copy
# lists: no extra entry, no link, every mode and owner as listed, every byte as
# hashed. Proves a copy, never a source (SC-864): the caller has already proven
# the manifest copy is the signed one.
webmail_tree_verify() {
	local dir="$1" m="$1/${WEBMAIL_MANIFEST_COPY}"
	[[ -f "$m" && ! -L "$m" ]] || return 1
	diff -q <(webmail_tree_expected "$m" "$SHCP_UPDATE_EXPECT_UID") <(webmail_tree_actual "$dir") >/dev/null \
		|| return 1
	( cd -- "$dir" && jq -r '.files | to_entries[] | "\(.value.sha256)  \(.key)"' "$WEBMAIL_MANIFEST_COPY" \
		| sha256sum --check --strict --status - )
}

# --- staging: copy, verify the copy, rename (SC-864) ---

webmail_stage_path_ok() { [[ "${1%/*}" == "$WEBMAIL_RELEASE_ROOT" && "${1##*/}" == .stage-* ]] && run_id_valid "${1##*/.stage-}"; }

webmail_stage_discard() {
	local d="$WEBMAIL_STAGE_DIR"
	[[ -n "$d" ]] || return 0
	# Re-validate before building an rm -rf path (SC-256).
	webmail_stage_path_ok "$d" || return 1
	rm -rf -- "$d"
	WEBMAIL_STAGE_DIR=""
}

# A directory made here inherits the /opt type (usr_t), not the file_contexts rule
# the releases beneath it get (shcp-installer#1707), so it is labelled at once.
# When that fails the still-empty directories go again, so the next run retries
# rather than finding them present and leaving them unlabelled for good.
webmail_release_root_ensure() {
	local parent="${WEBMAIL_RELEASE_ROOT%/*}" d i
	local -a made=()
	for d in "$parent" "$WEBMAIL_RELEASE_ROOT"; do
		[[ -d "$d" ]] && continue
		mkdir -m 0755 -- "$d" || return 1
		made+=("$d")
	done
	webmail_dir_chain_trusted "$WEBMAIL_RELEASE_ROOT" "${WM_ROOT:-/}" || return 1
	(( ${#made[@]} == 0 )) || webmail_relabel "${made[@]}" && return 0
	for (( i = ${#made[@]} - 1; i >= 0; i-- )); do rmdir -- "${made[i]}" 2>/dev/null; done
	return 2
}

# webmail_stage_manifest <run-id> — copy the package manifest and its signature
# into a fresh root-only stage dir and verify THE COPY. Everything after this
# reads the copy; /usr/share is never read for a manifest again. Sets
# WEBMAIL_STAGE_DIR, WEBMAIL_RELEASE_ID, WEBMAIL_MANIFEST_SHA256.
webmail_stage_manifest() {
	local run_id="$1" stage copy size
	webmail_dir_chain_trusted "$WEBMAIL_PAYLOAD_DIR" "${WM_ROOT:-/}" \
		&& webmail_file_trusted "$WEBMAIL_MANIFEST" && webmail_file_trusted "${WEBMAIL_MANIFEST}.asc" \
		|| { webmail_refuse "the shcp-webmail package (${WEBMAIL_PACKAGE_DIR}) is missing, or it or a directory above it is not root-owned and unwritable by others"; return 1; }
	size="$(stat -c %s -- "$WEBMAIL_MANIFEST")" || return 1
	(( size <= WEBMAIL_MANIFEST_MAX_BYTES )) \
		|| { webmail_refuse "${WEBMAIL_MANIFEST} is ${size} bytes, over the ${WEBMAIL_MANIFEST_MAX_BYTES}-byte cap"; return 1; }
	webmail_release_root_ensure || {
		if (( $? == 2 )); then
			webmail_refuse "could not relabel ${WEBMAIL_RELEASE_ROOT} (SELinux is enabled but restorecon failed or is missing)"
		else
			webmail_refuse "${WEBMAIL_RELEASE_ROOT} could not be created, or it or a directory above it is not root-owned and unwritable by others"
		fi
		return 1
	}
	stage="${WEBMAIL_RELEASE_ROOT}/.stage-${run_id}"
	mkdir -m 0700 -- "$stage" 2>/dev/null \
		|| { webmail_refuse "could not create the stage dir ${stage}"; return 1; }
	WEBMAIL_STAGE_DIR="$stage"
	copy="${stage}/${WEBMAIL_MANIFEST_COPY}"
	install -m 0644 -- "$WEBMAIL_MANIFEST" "$copy" && install -m 0644 -- "${WEBMAIL_MANIFEST}.asc" "${copy}.asc" \
		|| { webmail_refuse "could not copy the payload manifest into ${stage}"; return 1; }
	webmail_manifest_signed "$copy" "${copy}.asc" \
		|| { webmail_refuse "the payload manifest is not signed by the pinned release primary ${RELEASE_FPR}"; return 1; }
	webmail_manifest_shape_ok "$copy" \
		|| { webmail_refuse "the signed payload manifest is not a format-1 updater_webmail manifest this engine can act on"; return 1; }
	WEBMAIL_RELEASE_ID="$(jq -r '.release_id' "$copy")" || return 1
	WEBMAIL_MANIFEST_SHA256="$(sha256sum -- "$copy")" || return 1
	WEBMAIL_MANIFEST_SHA256="${WEBMAIL_MANIFEST_SHA256%% *}"
}

# webmail_stage_payload — copy every manifest file into the stage dir, set the
# listed modes, verify the copy, and rename it into place. A directory already
# carrying the release id is reused only if it verifies byte for byte against
# this manifest; an id is a promise about bytes. Sets WEBMAIL_RELEASE_DIR.
webmail_stage_payload() {
	local stage="$WEBMAIL_STAGE_DIR" m="${WEBMAIL_STAGE_DIR}/${WEBMAIL_MANIFEST_COPY}"
	local dest="${WEBMAIL_RELEASE_ROOT}/${WEBMAIL_RELEASE_ID}" kind n rel mode
	local -A lone=()
	webmail_stage_path_ok "$stage" && webmail_release_id_valid "$WEBMAIL_RELEASE_ID" || return 1

	if [[ -e "$dest" || -L "$dest" ]]; then
		if [[ -d "$dest" && ! -L "$dest" ]] && path_uid0_not_writable "$dest" \
				&& cmp -s -- "$m" "${dest}/${WEBMAIL_MANIFEST_COPY}" && webmail_tree_verify "$dest"; then
			webmail_stage_discard || return 1
			WEBMAIL_RELEASE_DIR="$dest"
			return 0
		fi
		webmail_refuse "${dest} exists but is not exactly the signed ${WEBMAIL_RELEASE_ID} payload; move it aside to re-stage it"
		return 1
	fi

	# One listing of the source: every listed file must be a lone regular file.
	# A symlink or a hard link in the package tree is refused, never followed.
	while read -r kind n rel; do
		[[ "$kind" == f && "$n" == 1 ]] && lone["$rel"]=1
	done < <(find "$WEBMAIL_PAYLOAD_DIR" -mindepth 1 \( -type f -printf 'f %n %P\n' \) -o -printf 'x 0 %P\n')
	while IFS= read -r rel; do
		[[ -n "${lone[$rel]:-}" ]] \
			|| { webmail_refuse "payload entry ${rel} is missing, or not a single-link regular file"; return 1; }
	done < <(jq -r '.files | keys[]' "$m")

	( umask 022
	  cd -- "$stage" || exit 1
	  jq -r '[.files | keys[] | split("/") | .[:-1] | select(length > 0) | join("/")] | unique[]' \
		"$WEBMAIL_MANIFEST_COPY" | xargs -r -d '\n' mkdir -p -- ) \
		|| { webmail_refuse "could not create the payload directories in ${stage}"; return 1; }
	jq -r '.files | keys[]' "$m" | ( cd -- "$WEBMAIL_PAYLOAD_DIR" && xargs -r -d '\n' cp -P --parents -t "$stage" -- ) \
		|| { webmail_refuse "copying the payload into ${stage} failed"; return 1; }
	# chmod follows symlinks, so prove there are none before the first one runs.
	[[ -z "$(find "$stage" -mindepth 1 ! -type f ! -type d -print -quit)" ]] \
		|| { webmail_refuse "the copied payload contains an entry that is neither a file nor a directory"; return 1; }
	for mode in $(jq -r '[.files[].mode] | unique[]' "$m"); do
		jq -r --arg m "$mode" '.files | to_entries[] | select(.value.mode == $m) | .key' "$m" \
			| ( cd -- "$stage" && xargs -r -d '\n' chmod "$mode" -- ) \
			|| { webmail_refuse "could not set mode ${mode} in ${stage}"; return 1; }
	done
	webmail_test_hook after-copy "$stage"
	webmail_tree_verify "$stage" \
		|| { webmail_refuse "the copied payload does not match its signed manifest (SC-864)"; return 1; }

	# Labelled before it is renamed into place, so a release that exists is
	# always a labelled one; the stage path matches the same file_contexts spec.
	webmail_relabel "$stage" || { webmail_refuse "could not relabel ${stage} (SELinux is enabled but restorecon failed or is missing)"; return 1; }
	chmod 0755 -- "$stage" || return 1
	mv -T -- "$stage" "$dest" || { webmail_refuse "could not rename ${stage} to ${dest}"; return 1; }
	WEBMAIL_STAGE_DIR=""
	WEBMAIL_RELEASE_DIR="$dest"
}

# --- what webmail serves now (the rollback target) ---

# webmail_mode_package — the distribution-mode record says `package`: exactly
# those bytes, root:root 0600, one link (§4a). Anything else is not package mode.
webmail_mode_package() {
	webmail_file_trusted "$WEBMAIL_MODE_FILE" || return 1
	[[ "$(stat -c %a -- "$WEBMAIL_MODE_FILE")" == 600 ]] || return 1
	cmp -s -- "$WEBMAIL_MODE_FILE" <(printf 'package\n')
}

# webmail_previous_resolve — what /var/www/webmail serves now, as the target a
# rollback re-renders and re-links. Refuses unless that target still exists and
# is what it claims (A-7): a run must never start when it could not roll back.
webmail_previous_resolve() {
	local t
	[[ -L "$WEBMAIL_LINK" ]] || { webmail_refuse "${WEBMAIL_LINK} is not a symlink"; return 1; }
	t="$(readlink -- "$WEBMAIL_LINK")" || return 1
	if [[ "${t%/*}" == "$WEBMAIL_RELEASE_ROOT" ]] && webmail_release_id_valid "${t##*/}"; then
		[[ -d "$t" && ! -L "$t" ]] \
			&& webmail_manifest_signed "${t}/${WEBMAIL_MANIFEST_COPY}" "${t}/${WEBMAIL_MANIFEST_COPY}.asc" \
			&& webmail_tree_verify "$t" \
			|| { webmail_refuse "the live release ${t} no longer verifies against its signed manifest, so there is nothing safe to roll back to"; return 1; }
		WEBMAIL_PREV_KIND=release
		WEBMAIL_PREV_TARGET="${t##*/}"
	elif [[ "${t%/*}" == "$WEBMAIL_LEGACY_PARENT" && "${t##*/}" =~ ^roundcubemail-[0-9][0-9.]*$ ]]; then
		[[ -d "$t" && ! -L "$t" ]] \
			|| { webmail_refuse "the live legacy tree ${t} is not a real directory"; return 1; }
		WEBMAIL_PREV_KIND=legacy
		WEBMAIL_PREV_TARGET="legacy:${t}"
	else
		webmail_refuse "${WEBMAIL_LINK} points at ${t}, which is neither a package release nor a legacy roundcubemail tree"
		return 1
	fi
	WEBMAIL_PREV_ROOT="$t"
	if webmail_mode_package; then WEBMAIL_PREV_MODE=package; else WEBMAIL_PREV_MODE=legacy; fi
	# The link and the mode record move together, at commit and at rollback. Out
	# of step means a run was interrupted outside a journal, or someone edited the
	# box: refuse rather than guess which half is right (A-8).
	if [[ "$WEBMAIL_PREV_KIND" == release && "$WEBMAIL_PREV_MODE" != package ]] \
			|| [[ "$WEBMAIL_PREV_KIND" == legacy && -e "$WEBMAIL_MODE_FILE" ]]; then
		webmail_refuse "${WEBMAIL_LINK} (${WEBMAIL_PREV_KIND}) and ${WEBMAIL_MODE_FILE} disagree; inspect the box before activating"
		return 1
	fi
}

# webmail_release_older <a> <b> — release id <a> is older than <b>:
# upstream version by sort -V, then the shcp build number.
webmail_release_older() {
	local au="${1#webmail-}" bu="${2#webmail-}" an bn
	local -a s
	an="${au##*-shcp.}"; au="${au%-shcp.*}"
	bn="${bu##*-shcp.}"; bu="${bu%-shcp.*}"
	if [[ "$au" != "$bu" ]]; then
		mapfile -t s < <(printf '%s\n%s\n' "$au" "$bu" | sort -V)
		[[ "${s[0]}" == "$au" ]]
		return
	fi
	(( an < bn ))
}

# --- the data the transaction snapshots ---

# webmail_db_ok — roundcube.db and every SQLite sidecar present beside it is a
# lone regular file the webmail uid owns. Root snapshots and renames these; a
# link here would point those operations somewhere else (A-3).
webmail_db_ok() {
	local uid f
	uid="$(webmail_uid)" || { webmail_refuse "the ${WEBMAIL_USER} account does not exist"; return 1; }
	[[ -d "$WEBMAIL_DATA_DIR" && ! -L "$WEBMAIL_DATA_DIR" ]] \
		|| { webmail_refuse "${WEBMAIL_DATA_DIR} is not a real directory"; return 1; }
	[[ -e "$WEBMAIL_DB" ]] || { webmail_refuse "${WEBMAIL_DB} does not exist"; return 1; }
	for f in "$WEBMAIL_DB" "${WEBMAIL_DB}-journal" "${WEBMAIL_DB}-wal" "${WEBMAIL_DB}-shm"; do
		[[ -e "$f" || -L "$f" ]] || continue
		[[ -f "$f" && ! -L "$f" && "$(stat -c '%h %u' -- "$f")" == "1 ${uid}" ]] \
			|| { webmail_refuse "${f} is not a single-link regular file owned by ${WEBMAIL_USER}"; return 1; }
	done
}

# webmail_snapshot_root_ok — the root-only directory the database snapshot goes
# in, on the same filesystem as the database so restore is a rename (F6, A-3).
webmail_snapshot_root_ok() {
	# The installer makes this directory on first use (_rcm_control_state_dir:
	# root 0755), so a box that has never quiesced webmail or written a request
	# has none yet. Make it the same way; an existing one is judged, never fixed.
	if [[ ! -e "$WEBMAIL_STATE_DIR" && ! -L "$WEBMAIL_STATE_DIR" ]]; then
		mkdir -m 0755 -- "$WEBMAIL_STATE_DIR" && webmail_relabel "$WEBMAIL_STATE_DIR" \
			|| { webmail_refuse "could not create ${WEBMAIL_STATE_DIR}"; return 1; }
	fi
	webmail_dir_chain_trusted "$WEBMAIL_STATE_DIR" "${WM_ROOT:-/}" \
		|| { webmail_refuse "${WEBMAIL_STATE_DIR} or a directory above it is not root-owned and unwritable by others"; return 1; }
	[[ -d "$WEBMAIL_SNAPSHOT_ROOT" ]] || mkdir -m 0700 -- "$WEBMAIL_SNAPSHOT_ROOT" || return 1
	[[ ! -L "$WEBMAIL_SNAPSHOT_ROOT" && "$(stat -c '%u %a' -- "$WEBMAIL_SNAPSHOT_ROOT")" == "${SHCP_UPDATE_EXPECT_UID} 700" ]] \
		|| { webmail_refuse "${WEBMAIL_SNAPSHOT_ROOT} is not a root 0700 directory"; return 1; }
	[[ "$(stat -c %d -- "$WEBMAIL_SNAPSHOT_ROOT")" == "$(stat -c %d -- "$WEBMAIL_DATA_DIR")" ]] \
		|| { webmail_refuse "${WEBMAIL_SNAPSHOT_ROOT} and ${WEBMAIL_DATA_DIR} are on different filesystems, so the database could not be restored by rename"; return 1; }
}

# webmail_import_legacy <tree> — a legacy tree may be left behind only when
# nothing in it is still the only copy: its des_key is the one recorded in the
# sidecar (SC-414), and every Enigma keyring has been moved to the data dir.
# The same rules the installer applies before it retires a tree (SC-865).
webmail_import_legacy() {
	local tree="$1" key side home
	webmail_file_trusted "$WEBMAIL_DES_KEY_FILE" && [[ "$(stat -c %a -- "$WEBMAIL_DES_KEY_FILE")" == 600 ]] \
		|| { webmail_refuse "${WEBMAIL_DES_KEY_FILE} is missing or not a root 0600 single-link file; run installer converge first"; return 1; }
	side="$(<"$WEBMAIL_DES_KEY_FILE")"
	[[ "$side" =~ ^[A-Za-z0-9+/]{24}$ ]] \
		|| { webmail_refuse "${WEBMAIL_DES_KEY_FILE} does not hold a 24-character key"; return 1; }
	key="$(sed -nE "s/.*\\\$config\[['\"]des_key['\"]\][[:space:]]*=[[:space:]]*['\"]([^'\"]*)['\"][[:space:]]*;.*/\1/p" \
		"${tree}/config/config.inc.php" 2>/dev/null | sed -n 1p)" || key=""
	# As the installer's migrate path does: a key that cannot be read out of the
	# tree's config is a refusal, not a pass — it is the key every stored
	# preference is encrypted with.
	[[ -n "$key" ]] || { webmail_refuse "cannot read the des_key from ${tree}/config/config.inc.php"; return 1; }
	[[ "$key" == "$side" ]] \
		|| { webmail_refuse "${tree}'s des_key is not the one recorded in ${WEBMAIL_DES_KEY_FILE}"; return 1; }
	for home in "${tree}"/plugins/enigma/home/*/; do
		[[ -d "$home" ]] || continue
		home="${home%/}"
		[[ -d "${WEBMAIL_ENIGMA_DIR}/${home##*/}" ]] \
			|| { webmail_refuse "the Enigma keyring ${home##*/} is still only in ${tree}; run installer converge first"; return 1; }
	done
}

# webmail_space_ok — every filesystem the transaction writes has room for what
# it will write plus headroom (F6), summed per filesystem: the payload copy, the
# database snapshot plus room for the migration to grow it, the config copy.
webmail_space_ok() {
	local -A need=()
	local dev path kb avail db_kb cfg_kb=0 f
	local -a dbfiles=()
	if [[ ! -d "${WEBMAIL_RELEASE_ROOT}/${WEBMAIL_RELEASE_ID}" ]]; then
		kb="$(jq '[.files[].size] | add / 1024 | floor + 1' "${WEBMAIL_STAGE_DIR}/${WEBMAIL_MANIFEST_COPY}")" || return 1
		dev="$(stat -c %d -- "$WEBMAIL_RELEASE_ROOT")" || return 1
		need[$dev]=$(( ${need[$dev]:-0} + kb ))
	fi
	for f in "$WEBMAIL_DB" "${WEBMAIL_DB}-journal" "${WEBMAIL_DB}-wal" "${WEBMAIL_DB}-shm"; do
		[[ -f "$f" ]] && dbfiles+=("$f")
	done
	db_kb="$(du -sk -- "${dbfiles[@]}" | awk '{s += $1} END {print s + 0}')" || return 1
	dev="$(stat -c %d -- "$WEBMAIL_DATA_DIR")" || return 1
	need[$dev]=$(( ${need[$dev]:-0} + 2 * db_kb ))
	if [[ -d "$WEBMAIL_CONFIG_DIR" ]]; then
		cfg_kb="$(du -sk -- "$WEBMAIL_CONFIG_DIR" | awk '{print $1 + 0}')" || return 1
	fi
	dev="$(stat -c %d -- "${WEBMAIL_CONFIG_DIR%/*}")" || return 1
	need[$dev]=$(( ${need[$dev]:-0} + cfg_kb ))
	for path in "$WEBMAIL_RELEASE_ROOT" "$WEBMAIL_DATA_DIR" "${WEBMAIL_CONFIG_DIR%/*}"; do
		dev="$(stat -c %d -- "$path")" || return 1
		[[ -n "${need[$dev]:-}" ]] || continue
		avail="$(df -Pk -- "$path" | awk 'NR == 2 {print $4}')"
		[[ "$avail" =~ ^[0-9]+$ ]] || { webmail_refuse "could not read free space on ${path}"; return 1; }
		(( avail >= need[$dev] + WEBMAIL_HEADROOM_KB )) \
			|| { webmail_refuse "${path} has ${avail} KiB free; the activation needs $(( need[$dev] + WEBMAIL_HEADROOM_KB )) KiB there"; return 1; }
		unset 'need[$dev]'
	done
}

# webmail_backed_off <release-id> — a run for this release already failed and
# nobody has asked for it again (SC-866). An untrusted
# record is ignored, never obeyed: it can only stop an activation, and a file
# anyone could plant must not be able to.
webmail_backed_off() {
	webmail_file_trusted "$WEBMAIL_LAST_FAILURE" || return 1
	[[ "$(stat -c %a -- "$WEBMAIL_LAST_FAILURE")" == 600 ]] || return 1
	jq -e --arg id "$1" '.format == 1 and .release_id == $id' "$WEBMAIL_LAST_FAILURE" >/dev/null 2>&1
}

# webmail_preflight <run-id> <retry> <allow-downgrade> — every check that can
# refuse, then the verified copy. Writes nothing outside the stage dir and the
# release root, and no journal: a refusal is exit 2 with nothing changed.
webmail_preflight() {
	local run_id="$1" retry="$2" allow_downgrade="$3"
	fresh_run_state_trusted \
		|| { webmail_refuse "${RUN_TREE_UNTRUSTED}; a run started there could not be recovered"; return 1; }
	webmail_control_trusted \
		|| { webmail_refuse "${WEBMAIL_CONTROL_CLI} is missing, not executable, or it or a directory above it is not root-owned and unwritable by others"; return 1; }
	webmail_previous_resolve || return 1
	webmail_db_ok || return 1
	webmail_snapshot_root_ok || return 1
	if [[ "$WEBMAIL_PREV_KIND" == legacy ]]; then
		webmail_import_legacy "$WEBMAIL_PREV_ROOT" || return 1
	fi
	webmail_stage_manifest "$run_id" || return 1
	if [[ "$WEBMAIL_PREV_KIND" == release ]]; then
		[[ "$WEBMAIL_RELEASE_ID" != "$WEBMAIL_PREV_TARGET" ]] \
			|| { webmail_refuse "${WEBMAIL_RELEASE_ID} is already the live release"; return 1; }
		if webmail_release_older "$WEBMAIL_RELEASE_ID" "$WEBMAIL_PREV_TARGET" && [[ "$allow_downgrade" != 1 ]]; then
			webmail_refuse "${WEBMAIL_RELEASE_ID} is older than the live ${WEBMAIL_PREV_TARGET}; pass --allow-downgrade to activate it anyway"
			return 1
		fi
	fi
	if [[ "$retry" != 1 ]] && webmail_backed_off "$WEBMAIL_RELEASE_ID"; then
		webmail_refuse "an activation of ${WEBMAIL_RELEASE_ID} already failed (${WEBMAIL_LAST_FAILURE}); pass --retry to try it again"
		return 1
	fi
	webmail_space_ok || return 1
	webmail_stage_payload || return 1
}

# --- the transaction (WM6-4 / WM6-5) ---
#
# Phases move forward only: admitted → quiesced → snapshotted → configured →
# migrated → switched → resumed → verified → committing → committed. Before
# each side effect the run durably journals `.webmail.intent` (the write-ahead
# record, C-2); after it, the phase. Recovery and rollback never trust the
# phase alone for what was done: the link is read back, a snapshot counts only
# when its recorded hash matches what is on disk, and a restore that already
# happened is recognised by the restored bytes.
#
# `committing` is the line (F1): a run that reached it is completed forward,
# anything earlier is rolled back. Rollback is attempted once per run; a
# rollback that fails is terminal (rolled_back_partial, finished_at set,
# maintenance held) until an operator runs `webmail recover --retry` (A-2).

WEBMAIL_CONTROL_RUN_DIR="${WM_ROOT}/run/shcp-webmail-control"
WEBMAIL_ACTIVATION_LOCK="${WEBMAIL_CONTROL_RUN_DIR}/activation.lock"
WEBMAIL_MIGRATE_REQUEST="${WEBMAIL_STATE_DIR}/migrate-requested"
WEBMAIL_LOCK_WAIT="${SHCP_UPDATE_WEBMAIL_LOCK_WAIT:-60}"
WEBMAIL_LOCK_FD=""
WEBMAIL_RUN=""
WEBMAIL_TERM=0
WEBMAIL_EMITTED=0
WEBMAIL_FAILED_STEP=""

# webmail_nolock <cmd…> — run <cmd> with the activation-lock fd closed. Only
# the control CLI may hold it (SC-867): it is the CLI's
# proof that its caller holds the lock, and a child that outlived the engine
# would keep the lock held.
webmail_nolock() {
	if [[ -n "$WEBMAIL_LOCK_FD" ]]; then
		"$@" {WEBMAIL_LOCK_FD}>&-
	else
		"$@"
	fi
}

# webmail_activation_lock — take the installer's activation lock, waiting up to
# WEBMAIL_LOCK_WAIT seconds. The same checks rcm_activation_lock makes: a
# root-only 0700 directory, a root 0600 single-link lock file, and the open fd
# being that file. Returns 0 held, 1 unsafe (refused, never repaired), 2 busy.
webmail_activation_lock() {
	local dir="$WEBMAIL_CONTROL_RUN_DIR" path="$WEBMAIL_ACTIVATION_LOCK" fd want
	[[ -e "$dir" || -L "$dir" ]] || ( umask 077 && mkdir -- "$dir" ) || return 1
	[[ -d "$dir" && ! -L "$dir" && "$(stat -c '%u %a' -- "$dir")" == "${SHCP_UPDATE_EXPECT_UID} 700" ]] || return 1
	if [[ -L "$path" ]] || [[ -e "$path" && ! -f "$path" ]]; then
		return 1
	fi
	( umask 077 && : >>"$path" ) 2>/dev/null || return 1
	exec {fd}<>"$path" || return 1
	want="${SHCP_UPDATE_EXPECT_UID} 600 1 $(stat -c '%d:%i' -- "$path")"
	if [[ "$(stat -L -c '%u %a %h %d:%i' "/proc/self/fd/${fd}" 2>/dev/null)" != "$want" ]]; then
		exec {fd}>&-
		return 1
	fi
	if ! flock -w "$WEBMAIL_LOCK_WAIT" "$fd"; then
		exec {fd}>&-
		return 2
	fi
	WEBMAIL_LOCK_FD="$fd"
}

# webmail_control <verb> [target] — one verb of /usr/sbin/shcp-webmail-control,
# re-proven trusted first, under an empty environment: the lock fd and, where
# the verb renders for or checks a target, SHCP_WEBMAIL_TARGET: health proves
# that shcpd serves exactly that tree (shcp-installer#1706). Its output goes to
# the run log.
webmail_control() {
	local verb="$1" target="${2:-}" log=/dev/null
	local -a env=(PATH=/usr/sbin:/usr/bin:/sbin:/bin)
	webmail_control_trusted || { engine_log "webmail: ${WEBMAIL_CONTROL_CLI} is no longer trusted; refusing to run ${verb}"; return 1; }
	[[ -n "$WEBMAIL_LOCK_FD" ]] && env+=("SHCP_WEBMAIL_LOCK_FD=${WEBMAIL_LOCK_FD}")
	[[ -n "$target" ]] && env+=("SHCP_WEBMAIL_TARGET=${target}")
	[[ -n "$WEBMAIL_RUN" && -d "${RUNS_DIR}/${WEBMAIL_RUN}" ]] && log="${RUNS_DIR}/${WEBMAIL_RUN}/log"
	engine_log "webmail: shcp-webmail-control ${verb}${target:+ (target ${target})}"
	env -i "${env[@]}" "$WEBMAIL_CONTROL_CLI" "$verb" >>"$log" 2>&1
}

# webmail_control_status — the CLI's read-only status JSON, re-proven trusted
# like every other call (SC-867).
webmail_control_status() {
	webmail_control_trusted || return 1
	env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin "$WEBMAIL_CONTROL_CLI" status 2>/dev/null
}

# webmail_quiet_check — nothing of webmail's can be writing: no process of the
# webmail uid, and the CLI reports ingress closed, the pool parked and the
# maintenance timer stopped. Re-checked before every step that touches the
# database (A-3).
webmail_quiet_check() {
	local uid st
	uid="$(webmail_uid)" || return 1
	if pgrep -u "$uid" >/dev/null 2>&1; then
		engine_log "webmail: a ${WEBMAIL_USER} process is still running; refusing to touch the database"
		return 1
	fi
	st="$(webmail_control_status)" || return 1
	jq -e '.maintenance == true and .pool_enabled == false and .timer_active == false' <<<"$st" >/dev/null 2>&1 \
		|| { engine_log "webmail: shcp-webmail-control status is not quiesced (${st}); refusing to touch the database"; return 1; }
}

# webmail_ingress_open — the CLI reports no maintenance marker and the pool
# loaded: what open-ingress leaves behind.
webmail_ingress_open() {
	local st
	st="$(webmail_control_status)" || return 1
	jq -e '.maintenance == false and .pool_enabled == true' <<<"$st" >/dev/null 2>&1
}

webmail_sha256() { local h; h="$(sha256sum -- "$1")" || return 1; printf '%s' "${h%% *}"; }

webmail_selinux_on() { command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; }

# webmail_tree_hash <dir> — one hash over every entry's path, type, owner,
# group and mode, and every file's bytes. What a config restore must match.
webmail_tree_hash() {
	local h
	h="$( { ( cd -- "$1" && find . -mindepth 1 -printf '%P\t%y\t%U:%G\t%m\t%l\n' | LC_ALL=C sort ) \
		&& ( cd -- "$1" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum -- ); } | sha256sum )" \
		|| return 1
	printf '%s' "${h%% *}"
}

# --- journal ---

# The barrier is a separate sync, not journal_update_durable: that one removes
# the file when the directory sync fails, which here would delete the only
# record of a transaction in progress. A failed barrier dies with the journal
# intact (exit 3, interrupted, recovered later).
webmail_intent() {   # <action> — write-ahead, durable, BEFORE the effect (C-2)
	local jf
	jf="$(journal_path "$WEBMAIL_RUN")"
	journal_update "$WEBMAIL_RUN" '.webmail.intent = {action: $a, at: $now, done: false}' \
		--arg a "$1" --arg now "$(now_utc)"
	sync -- "$jf" "${jf%/*}" || die "webmail: could not make the intent ${1} durable in ${jf}"
}

# webmail_close <phase> <status> <reason> — one write lands the terminal phase,
# result, status and finished_at together, so no kill can leave a terminal phase
# with finished_at unset (recovery would act on it again).
webmail_close() {
	journal_update "$WEBMAIL_RUN" \
		'.webmail.phase = $p | .webmail.result = $p | .webmail.reason = (if $r == "" then null else $r end) | .webmail.intent.done = true
		 | .status = $st | .error = (if $r == "" then null else $r end) | .finished_at = $now' \
		--arg p "$1" --arg st "$2" --arg r "$3" --arg now "$(now_utc)"
	panel_import_run "$WEBMAIL_RUN"
}

webmail_done() {   # [phase] — the effect landed
	journal_update "$WEBMAIL_RUN" \
		'.webmail.intent.done = true | if $p == "" then . else .webmail.phase = $p end' --arg p "${1:-}"
	[[ -n "${1:-}" ]] && webmail_status_write "$1"
	return 0
}

# webmail_status_write <phase> — the panel's progress document, the same shape
# every run's status.json has (SC-416: scalars and a translation key only).
WEBMAIL_PHASES=(quiesced snapshotted configured migrated switched resumed verified committing committed)
webmail_status_write() {
	local phase="$1" i n=0
	for i in "${!WEBMAIL_PHASES[@]}"; do
		[[ "${WEBMAIL_PHASES[$i]}" == "$phase" ]] && n=$(( i + 1 ))
	done
	jq -n --arg run_id "$WEBMAIL_RUN" --arg p "$phase" --argjson d "$n" --argjson t "${#WEBMAIL_PHASES[@]}" \
		--arg now "$(now_utc)" \
		'{run_id: $run_id, stage: ("webmail:" + $p), stages_done: $d, stages_total: $t,
		  message: ("webmail.phase." + $p), detail: "", pct_estimate: ($d * 100 / $t | floor), updated_at: $now}' \
		| atomic_write "$(status_path "$WEBMAIL_RUN")" 0644 || return 0
	webmail_nolock selinux_restore "$(status_path "$WEBMAIL_RUN")" || true
}

# webmail_crash_point <action> <before|after> — the crash drill seam (as
# SHCP_UPDATE_CRASH_STAGE is for stages): SIGKILL ourselves on either side of
# one effect, the two places a power cut can land.
webmail_crash_point() {
	[[ "${SHCP_UPDATE_WEBMAIL_CRASH_AT:-}" == "$1:$2" ]] || return 0
	engine_log "CRASH DRILL: kill -9 at webmail ${1}:${2}"
	kill -9 $$
}

# webmail_step <action> <phase> <cmd…> — one forward effect, bracketed by its
# intent and its phase. A TERM that arrived during the previous step is seen
# here, at the boundary, and fails the run into rollback.
webmail_step() {
	local action="$1" phase="$2"
	shift 2
	if (( WEBMAIL_TERM )); then
		WEBMAIL_FAILED_STEP="terminated before ${action}"
		return 1
	fi
	webmail_intent "$action"
	webmail_crash_point "$action" before
	if ! "$@"; then
		WEBMAIL_FAILED_STEP="$action"
		return 1
	fi
	webmail_crash_point "$action" after
	webmail_done "$phase"
}

# --- forward effects ---

# webmail_snapshot_db — a consistent copy of roundcube.db, taken after quiesce,
# into the root-only snapshot dir on the same filesystem. `immutable=1` opens it
# without creating a -shm or -journal as root beside the live file. A hot
# journal or an un-checkpointed WAL means the file alone is not the database:
# refuse rather than snapshot half of it.
webmail_snapshot_db() {
	local dir="${WEBMAIL_SNAPSHOT_ROOT}/${WEBMAIL_RUN}" dest sha meta ctx="" ino
	webmail_quiet_check && webmail_db_ok || return 1
	ino="$(stat -c '%d:%i' -- "$WEBMAIL_DB")" || return 1
	if [[ -s "${WEBMAIL_DB}-journal" || -s "${WEBMAIL_DB}-wal" ]]; then
		engine_log "webmail: ${WEBMAIL_DB} has a hot journal or an un-checkpointed WAL; refusing to snapshot it"
		return 1
	fi
	dest="${dir}/roundcube.db"
	[[ "$WEBMAIL_DB$dest" != *"'"* ]] || return 1
	mkdir -m 0700 -- "$dir" || return 1
	webmail_nolock sqlite3 "file:${WEBMAIL_DB}?immutable=1" ".backup '${dest}'" || return 1
	# sqlite3 opens the path, and the directory is the webmail uid's. Prove the
	# path still named the same lone file across the backup, and that what was
	# copied is a Roundcube database: a swap to a link at another SQLite file
	# (the panel's) must not become a "snapshot" a rollback hands to that uid.
	[[ -f "$WEBMAIL_DB" && ! -L "$WEBMAIL_DB" && "$(stat -c '%d:%i %h' -- "$WEBMAIL_DB")" == "${ino} 1" ]] \
		|| { engine_log "webmail: ${WEBMAIL_DB} changed under the snapshot; refusing it"; return 1; }
	[[ "$(webmail_nolock sqlite3 "file:${dest}?immutable=1" 'PRAGMA integrity_check;' 2>/dev/null)" == ok ]] || return 1
	[[ "$(webmail_nolock sqlite3 "file:${dest}?immutable=1" "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'session');" 2>/dev/null)" == 2 ]] \
		|| { engine_log "webmail: the snapshot of ${WEBMAIL_DB} is not a Roundcube database; refusing it"; return 1; }
	sha="$(webmail_sha256 "$dest")" || return 1
	meta="$(stat -c '%u:%g %a' -- "$WEBMAIL_DB")" || return 1
	webmail_selinux_on && ctx="$(stat -c %C -- "$WEBMAIL_DB" 2>/dev/null)"
	journal_update "$WEBMAIL_RUN" \
		'.webmail.snapshot.db = {path: $p, sha256: $h, owner: $o, mode: $m, context: (if $c == "" then null else $c end)}' \
		--arg p "$dest" --arg h "$sha" --arg o "${meta% *}" --arg m "${meta#* }" --arg c "$ctx"
}

# webmail_snapshot_config — a full-metadata copy of /etc/shcp-roundcube beside
# it under root-owned /etc, and its tree hash.
webmail_snapshot_config() {
	local dest="${WEBMAIL_CONFIG_DIR%/*}/.shcp-roundcube.wm6-${WEBMAIL_RUN}" h
	[[ -d "$WEBMAIL_CONFIG_DIR" && ! -L "$WEBMAIL_CONFIG_DIR" ]] || return 1
	[[ ! -e "$dest" && ! -L "$dest" ]] || return 1
	cp -a -- "$WEBMAIL_CONFIG_DIR" "$dest" || return 1
	h="$(webmail_tree_hash "$WEBMAIL_CONFIG_DIR")" || return 1
	[[ "$(webmail_tree_hash "$dest")" == "$h" ]] || return 1
	journal_update "$WEBMAIL_RUN" '.webmail.snapshot.config = {path: $p, tree_sha256: $h}' --arg p "$dest" --arg h "$h"
}

# webmail_link_switch <target> — repoint /var/www/webmail atomically (a staged
# link renamed over the old one, as panel_flip does) and read it back.
webmail_link_switch() {
	local target="$1" staged="${WEBMAIL_LINK}.activating.$$" f
	[[ -d "$target" && ! -L "$target" ]] || return 1
	# A kill mid-switch leaves its staged link behind; only links are removed.
	for f in "${WEBMAIL_LINK}".activating.*; do
		[[ -L "$f" ]] && rm -f -- "$f"
	done
	ln -s -- "$target" "$staged" || return 1
	chown -h 0:0 -- "$staged" 2>/dev/null || true
	if ! mv -T -- "$staged" "$WEBMAIL_LINK"; then
		rm -f -- "$staged"
		return 1
	fi
	[[ "$(readlink -- "$WEBMAIL_LINK")" == "$target" ]]
}

# webmail_mode_write — distribution-mode becomes exactly `package\n`, root 0600.
webmail_mode_write() {
	webmail_mode_package && return 0
	if [[ -L "$WEBMAIL_MODE_FILE" ]] || [[ -e "$WEBMAIL_MODE_FILE" && ! -f "$WEBMAIL_MODE_FILE" ]]; then
		return 1
	fi
	printf 'package\n' | atomic_write_durable "$WEBMAIL_MODE_FILE" || return 1
	webmail_relabel "$WEBMAIL_MODE_FILE" || return 1
	webmail_mode_package
}

# --- rollback effects ---

# webmail_restore_db — put the snapshot back by rename, with every SQLite
# sidecar moved aside with the file it belongs to, then re-apply the recorded
# owner, mode and label and check the result (C-1). Idempotent from observed
# state: a live file that already IS the snapshot is a restore that happened.
webmail_restore_db() {
	local rec path sha owner mode ctx rej sfx
	rec="$(jq -c '.webmail.snapshot.db // empty' "$(journal_path "$WEBMAIL_RUN")")" || return 1
	[[ -n "$rec" ]] || return 0   # no snapshot recorded: the database was never touched
	path="$(jq -r .path <<<"$rec")"; sha="$(jq -r .sha256 <<<"$rec")"
	owner="$(jq -r .owner <<<"$rec")"; mode="$(jq -r .mode <<<"$rec")"; ctx="$(jq -r '.context // empty' <<<"$rec")"
	[[ "$path" == "${WEBMAIL_SNAPSHOT_ROOT}/${WEBMAIL_RUN}/roundcube.db" && "$owner" =~ ^[0-9]+:[0-9]+$ && "$mode" =~ ^[0-7]{3,4}$ ]] || return 1
	local done_before=0
	if [[ -f "$WEBMAIL_DB" && ! -L "$WEBMAIL_DB" ]] && [[ "$(webmail_sha256 "$WEBMAIL_DB")" == "$sha" ]]; then
		:   # already restored; still checked below
	elif [[ ! -e "$path" ]] && jq -e '[.webmail.rollback.steps[]? | select(.step == "restore-db" and .ok)] | length > 0' \
			"$(journal_path "$WEBMAIL_RUN")" >/dev/null 2>&1; then
		# An earlier attempt restored it (the rename consumed the snapshot) and
		# webmail has written to it since — a health probe's session row, or users
		# after a rollback whose last journal write was lost. That is done; do not
		# fail the retry over a hash only the first restore could match.
		done_before=1
	else
		[[ -f "$path" && ! -L "$path" && "$(webmail_sha256 "$path")" == "$sha" ]] \
			|| { engine_log "webmail: the database snapshot ${path} is missing or does not match its recorded hash"; return 1; }
		webmail_quiet_check || return 1
		rej="${WEBMAIL_SNAPSHOT_ROOT}/${WEBMAIL_RUN}/rejected"
		[[ -d "$rej" ]] || mkdir -m 0700 -- "$rej" || return 1
		for sfx in "" -journal -wal -shm; do
			if [[ -e "${WEBMAIL_DB}${sfx}" || -L "${WEBMAIL_DB}${sfx}" ]]; then
				mv -fT -- "${WEBMAIL_DB}${sfx}" "${rej}/roundcube.db${sfx}" || return 1
			fi
		done
		# Owner and mode go on while the file is still in the root-only snapshot
		# dir: once it is renamed into the webmail uid's directory, a chmod by
		# path would follow whatever that uid put there (A-3).
		chown -h -- "$owner" "$path" && chmod -- "$mode" "$path" || return 1
		mv -T -- "$path" "$WEBMAIL_DB" || return 1
	fi
	if [[ -n "$ctx" ]] && webmail_selinux_on; then
		webmail_nolock restorecon -F -- "$WEBMAIL_DB" || return 1
	fi
	[[ -f "$WEBMAIL_DB" && ! -L "$WEBMAIL_DB" ]] \
		&& [[ "$(stat -c '%h %u:%g %a' -- "$WEBMAIL_DB")" == "1 ${owner} ${mode#0}" ]] \
		&& { (( done_before )) || [[ "$(webmail_sha256 "$WEBMAIL_DB")" == "$sha" ]]; } \
		&& [[ "$(webmail_nolock sqlite3 "file:${WEBMAIL_DB}?immutable=1" 'PRAGMA integrity_check;' 2>/dev/null)" == ok ]]
}

# webmail_restore_config — swap the snapshot back in: live → .rejected, then
# snapshot → live, each under its own intent, so a kill between the two renames
# is finished from what is on disk (A-6). Idempotent: a live tree that hashes
# as recorded is a restore that happened (or a config never touched).
webmail_restore_config() {
	local rec snap h rej="${WEBMAIL_CONFIG_DIR%/*}/.shcp-roundcube.wm6-${WEBMAIL_RUN}.rejected"
	rec="$(jq -c '.webmail.snapshot.config // empty' "$(journal_path "$WEBMAIL_RUN")")" || return 1
	[[ -n "$rec" ]] || return 0
	snap="$(jq -r .path <<<"$rec")"; h="$(jq -r .tree_sha256 <<<"$rec")"
	[[ "$snap" == "${WEBMAIL_CONFIG_DIR%/*}/.shcp-roundcube.wm6-${WEBMAIL_RUN}" ]] || return 1
	if [[ -d "$WEBMAIL_CONFIG_DIR" && ! -L "$WEBMAIL_CONFIG_DIR" ]] && [[ "$(webmail_tree_hash "$WEBMAIL_CONFIG_DIR")" == "$h" ]]; then
		webmail_relabel "$WEBMAIL_CONFIG_DIR"   # a retry after a failed relabel must still relabel
		return
	fi
	# An earlier attempt of this rollback restored it and a later step (the
	# previous target's prepare-config) has rendered into it since: the snapshot
	# was consumed by that restore's rename, and the tree is the previous
	# target's. That is done, not missing.
	if [[ ! -e "$snap" && -d "$WEBMAIL_CONFIG_DIR" && ! -L "$WEBMAIL_CONFIG_DIR" ]] \
			&& jq -e '[.webmail.rollback.steps[]? | select(.step == "restore-config" and .ok)] | length > 0' \
				"$(journal_path "$WEBMAIL_RUN")" >/dev/null 2>&1; then
		return 0
	fi
	[[ -d "$snap" && ! -L "$snap" && "$(webmail_tree_hash "$snap")" == "$h" ]] \
		|| { engine_log "webmail: the config snapshot ${snap} is missing or does not match its recorded hash"; return 1; }
	if [[ -e "$WEBMAIL_CONFIG_DIR" || -L "$WEBMAIL_CONFIG_DIR" ]]; then
		if [[ -e "$rej" ]]; then
			# A previous attempt's reject: re-validate before building an rm -rf path.
			[[ "$rej" == "${WEBMAIL_CONFIG_DIR%/*}/.shcp-roundcube.wm6-"*".rejected" ]] || return 1
			rm -rf -- "$rej" || return 1
		fi
		mv -T -- "$WEBMAIL_CONFIG_DIR" "$rej" || return 1
	fi
	webmail_crash_point restore-config between
	mv -T -- "$snap" "$WEBMAIL_CONFIG_DIR" || return 1
	webmail_relabel "$WEBMAIL_CONFIG_DIR" || return 1
	[[ "$(webmail_tree_hash "$WEBMAIL_CONFIG_DIR")" == "$h" ]]
}

webmail_relink_previous() {
	local now
	now="$(readlink -- "$WEBMAIL_LINK" 2>/dev/null)" || now=""
	[[ "$now" == "$WEBMAIL_PREV_ROOT" ]] && return 0
	[[ "$now" == "$WEBMAIL_RELEASE_DIR" ]] || { engine_log "webmail: ${WEBMAIL_LINK} points at ${now:-nothing}, neither the new release nor the previous target"; return 1; }
	webmail_link_switch "$WEBMAIL_PREV_ROOT"
}

# webmail_rb_step <step> <cmd…> — one rollback step, journaled before and after.
webmail_rb_step() {
	local step="$1" ok=true
	shift
	webmail_intent "rollback:${step}"
	webmail_crash_point "rollback:${step}" before
	"$@" || ok=false
	journal_update "$WEBMAIL_RUN" \
		'.webmail.intent.done = true | .webmail.rollback.steps += [{step: $s, ok: ($ok == "true"), at: $now}]' \
		--arg s "$step" --arg ok "$ok" --arg now "$(now_utc)"
	webmail_crash_point "rollback:${step}" after
	[[ "$ok" == true ]] || { engine_log "webmail: CRITICAL: rollback step ${step} failed"; return 1; }
}

# webmail_rollback — every piece back, in this order, before ingress reopens:
# re-quiesce (a resumed pool is parked again), restore the database and the
# config, re-render for the previous target (legacy included), put the link
# back, resume the pool, prove health, open ingress. The first failure stops it
# with maintenance held. Returns 0 rolled_back, 1 rolled_back_partial.
webmail_rollback() {
	journal_update "$WEBMAIL_RUN" '.webmail.phase = "rolling_back" | .webmail.rollback.steps //= []'
	webmail_rb_step quiesce webmail_control quiesce \
		&& webmail_rb_step restore-db webmail_restore_db \
		&& webmail_rb_step restore-config webmail_restore_config \
		&& webmail_rb_step prepare-config webmail_control prepare-config "$WEBMAIL_PREV_TARGET" \
		&& webmail_rb_step relink webmail_relink_previous \
		&& webmail_rb_step resume-pool webmail_control resume-pool \
		&& webmail_rb_step health webmail_control health "$WEBMAIL_PREV_TARGET" \
		&& webmail_rb_step open-ingress webmail_control open-ingress
}

# --- commit ---

# webmail_commit_complete — the forward half past `committing`: the mode record,
# then ingress. Both are idempotent, so recovery runs exactly this.
webmail_commit_complete() {
	webmail_intent mode
	webmail_crash_point mode before
	webmail_mode_write || { WEBMAIL_FAILED_STEP=mode; return 1; }
	webmail_crash_point mode after
	webmail_intent open-ingress
	webmail_crash_point open-ingress before
	# open-ingress refuses once the marker is gone, so a kill after it opened
	# ingress but before `committed` was journaled would retry it forever. Read
	# the state back instead: open ingress over a loaded pool is that step done.
	if webmail_ingress_open; then
		engine_log "webmail: ingress is already open over a loaded pool; the commit's last step had landed"
	else
		webmail_control open-ingress || { WEBMAIL_FAILED_STEP=open-ingress; return 1; }
	fi
	webmail_crash_point open-ingress after
	webmail_close committed healthy ""
	# A committed release is what any request asked for, and it did not fail.
	rm -f -- "$WEBMAIL_MIGRATE_REQUEST" "$WEBMAIL_LAST_FAILURE"
	webmail_status_write committed
}

webmail_commit() {
	journal_update_durable "$WEBMAIL_RUN" '.webmail.phase = "committing"'
	webmail_status_write committing
	webmail_commit_complete
}

# --- outcome ---

# webmail_emit <result> [reason] — the contract's last stdout line (§4a).
webmail_emit() {
	jq -nc --arg r "$1" --arg id "$WEBMAIL_RELEASE_ID" --arg run "$WEBMAIL_RUN" --arg why "${2:-}" \
		'{result: $r, release_id: (if $id == "" then null else $id end),
		  run_id: (if $run == "" then null else $run end), reason: (if $why == "" then null else $why end)}'
	WEBMAIL_EMITTED=1
}

webmail_last_failure_write() {   # <result>
	jq -nc --arg id "$WEBMAIL_RELEASE_ID" --arg run "$WEBMAIL_RUN" --arg r "$1" --arg at "$(now_utc)" \
		'{format: 1, release_id: $id, run_id: $run, result: $r, at: $at}' \
		| atomic_write_durable "$WEBMAIL_LAST_FAILURE" \
		|| engine_log "webmail: could not record ${WEBMAIL_LAST_FAILURE}; a trigger may retry this release"
}

# webmail_fail_and_roll_back <reason> — the forward path failed before
# `committing`: roll back once and report. Exits 1 (rolled_back) or 3.
webmail_fail_and_roll_back() {
	local reason="$1" failed=1
	# A TERM (shutdown, an operator stop) is an interruption, not a failure of
	# the release: roll back, but do not back the release off.
	[[ "$WEBMAIL_FAILED_STEP" == terminated* ]] && failed=0
	engine_log "webmail: run ${WEBMAIL_RUN} failed at ${WEBMAIL_FAILED_STEP:-unknown}: ${reason}; rolling back"
	journal_update "$WEBMAIL_RUN" '.webmail.reason = $r' --arg r "$reason"
	# Quiesce refused before it changed anything (no marker, the pool still
	# loaded): there is nothing to roll back, and the rollback's own quiesce
	# would refuse the same way and report webmail "held" when it is serving.
	if [[ "$WEBMAIL_FAILED_STEP" == quiesce ]] && webmail_ingress_open; then
		webmail_close rolled_back rolled_back "${reason}; nothing had been changed"
		webmail_last_failure_write rolled_back
		webmail_emit rolled_back "${reason}; nothing had been changed"
		exit 1
	fi
	if webmail_rollback; then
		webmail_close rolled_back rolled_back "$reason"
		(( failed )) && webmail_last_failure_write rolled_back
		webmail_emit rolled_back "$reason"
		exit 1
	fi
	webmail_close rolled_back_partial rolled_back_partial "${reason}; the rollback did not complete (see .webmail.rollback.steps)"
	webmail_last_failure_write rolled_back_partial
	engine_log "webmail: CRITICAL: run ${WEBMAIL_RUN} could not be rolled back; webmail stays in maintenance. Fix the failed step, then: shcp-update webmail recover --retry"
	webmail_emit rolled_back_partial "${reason}; rollback incomplete, webmail stays in maintenance"
	exit 3
}

webmail_commit_incomplete() {
	journal_update "$WEBMAIL_RUN" '.webmail.phase = "commit_incomplete" | .webmail.result = "commit_incomplete" | .webmail.reason = $r' \
		--arg r "committed, but ${WEBMAIL_FAILED_STEP} failed; retried forward on the next tick"
	engine_log "webmail: run ${WEBMAIL_RUN} is committed but ${WEBMAIL_FAILED_STEP} failed; webmail stays in maintenance until it is retried"
	webmail_emit commit_incomplete "committed, but ${WEBMAIL_FAILED_STEP} failed; webmail stays in maintenance until a retry opens it"
	exit 3
}

# webmail_refused — exit 2: nothing was changed and no journal was written for
# this run.
webmail_refused() {
	webmail_stage_discard || true
	webmail_emit refused "${WEBMAIL_REFUSAL:-refused}"
	exit 2
}

# The EXIT trap. A `die` or an unexpected failure lands here without an
# outcome: before the journal it is a refusal; after it the run is left
# journaled for recovery, with maintenance held (C-3, I-11).
webmail_on_exit() {
	local rc=$?
	trap - EXIT
	if (( WEBMAIL_EMITTED )); then
		webmail_after_outcome "$rc" || true
		shcp_release_lifecycle_release || true
		exit "$rc"
	fi
	shcp_release_lifecycle_release || true
	if [[ -z "$WEBMAIL_RUN" ]]; then
		webmail_stage_discard 2>/dev/null || true
		webmail_emit refused "${WEBMAIL_REFUSAL:-the engine stopped before the transaction began (exit ${rc})}"
		exit 2
	fi
	engine_log "webmail: run ${WEBMAIL_RUN} stopped mid-transaction (exit ${rc}); webmail stays in maintenance until it is recovered"
	webmail_emit interrupted "the engine stopped mid-transaction (exit ${rc}); webmail stays in maintenance until it is recovered"
	exit 3
}

# --- recovery ---

# webmail_load_run <run-id> — the transaction's identity, read back from a
# journal the run-tree gate has proven root's (SC-636), every field re-validated
# before it builds a path.
webmail_load_run() {
	local run="$1" jf
	jf="$(journal_path "$run")"
	run_tree_trusted "$run" || { webmail_refuse "run ${run} failed ownership validation (${RUN_TREE_UNTRUSTED:-})"; return 1; }
	WEBMAIL_RUN="$run"
	CURRENT_RUN_ID="$run"
	WEBMAIL_RELEASE_ID="$(jq -r '.webmail.release_id // empty' "$jf")"
	WEBMAIL_RELEASE_DIR="$(jq -r '.webmail.release_dir // empty' "$jf")"
	WEBMAIL_PREV_KIND="$(jq -r '.webmail.previous.kind // empty' "$jf")"
	WEBMAIL_PREV_TARGET="$(jq -r '.webmail.previous.target // empty' "$jf")"
	WEBMAIL_PREV_ROOT="$(jq -r '.webmail.previous.root // empty' "$jf")"
	WEBMAIL_PREV_MODE="$(jq -r '.webmail.previous.mode // empty' "$jf")"
	webmail_release_id_valid "$WEBMAIL_RELEASE_ID" \
		&& [[ "$WEBMAIL_RELEASE_DIR" == "${WEBMAIL_RELEASE_ROOT}/${WEBMAIL_RELEASE_ID}" ]] \
		|| { webmail_refuse "run ${run} records no valid release"; return 1; }
	case "$WEBMAIL_PREV_KIND" in
		release) webmail_release_id_valid "$WEBMAIL_PREV_TARGET" \
				&& [[ "$WEBMAIL_PREV_ROOT" == "${WEBMAIL_RELEASE_ROOT}/${WEBMAIL_PREV_TARGET}" ]] ;;
		legacy) [[ "$WEBMAIL_PREV_TARGET" == "legacy:${WEBMAIL_PREV_ROOT}" && "${WEBMAIL_PREV_ROOT%/*}" == "$WEBMAIL_LEGACY_PARENT" \
				&& "${WEBMAIL_PREV_ROOT##*/}" =~ ^roundcubemail-[0-9][0-9.]*$ ]] ;;
		*) false ;;
	esac || { webmail_refuse "run ${run} records no valid previous target"; return 1; }
}

# webmail_recover_pending — finish an interrupted run, if there is one: forward
# when it reached `committing` (F1), otherwise one rollback. Returns 0 when
# nothing is left unfinished, 1 when a run is still unfinished or could not be
# rolled back (the caller refuses a new run over it). Never exits on its own.
webmail_recover_pending() {
	local run phase newest
	run="$(latest_unfinished_webmail_run)"
	if [[ -z "$run" ]]; then
		# A rollback that failed is terminal but not resolved: its marker holds
		# webmail in maintenance, and only `recover --retry` may act on it.
		newest="$(latest_webmail_run)"
		if [[ -n "$newest" && "$(jq -r .status "$(journal_path "$newest")" 2>/dev/null)" == rolled_back_partial ]]; then
			WEBMAIL_PARTIAL_RUN="$newest"
			webmail_refuse "run ${newest} could not be rolled back and webmail is held in maintenance; fix the failed step, then: shcp-update webmail recover --retry"
			return 1
		fi
		return 0
	fi
	webmail_lifecycle || return 1
	# A run killed before its first intent changed nothing; an older engine's
	# journal may even lack its .webmail record. Close it, never roll it back.
	if jq -e '.webmail == null or .webmail.intent == null' "$(journal_path "$run")" >/dev/null 2>&1; then
		run_tree_trusted "$run" || { webmail_refuse "run ${run} failed ownership validation (${RUN_TREE_UNTRUSTED:-})"; return 1; }
		WEBMAIL_RUN="$run"; CURRENT_RUN_ID="$run"
		engine_log "webmail: run ${run} stopped before its first effect; closing it, nothing to undo"
		journal_update "$run" '.webmail = ((.webmail // {}) + {phase: "abandoned", result: "abandoned",
			reason: "stopped before its first effect; nothing was changed"})
			| .status = "failed" | .error = "stopped before its first effect" | .finished_at = $now' --arg now "$(now_utc)"
		panel_import_run "$run"
		webmail_forget_run
		webmail_recover_pending
		return
	fi
	webmail_load_run "$run" || return 1
	phase="$(jq -r '.webmail.phase // empty' "$(journal_path "$run")")"
	engine_log "webmail: recovering run ${run} (phase ${phase:-none})"
	journal_update "$run" '.webmail.recovered_at = $now' --arg now "$(now_utc)"
	# A rollback that already reopened ingress, or reached a terminal phase,
	# lost only its closing write: finish it. Running it again would re-quiesce
	# a healthy webmail.
	if [[ "$phase" == rolled_back ]] || { [[ "$phase" == rolling_back ]] \
			&& jq -e '(.webmail.rollback.steps | last) == null | not' "$(journal_path "$run")" >/dev/null 2>&1 \
			&& jq -e '(.webmail.rollback.steps | last) as $l | $l.step == "open-ingress" and $l.ok' "$(journal_path "$run")" >/dev/null 2>&1; }; then
		webmail_close rolled_back rolled_back "interrupted after its rollback completed; closed by recovery"
		WEBMAIL_RECOVERED="rolled_back"
		return 0
	fi
	if [[ "$phase" == rolled_back_partial ]]; then
		webmail_close rolled_back_partial rolled_back_partial "the rollback did not complete; closed by recovery"
		WEBMAIL_PARTIAL_RUN="$run"
		webmail_refuse "run ${run} could not be rolled back and webmail is held in maintenance; fix the failed step, then: shcp-update webmail recover --retry"
		return 1
	fi
	case "$phase" in
		committing|committed|commit_incomplete)
			if webmail_commit_complete; then
				WEBMAIL_RECOVERED="committed"
				return 0
			fi
			journal_update "$run" '.webmail.phase = "commit_incomplete" | .webmail.result = "commit_incomplete"'
			WEBMAIL_RECOVERED="commit_incomplete"
			webmail_refuse "run ${run} is committed but ${WEBMAIL_FAILED_STEP} still fails; webmail stays in maintenance"
			return 1 ;;
	esac
	if webmail_rollback; then
		webmail_close rolled_back rolled_back "interrupted at phase ${phase:-none}; rolled back by recovery"
		# A kill cannot be told from a crash loop, so a recovered interruption backs
		# the release off like a failure: an operator --retry is the way past it.
		webmail_last_failure_write rolled_back
		WEBMAIL_RECOVERED="rolled_back"
		return 0
	fi
	webmail_close rolled_back_partial rolled_back_partial "interrupted at phase ${phase:-none}; the recovery rollback did not complete"
	webmail_last_failure_write rolled_back_partial
	engine_log "webmail: CRITICAL: run ${run} could not be rolled back; webmail stays in maintenance. Fix the failed step, then: shcp-update webmail recover --retry"
	WEBMAIL_RECOVERED="rolled_back_partial"
	webmail_refuse "run ${run} could not be rolled back; webmail stays in maintenance until: shcp-update webmail recover --retry"
	return 1
}
WEBMAIL_RECOVERED=""
WEBMAIL_PARTIAL_RUN=""

# latest_webmail_run — the newest webmail journal, finished or not.
latest_webmail_run() {
	local d
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" && [[ -f "$(journal_path "$d")" ]] && journal_is_webmail "$d" || continue
		printf '%s\n' "$d"
		return 0
	done
	return 0
}

# webmail_retry_reopen — `recover --retry`: re-open the newest webmail run, if
# it is rolled_back_partial, for one more rollback attempt. Its history is
# appended to, never reset.
webmail_retry_reopen() {
	local run
	run="$(latest_webmail_run)"
	[[ -n "$run" && "$(jq -r .status "$(journal_path "$run")")" == rolled_back_partial ]] || return 0
	webmail_load_run "$run" || return 1
	engine_log "webmail: --retry: re-opening run ${run} for another rollback attempt"
	journal_update "$run" '.status = "running" | .finished_at = null | .webmail.phase = "rolling_back"
		| .webmail.retries = ((.webmail.retries // 0) + 1)'
}

# --- the verbs ---

# webmail_forget_run — clear one run's identity, after recovering it and
# before the next run starts.
webmail_forget_run() {
	WEBMAIL_RUN=""
	CURRENT_RUN_ID=""
	WEBMAIL_RELEASE_ID=""
	WEBMAIL_RELEASE_DIR=""
	WEBMAIL_MANIFEST_SHA256=""
	WEBMAIL_PREV_KIND=""
	WEBMAIL_PREV_TARGET=""
	WEBMAIL_PREV_ROOT=""
	WEBMAIL_PREV_MODE=""
	WEBMAIL_FAILED_STEP=""
	WEBMAIL_REFUSAL=""
}

# webmail_begin — root, the panel product, the run lock, the activation lock.
# Each refusal returns 1 with WEBMAIL_REFUSAL set; the busy ones say so.
# The release lifecycle lock is not taken here: an hourly tick with nothing
# to do, and the wait for the activation lock, must never stall operator
# console commands (shcp-master#3262). webmail_lifecycle takes it for work.
WEBMAIL_BUSY=0
webmail_begin() {
	(( EUID == SHCP_UPDATE_EXPECT_UID )) || { webmail_refuse "webmail verbs must run as root"; return 1; }
	osf_detect_family
	resolve_product || { webmail_refuse "product resolution failed: ${PRODUCT_RESOLVE_ERROR}"; return 1; }
	[[ "$PRODUCT" == shcp ]] || { webmail_refuse "webmail is part of the shcp panel product, not ${PRODUCT}"; return 1; }
	if ! acquire_run_lock; then
		[[ -n "$LOCK_BUSY_REASON" ]] && WEBMAIL_BUSY=1
		webmail_refuse "${LOCK_BUSY_REASON:-could not take the run lock ${SHCP_UPDATE_LOCK} (SC-500)}"
		return 1
	fi
	# A running install holds the lifecycle lock and then waits for the
	# activation lock; holding the activation lock while waiting for the
	# lifecycle lock would make it wait for us. Step aside instead.
	if shcp_release_lifecycle_held; then
		WEBMAIL_BUSY=1
		webmail_refuse "an install or update holds the release lifecycle lock ${SHCP_RELEASE_LIFECYCLE_LOCK:-/run/shcp/release-lifecycle.lock}"
		return 1
	fi
	local rc=0
	webmail_activation_lock || rc=$?
	case "$rc" in
		0) return 0 ;;
		2) WEBMAIL_BUSY=1; webmail_refuse "the webmail activation lock ${WEBMAIL_ACTIVATION_LOCK} is held by another process (installer converge or reconcile)" ;;
		*) webmail_refuse "${WEBMAIL_ACTIVATION_LOCK} or its directory is not a root-only lock (refused, never repaired)" ;;
	esac
	return 1
}

# webmail_lifecycle — the release lifecycle lock (SC-355), for a run that has a
# transaction or a recovery to carry out: they rewrite /etc/shcp-roundcube,
# which the panel's console also writes. Held from here to the verb's exit.
# Returns 1 with WEBMAIL_REFUSAL set; WEBMAIL_BUSY says a holder was waited on.
webmail_lifecycle() {
	[[ -z "$SHCP_RELEASE_LIFECYCLE_FD" ]] || return 0
	LOCK_BUSY_REASON=""
	shcp_release_lifecycle_acquire && return 0
	[[ -n "$LOCK_BUSY_REASON" ]] && WEBMAIL_BUSY=1
	webmail_refuse "${LOCK_BUSY_REASON:-the release lifecycle lock ${SHCP_RELEASE_LIFECYCLE_LOCK:-/run/shcp/release-lifecycle.lock} could not be prepared or taken}"
	return 1
}

# webmail_lifecycle_refused — a tick that found the lifecycle lock busy changed
# nothing: it exits idle and leaves a migrate request for the next tick.
WEBMAIL_TICK=0
webmail_lifecycle_refused() {
	if (( WEBMAIL_BUSY && WEBMAIL_TICK )); then
		WEBMAIL_TRIGGER_KIND=""
		webmail_emit idle "busy: ${WEBMAIL_REFUSAL}"
		exit 0
	fi
	webmail_refused
}

webmail_journal_init() {   # <run-id> <trigger>
	local run="$1" trigger="$2" top=auto
	[[ "$trigger" == operator ]] && top=manual
	# The .webmail record is part of the journal's FIRST document: two renames
	# would leave, after a kill between them, a webmail journal nothing can read.
	local record
	record="$(jq -nc --arg t "$trigger" --arg id "$WEBMAIL_RELEASE_ID" --arg dir "$WEBMAIL_RELEASE_DIR" \
		--arg msha "$WEBMAIL_MANIFEST_SHA256" --arg pk "$WEBMAIL_PREV_KIND" --arg pt "$WEBMAIL_PREV_TARGET" \
		--arg pr "$WEBMAIL_PREV_ROOT" --arg pm "$WEBMAIL_PREV_MODE" \
		'{webmail: {schema: 1, trigger: $t, phase: "admitted", intent: null,
			release_id: $id, release_dir: $dir, manifest_sha256: $msha,
			previous: {kind: $pk, target: $pt, root: $pr, mode: $pm},
			snapshot: {}, rollback: {steps: []}, result: null, reason: null}}')" || die "webmail: could not build the journal record"
	journal_init "$run" "$top" webmail null false \
		"$(jq -nc --arg t "$trigger" --arg now "$(now_utc)" '{kind: "webmail", admitted_at: $now, trigger: $t}')" \
		"$record"
	WEBMAIL_RUN="$run"
	CURRENT_RUN_ID="$run"
}

# webmail_run_transaction <trigger> <retry> <allow-downgrade> — preflight, then
# the transaction. Exits with the outcome; never returns.
webmail_run_transaction() {
	local trigger="$1" retry="$2" downgrade="$3" run
	webmail_lifecycle || webmail_lifecycle_refused
	run="$(new_run_id)"
	webmail_preflight "$run" "$retry" "$downgrade" || webmail_refused
	if (( WEBMAIL_TERM )); then
		webmail_refuse "terminated before the transaction began"
		webmail_refused
	fi
	webmail_journal_init "$run" "$trigger"
	# From here a refusal is impossible: every failure is a rollback.
	if ! webmail_step quiesce quiesced webmail_control quiesce \
		|| ! webmail_step snapshot-db "" webmail_snapshot_db \
		|| ! webmail_step snapshot-config snapshotted webmail_snapshot_config \
		|| ! webmail_step prepare-config configured webmail_control prepare-config "$WEBMAIL_RELEASE_ID" \
		|| ! webmail_step migrate-schema migrated webmail_control migrate-schema "$WEBMAIL_RELEASE_ID" \
		|| ! webmail_step switch switched webmail_link_switch "$WEBMAIL_RELEASE_DIR" \
		|| ! webmail_step resume-pool resumed webmail_control resume-pool \
		|| ! webmail_step health verified webmail_control health "$WEBMAIL_RELEASE_ID"; then
		if [[ "$WEBMAIL_FAILED_STEP" == terminated* ]]; then
			webmail_fail_and_roll_back "$WEBMAIL_FAILED_STEP"
		fi
		webmail_fail_and_roll_back "${WEBMAIL_FAILED_STEP} failed"
	fi
	# Past `committing` the run only goes forward (F1); a TERM now is ignored.
	webmail_commit || webmail_commit_incomplete
	webmail_emit committed ""
	exit 0
}

webmail_activate() {
	local retry=0 downgrade=0
	trap webmail_on_exit EXIT
	trap 'WEBMAIL_TERM=1' TERM
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--retry) retry=1; shift ;;
			--allow-downgrade) downgrade=1; shift ;;
			--json) shift ;;
			*) webmail_refuse "webmail activate: unknown argument: $1"; webmail_refused ;;
		esac
	done
	webmail_begin || webmail_refused
	webmail_recover_pending || webmail_refused
	# Recovery finished an interrupted activation of this same release: that IS
	# the outcome asked for, not a refusal because it is now live.
	if [[ "$WEBMAIL_RECOVERED" == committed && "$(webmail_installed_release 2>/dev/null)" == "$WEBMAIL_RELEASE_ID" ]]; then
		webmail_emit committed "recovered run ${WEBMAIL_RUN}"
		exit 0
	fi
	webmail_forget_run
	webmail_run_transaction "${WEBMAIL_TRIGGER:-operator}" "$retry" "$downgrade"
}

# `webmail recover [--retry]`: finish an interrupted run, or (with --retry)
# attempt once more to roll back a run whose rollback failed. Exit 0 when
# nothing is left unfinished, 3 when something still is, 2 when refused.
webmail_recover() {
	local retry=0
	trap webmail_on_exit EXIT
	trap 'WEBMAIL_TERM=1' TERM
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--retry) retry=1; shift ;;
			--json) shift ;;
			*) webmail_refuse "webmail recover: unknown argument: $1"; webmail_refused ;;
		esac
	done
	webmail_begin || webmail_refused
	if (( retry )); then
		webmail_retry_reopen || webmail_refused
	fi
	if webmail_recover_pending; then
		webmail_emit "${WEBMAIL_RECOVERED:-idle}" "${WEBMAIL_RECOVERED:+recovered run ${WEBMAIL_RUN}}"
		exit 0
	fi
	if [[ -n "$WEBMAIL_RECOVERED" ]]; then
		webmail_emit "$WEBMAIL_RECOVERED" "$WEBMAIL_REFUSAL"
		exit 3
	fi
	if [[ -n "$WEBMAIL_PARTIAL_RUN" ]]; then
		WEBMAIL_RUN="$WEBMAIL_PARTIAL_RUN"
		webmail_emit rolled_back_partial "$WEBMAIL_REFUSAL"
		exit 3
	fi
	webmail_refused
}

# --- the tick and retention (WM6-6 / WM6-7) ---

WEBMAIL_TRIGGER_KIND=""
WEBMAIL_RETIRED_DIRS=("${WEBMAIL_STATE_DIR}/retired" "${WM_ROOT}/var/www/.shcp-webmail-retired")
WEBMAIL_RETIRED_SEEN="${WEBMAIL_STATE_DIR}/retired-seen.json"
WEBMAIL_RETIRED_KEEP_DAYS="${SHCP_UPDATE_WEBMAIL_RETIRED_KEEP_DAYS:-30}"
WEBMAIL_STAGE_MAX_AGE_MIN=60
WEBMAIL_RUN_KEEP=5

# webmail_request_valid — the installer's migrate request, as untrusted input
# (SC-868): exactly `request 1\n`, a root 0600
# single-link file, in a directory only root can write. Anything else is logged
# and left alone, never acted on.
webmail_request_valid() {
	[[ -e "$WEBMAIL_MIGRATE_REQUEST" || -L "$WEBMAIL_MIGRATE_REQUEST" ]] || return 1
	if webmail_dir_chain_trusted "$WEBMAIL_STATE_DIR" "${WM_ROOT:-/}" \
			&& webmail_file_trusted "$WEBMAIL_MIGRATE_REQUEST" \
			&& [[ "$(stat -c %a -- "$WEBMAIL_MIGRATE_REQUEST")" == 600 ]] \
			&& cmp -s -- "$WEBMAIL_MIGRATE_REQUEST" <(printf 'request 1\n'); then
		return 0
	fi
	engine_log "webmail: ignoring ${WEBMAIL_MIGRATE_REQUEST}: not exactly 'request 1', or not a root 0600 single-link file in a root-only directory (SC-868)"
	return 1
}

# webmail_installed_release — the release id the installed package's manifest
# names. Read without verification: it only decides whether to TRY a
# transaction, whose preflight verifies everything it acts on.
webmail_installed_release() {
	local id
	webmail_file_trusted "$WEBMAIL_MANIFEST" || return 1
	id="$(jq -r '.release_id // empty' "$WEBMAIL_MANIFEST" 2>/dev/null)" || return 1
	webmail_release_id_valid "$id" || return 1
	printf '%s' "$id"
}

# webmail_live_release — the release id /var/www/webmail serves, or nothing
# when it serves a legacy tree or anything else.
webmail_live_release() {
	local t
	[[ -L "$WEBMAIL_LINK" ]] || return 1
	t="$(readlink -- "$WEBMAIL_LINK")" || return 1
	[[ "${t%/*}" == "$WEBMAIL_RELEASE_ROOT" ]] && webmail_release_id_valid "${t##*/}" || return 1
	printf '%s' "${t##*/}"
}

# webmail_upgrade_due — a package-mode box serving a release, with a newer
# release installed (an apt/dnf upgrade of shcp-webmail). Only a link that
# parses as a release counts (A-8): a mode record over a legacy link is drift
# for `check` to report, never a reason to act.
webmail_upgrade_due() {
	local live new
	webmail_mode_package || return 1
	live="$(webmail_live_release)" || return 1
	new="$(webmail_installed_release)" || return 1
	webmail_release_older "$live" "$new"
}

# webmail_after_outcome <rc> — on the way out of every webmail verb that ran
# with the locks: consume a migrate request a run acted on (0, 1, 3; a refusal
# changed nothing and is retried next tick), then prune.
webmail_after_outcome() {
	local rc="$1"
	if [[ "$WEBMAIL_TRIGGER_KIND" == converge-request ]]; then
		case "$rc" in 0|1|3) rm -f -- "$WEBMAIL_MIGRATE_REQUEST" ;; esac
	fi
	[[ -n "$WEBMAIL_LOCK_FD" ]] && webmail_retention
	return 0
}

# `webmail tick` — what shcp-update-webmail.timer runs: recover, then act on a
# request or an upgrade, then prune. A busy lock is a quiet exit 0 (the next
# tick retries); so is a release that is backed off.
webmail_tick() {
	trap webmail_on_exit EXIT
	trap 'WEBMAIL_TERM=1' TERM
	[[ $# -eq 0 ]] || { webmail_refuse "webmail tick takes no arguments"; webmail_refused; }
	WEBMAIL_TICK=1
	if ! webmail_begin; then
		if (( WEBMAIL_BUSY )); then
			webmail_emit idle "busy: ${WEBMAIL_REFUSAL}"
			exit 0
		fi
		webmail_refused
	fi
	if ! webmail_recover_pending; then
		if [[ -n "$WEBMAIL_PARTIAL_RUN" ]]; then
			WEBMAIL_RUN="$WEBMAIL_PARTIAL_RUN"
			webmail_emit rolled_back_partial "$WEBMAIL_REFUSAL"
			exit 3
		fi
		if [[ -n "$WEBMAIL_RECOVERED" ]]; then
			webmail_emit "$WEBMAIL_RECOVERED" "$WEBMAIL_REFUSAL"
			exit 3
		fi
		webmail_lifecycle_refused
	fi
	local recovered="$WEBMAIL_RECOVERED" recovered_run="$WEBMAIL_RUN" recovered_id="$WEBMAIL_RELEASE_ID" id=""
	webmail_forget_run
	if webmail_request_valid && webmail_mode_package \
			&& [[ -n "$(webmail_live_release 2>/dev/null)" && "$(webmail_live_release)" == "$(webmail_installed_release 2>/dev/null)" ]]; then
		engine_log "webmail: the migrate request is already satisfied (the installed release is live); consuming it"
		rm -f -- "$WEBMAIL_MIGRATE_REQUEST"
	fi
	if webmail_request_valid; then
		WEBMAIL_TRIGGER_KIND=converge-request
	elif webmail_upgrade_due; then
		WEBMAIL_TRIGGER_KIND=package-upgrade
	fi
	if [[ -n "$WEBMAIL_TRIGGER_KIND" ]]; then
		id="$(webmail_installed_release)" || id=""
		if [[ -n "$id" ]] && webmail_backed_off "$id"; then
			engine_log "webmail: ${id} already failed to activate (${WEBMAIL_LAST_FAILURE}); not retrying it on a ${WEBMAIL_TRIGGER_KIND} until it changes or an operator passes --retry"
			WEBMAIL_TRIGGER_KIND=""
			[[ -n "$recovered" ]] && { WEBMAIL_RUN="$recovered_run"; WEBMAIL_RELEASE_ID="$recovered_id"; }
			webmail_emit "${recovered:-idle}" "backed off: ${id} already failed${recovered:+; recovered an interrupted run first}"
			exit 0
		fi
		WEBMAIL_TRIGGER="$WEBMAIL_TRIGGER_KIND"
		webmail_run_transaction "$WEBMAIL_TRIGGER_KIND" 0 0
	fi
	[[ -n "$recovered" ]] && { WEBMAIL_RUN="$recovered_run"; WEBMAIL_RELEASE_ID="$recovered_id"; }
	webmail_emit "${recovered:-idle}" "${recovered:+recovered an interrupted run}"
	exit 0
}

# --- retention ---

# webmail_named_releases — every release id a journal still needs: the live
# one, and the release and previous target of every unfinished webmail run and
# of the newest committed one (the target its rollback-by-hand would use).
webmail_named_releases() {
	local d jf newest_committed=""
	webmail_live_release 2>/dev/null && printf '\n'
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		jf="$(journal_path "$d")"
		[[ -f "$jf" ]] && journal_is_webmail "$d" || continue
		if jq -e '.finished_at == null or .status == "rolled_back_partial"' "$jf" >/dev/null 2>&1; then
			jq -r '.webmail.release_id // empty, (.webmail.previous | select(.kind == "release") | .target)' "$jf" 2>/dev/null
		elif [[ -z "$newest_committed" ]] && jq -e '.webmail.phase == "committed"' "$jf" >/dev/null 2>&1; then
			newest_committed="$d"
			jq -r '.webmail.release_id // empty, (.webmail.previous | select(.kind == "release") | .target)' "$jf" 2>/dev/null
		fi
	done
}

webmail_unfinished_runs() {
	local d
	[[ -d "$RUNS_DIR" ]] || return 0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort); do
		run_id_valid "$d" && [[ -f "$(journal_path "$d")" ]] && journal_is_webmail "$d" || continue
		jq -e '.finished_at == null' "$(journal_path "$d")" >/dev/null 2>&1 && printf '%s\n' "$d"
	done
	return 0
}

# webmail_prune_releases — every release dir no journal names. Only a real
# directory whose name is a release id, directly under the release root, is
# ever removed (SC-238/SC-256: the bound is checked before the path is built).
webmail_prune_releases() {
	local -A named=()
	local id d jf
	# A journal that does not parse may name a release (even the one the link
	# points at mid-run); keep everything rather than guess.
	for jf in "$RUNS_DIR"/*/journal.json; do
		[[ -f "$jf" ]] || continue
		jq -e . "$jf" >/dev/null 2>&1 \
			|| { engine_log "webmail: ${jf} does not parse; not pruning any release this time"; return 0; }
	done
	while IFS= read -r id; do [[ -n "$id" ]] && named[$id]=1; done < <(webmail_named_releases)
	[[ -d "$WEBMAIL_RELEASE_ROOT" && ! -L "$WEBMAIL_RELEASE_ROOT" ]] || return 0
	for d in "$WEBMAIL_RELEASE_ROOT"/*; do
		id="${d##*/}"
		webmail_release_id_valid "$id" || continue
		[[ -d "$d" && ! -L "$d" ]] || continue
		[[ -n "${named[$id]:-}" ]] && continue
		[[ "$d" == "${WEBMAIL_RELEASE_ROOT}/${id}" ]] || continue
		engine_log "webmail: pruning release ${id} (no journal names it)"
		rm -rf -- "$d"
	done
}

# webmail_prune_leftovers — stage dirs an interrupted preflight left (older
# than an hour, so never a live preflight's), and the snapshot and config
# copies of finished runs other than the newest finished one, which is kept for
# a person to look at.
webmail_prune_leftovers() {
	local -A unfinished=()
	local d run newest_finished="" cfg_parent="${WEBMAIL_CONFIG_DIR%/*}"
	while IFS= read -r run; do [[ -n "$run" ]] && unfinished[$run]=1; done < <(webmail_unfinished_runs)
	for d in "$WEBMAIL_RELEASE_ROOT"/.stage-*; do
		[[ -d "$d" && ! -L "$d" ]] || continue
		run="${d##*/.stage-}"
		run_id_valid "$run" && [[ -z "${unfinished[$run]:-}" ]] || continue
		[[ -n "$(find "$d" -maxdepth 0 -mmin "+${WEBMAIL_STAGE_MAX_AGE_MIN}" -print 2>/dev/null)" ]] || continue
		webmail_stage_path_ok "$d" && rm -rf -- "$d"
	done
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" && [[ -f "$(journal_path "$d")" ]] && journal_is_webmail "$d" || continue
		[[ -z "${unfinished[$d]:-}" ]] || continue
		[[ -n "$newest_finished" ]] || { newest_finished="$d"; continue; }
		if [[ -d "${WEBMAIL_SNAPSHOT_ROOT}/${d}" && ! -L "${WEBMAIL_SNAPSHOT_ROOT}/${d}" ]]; then
			rm -rf -- "${WEBMAIL_SNAPSHOT_ROOT:?}/${d}"
		fi
		for run in "${cfg_parent}/.shcp-roundcube.wm6-${d}" "${cfg_parent}/.shcp-roundcube.wm6-${d}.rejected"; do
			[[ -d "$run" && ! -L "$run" ]] && rm -rf -- "$run"
		done
	done
	return 0
}

# webmail_prune_retired — legacy trees the installer retired (SC-865) are kept
# WEBMAIL_RETIRED_KEEP_DAYS from the moment this engine first saw them, then
# removed (shcp-updater#293). The clock is a root 0600 record, not ctime, which
# any chmod resets. Only `roundcubemail-<version>` real directories directly in
# a root 0700 retired dir are touched.
webmail_prune_retired() {
	local now seen dir d name first age
	now="$(date -u +%s)"
	seen="{}"
	if webmail_file_trusted "$WEBMAIL_RETIRED_SEEN" && jq -e 'type == "object"' "$WEBMAIL_RETIRED_SEEN" >/dev/null 2>&1; then
		seen="$(cat -- "$WEBMAIL_RETIRED_SEEN")"
	fi
	local next="{}"
	for dir in "${WEBMAIL_RETIRED_DIRS[@]}"; do
		[[ -d "$dir" && ! -L "$dir" && "$(stat -c '%u %a' -- "$dir")" == "${SHCP_UPDATE_EXPECT_UID} 700" ]] || continue
		for d in "$dir"/roundcubemail-*; do
			name="${d##*/}"
			[[ "$name" =~ ^roundcubemail-[0-9][0-9.]*$ && -d "$d" && ! -L "$d" ]] || continue
			first="$(jq -r --arg k "$d" '.[$k] // empty' <<<"$seen")"
			[[ "$first" =~ ^[0-9]+$ ]] || first="$now"
			age=$(( (now - first) / 86400 ))
			if (( age >= WEBMAIL_RETIRED_KEEP_DAYS )); then
				engine_log "webmail: removing retired legacy tree ${d} (retired ${age} days ago)"
				[[ "$d" == "${dir}/${name}" ]] && rm -rf -- "$d"
				continue
			fi
			next="$(jq -c --arg k "$d" --argjson v "$first" '. + {($k): $v}' <<<"$next")"
		done
	done
	[[ "$next" == "$(jq -c . <<<"$seen" 2>/dev/null)" ]] && return 0
	printf '%s\n' "$next" | atomic_write_durable "$WEBMAIL_RETIRED_SEEN" \
		|| engine_log "webmail: could not record ${WEBMAIL_RETIRED_SEEN}"
}

# webmail_run_prune — webmail journals have their own quota, outside RUN_KEEP:
# the newest WEBMAIL_RUN_KEEP, the newest committed one (the installer's retire
# reads it), and every unfinished one are kept.
webmail_run_prune() {
	local d jf seen=0 committed_kept=0
	for d in $(ls -1 "$RUNS_DIR" 2>/dev/null | sort -r); do
		run_id_valid "$d" || continue
		jf="$(journal_path "$d")"
		[[ -f "$jf" ]] && journal_is_webmail "$d" || continue
		seen=$(( seen + 1 ))
		if jq -e '.webmail.phase == "committed" and .finished_at != null' "$jf" >/dev/null 2>&1 && (( ! committed_kept )); then
			committed_kept=1
			continue
		fi
		(( seen <= WEBMAIL_RUN_KEEP )) && continue
		jq -e '.finished_at == null' "$jf" >/dev/null 2>&1 && continue
		run_id_valid "$d" && rm -rf -- "${RUNS_DIR:?}/${d}"
	done
}

webmail_retention() {
	webmail_prune_releases || true
	webmail_prune_leftovers || true
	webmail_prune_retired || true
	webmail_run_prune || true
}

cmd_webmail() {
	local sub="${1:-}"
	[[ $# -gt 0 ]] && shift
	case "$sub" in
		activate) webmail_activate "$@" ;;
		recover)  webmail_recover "$@" ;;
		tick)     webmail_tick "$@" ;;
		*) die "usage: shcp-update webmail activate [--retry] [--allow-downgrade] | recover [--retry] | tick" ;;
	esac
}

# --- capabilities (WM-6, shcp-updater#292) ------------------------------------
#
# Callers gate on what the engine IMPLEMENTS, not on its version: a version
# number says nothing to a caller that predates the feature, and a backport
# would make "version >= N" lie. The installer reads `.format == 1 and
# .webmail == 1` before it requests a webmail migration
# (rcm_updater_webmail_capable). So a key is added only in the commit that
# completes the contract it names — a release cut half way through must not
# advertise a transaction it cannot run.
# webmail: the WM-6 transaction (webmail activate|recover|tick), complete.
ENGINE_CAPABILITIES_JSON='{"format":1,"webmail":1}'

cmd_capabilities() {
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--json) shift ;;
			*) die "capabilities: unknown argument: $1" ;;
		esac
	done
	printf '%s\n' "$ENGINE_CAPABILITIES_JSON"
}

usage_short() {
	cat <<'EOF'
Usage: shcp-update [--json] <verb> [options]
Verbs: check, apply, resume, rollback, repair-series-pointer, status, self-test,
       maintenance, reconcile-config, converge-installer, fix-db-ownership,
       capabilities, webmail, ledger
Try 'shcp-update --help' for the full text.
EOF
}

usage() {
	cat <<'EOF'
shcp-update — the SHCP update apply engine

Usage: shcp-update [--json] <verb> [options]

Global options (accepted before the verb, and by every verb after it):
  --json          structured JSON output (reconcile-config and converge-installer
                  are always JSON; the flag is accepted and redundant there)
  -h, --help      this text on stdout, exit 0

Verbs:
  check [--blockers | --pending | --reboot] [--json]
        Read-only preflight of the box: blockers, advisories, pending work.
  apply [--from-request | --scope security | --reinstall [--command-uuid <uuid>]]
        [--resumed-from-self-update <run-id>] [--json]
        Run the state machine: self-update → preflight → snapshot → apt → panel
        → db → republish-vhosts → restart → health → republish-pools → finalize.
        The panel requests work through the validated request file and the
        fixed systemd unit; it can never pass arbitrary argv (SC-317).
  resume [--at-boot] [--json]
        Continue an interrupted run. Bare: an operator continuing a crashed
        (status=running) or parked (awaiting_reboot) run. --at-boot: the boot
        unit auto-continuing a run parked for a deliberate reboot — exits 0
        when there is nothing to do, never failing the unit (SC-532).
  rollback <run-id> [--yes] [--force-superseded] [--json]
        Undo a run: packages, panel release, database. Confirms when
        interactive (SC-071).
  repair-series-pointer <run-id> [--json]
        Restore only a failed series run's package-manager pointer after
        proving its prior repository leaf.
  status [--follow] [--json]
        Progress of the latest run.
  self-test [--json]
        Dependency / writability probe.
  maintenance --set | --clear | --status [--json]
        Operator escape hatch for the maintenance flag.
  reconcile-config --check
        UPD-14: report configuration drift between what the running software
        DECLARES it requires and what this box has. Read-only: no lock, no
        writes, no network. Exit 0 no findings, 3 findings, 4 could-not-check.
  reconcile-config --apply
        UPD-14 task 2: apply the additive, operator-intent-preserving fixes for
        that drift (SC-432) — enable a never-seen declared unit, mint an absent
        declared env key, install the SC-423 edge-auth block, backfill an
        absent installer-owned file. Takes the run flock, verifies the signed
        installer artifact before reading it, archives what it touches (one
        archive per apply attempt series), and re-checks convergence. Exit 0
        converged or no-op, 3 coverage gaps remain, 4 aborted (nothing changed).
  converge-installer --check | --apply [--if-pending]
        SC-692: run the paired installer's shcp-converge (always JSON).
        --if-pending applies when a panel release changed or an enable or
        disable of PostgreSQL is not carried out yet; otherwise it runs at
        most a daily --check. Exit 0
        ok/idle, 3 findings or deferred (lock contention, manifest unreachable,
        skew — a later run retries), 4 stopped/refused/failed.
  verify-initramfs [--repair] [--json]
        SC-783: audit every installed kernel's initramfs for its own modules;
        --repair regenerates a broken one (takes the run lock). Exit 1 while any
        image is still broken. Every package-capable verb runs the same audit
        on its way out (shcp-updater#271).
  fix-db-ownership
        Re-chown the panel DB and its WAL/SHM sidecars to the panel user,
        symlink/hardlink-safe (SC-663). Deliberately unlocked (it is the drift
        unit's ExecStartPost); always exits 0.
  capabilities [--json]
        What this engine implements, as one JSON object (always JSON):
        {"format":1, ...}. A capability is a key with value 1, present only
        when the whole contract behind it is implemented. No lock, any user:
        callers ask while holding locks of their own.
  webmail activate [--retry] [--allow-downgrade]
        WM-6: move webmail to the installed shcp-webmail payload as one
        journaled transaction: verify a private copy, quiesce, snapshot, render,
        migrate, switch, prove health, commit, or roll every piece back. The
        last stdout line is one JSON object {result, release_id, run_id,
        reason}. Exit 0 committed, 2 refused (nothing changed, no journal),
        1 rolled back completely, 3 rolled back partially, committed but ingress
        still closed, or interrupted (webmail stays in maintenance).
        A release whose activation already failed (last-failure.json) is
        refused until it changes or --retry is given; --allow-downgrade permits
        a release older than the live one.
  webmail recover [--retry]
        Finish an interrupted webmail run: forward past `committing`, otherwise
        roll back once. --retry attempts the rollback of a run whose rollback
        failed once more. Exit 0 nothing left unfinished, 3 something still is,
        2 refused.
  webmail tick
        What shcp-update-webmail.timer runs: recover, then activate on the
        installer's migrate request or a newer installed shcp-webmail package
        (package mode only), then prune releases and leftovers. A busy lock or a
        backed-off release is exit 0; otherwise as activate.
  ledger --apply | --check | --hold-check [--json]
        The update ledger (SC-1041). --apply: fetch this box's stream
        document and hold, verify both against the pinned release primary, the
        channel, the stream, expiry, the per-stream floor and the versions
        manifest, then apply under the run lock. --check: the same without the
        lock or the apply. --hold-check: the hourly hold fetch; acts only when
        the hold in force names entries to retire. Exit 0 committed/skipped,
        3 deferred (nothing applied; `check` names why), 1 a finding that
        needs a person, 75 lock busy.
  ledger --validate FILE [--stream STREAM] [--ledger LEDGER_JSON]
        Validate an update-ledger document against the grammar this engine
        compiles: prints `ok` (exit 0), after one `unsupported: <id>: <op>` line
        per entry whose op this engine lacks, or exactly one
        `refused: <code>: <detail>` line (exit 1). --stream refuses a document
        for another stream; --ledger refuses one that omits an id the box
        ledger records under the document's stream. --validate alone needs no
        root, no lock and no network; every other mode runs as root under the
        run lock (busy: exit 75).

Exit codes: 0 ok · 1 usage / verb error · 2 no verb given · 3 findings,
deferred or blockers · 4 could-not-check, refused or failed.
EOF
}

# --- update ledger: document validation (WI-1; SC-1041, SC-1042) ---
#
# The ledger is a world-readable, cumulative, per-stream JSON document of typed
# operations (docs/security-update-feed-plan.md §2-3 in shcp-master). This
# section is the ONE implementation of its grammar: the box runs it on every
# fetched document before any field is acted on, and the build tool runs it on
# every rendered stream in CI, so a document the build accepts is a document a
# box accepts, by construction rather than by two hand-kept copies.
#
# The validator needs no root, no lock, no network and no state; the other
# modes of the verb (--apply, --check, --hold-check, --adopt: the fetch/verify
# and apply sections below) run as root under the run lock. `ledger` is a VERB,
# never a stage (SC-432).
#
# Two classes of outcome, and the split is the point (plan §2.8 "engine skew"):
#   refused:     the whole document. Envelope, grammar, canonical form, digest,
#                channel, stream, sequence, supersession, retirement and
#                omitted-id violations -- things no engine version could accept.
#   unsupported: ONE entry whose `op` this engine does not compile. Reported,
#                never refused, whatever its engine_min says: an op added to a
#                newer engine (or removed from one) must not darken a series on
#                the boxes that predate it. Only the envelope of such an entry is
#                validated, so a future op's own fields cannot refuse the
#                document here.
#
# feed_sha256 is the digest of the canonical document WITHOUT that key:
#     jq -c 'del(.feed_sha256)' doc.json | sha256sum
# The bytes must already equal `jq -c .` of themselves (no whitespace, no
# duplicate keys -- the console_identity_release_valid idiom), so a build tool
# that emits sorted-key compact JSON with one trailing LF and computes exactly
# that pipe produces what the box recomputes. Anything else is `canonical`.

LEDGER_KIND='update-ledger'
LEDGER_SCHEMA_VERSION=1
LEDGER_STREAM_RE='^[a-z]+(-[a-z]+)?-[0-9]+\.[0-9]+-[a-z]+[0-9]+(\.[0-9]+)?$'
LEDGER_ENTRY_ID_RE='^SHCP-(SEC|UPD)-[0-9]{4}-[0-9]{4}$'
LEDGER_ADVISORY_RE='^(CVE-[0-9]{4}-[0-9]+|GHSA-[a-z0-9-]{14}|SHCP-[0-9]{4}-[0-9]{4})$'
LEDGER_SHA256_RE='^[0-9a-f]{64}$'
# Top-level keys, exactly these, and the per-entry envelope every op shares.
LEDGER_DOC_KEYS=(kind schema_version stream product series os channel sequence generated_at expires_at entries retired feed_sha256)
LEDGER_ENTRY_KEYS=(id seq issued_at severity advisory_ids embargoed engine_min rollout supersedes op)
LEDGER_CHANNELS=(staging prod)
LEDGER_SEVERITIES=(critical high medium low)
# The compiled op catalogue. An op is added here only in the commit that
# implements its apply path; a name here with no body would be a lie the build
# tool believes.
LEDGER_OPS=(php-ini)

# php-ini (plan §2.3). The allow-list admits only keys whose override harms the
# overriding tenant alone; the deny-list is FpmPoolConfinementGuard's floor
# plus the SC-435 set, and is checked here as well as at allow-list review so
# a later edit to PHP_INI_ALLOWED can never slip one of these through (the
# engine side of SC-1041 rule 3).
PHP_INI_TARGETS=(tenant-fpm panel-php)
PHP_INI_ALLOWED=(session.upload_progress.enabled fatal_error_backtraces)
PHP_INI_DENIED=(open_basedir disable_functions disable_classes auto_prepend_file auto_append_file
	upload_tmp_dir session.save_path sys_temp_dir allow_url_fopen allow_url_include memory_limit
	extension extension_dir zend_extension opcache.restrict_api opcache.validate_permission)
PHP_INI_DENIED_PREFIX='user_ini.'
PHP_INI_VALUE_RE='^[A-Za-z0-9_.-]{1,64}$'

# A bash array as a JSON array of strings, for --argjson.
ledger_json_list() {
	printf '%s\n' "$@" | jq -R . | jq -sc .
}

# ledger_refuse <code> <detail> -- the one refusal line. Codes are a contract
# the corpus asserts on (tests/fixtures/ledger-corpus/); never reword one.
ledger_refuse() {
	printf 'refused: %s: %s\n' "$1" "$2"
	return 1
}

# ledger_validate_document FILE [STREAM] [LEDGER]
#
# Exit 0 with `ok` on stdout (preceded by one `unsupported: <id>: <op>` line
# per entry outside the catalogue), or exit 1 with exactly one
# `refused: <code>: <detail>` line. STREAM, when given, must equal the
# document's. LEDGER is a box ledger (plan §3.5): every id it records under the
# document's stream must still be listed, in entries[] or retired[] -- a
# generation that silently drops an id this box applied is refused
# (`omitted-id`), because "not mentioned" would otherwise read as "keep as is"
# on one box and "never existed" on a fresh one.
ledger_validate_document() {
	local file="$1" want_stream="${2:-}" box_ledger="${3:-}"
	local failure code detail stream expected got line id op
	local doc_keys entry_keys channels severities ops targets allowed denied

	# Size before parse: jq on an unbounded file is the cheap DoS (SC-480), and
	# the cap is the same one the manifest fetch enforces after download.
	[[ $(stat -c %s -- "$file") -le "$MAX_MANIFEST_BYTES" ]] \
		|| ledger_refuse size "document exceeds ${MAX_MANIFEST_BYTES} bytes" || return 1
	# Exactly one JSON document, whose bytes are their own compact form. Slurp
	# so two concatenated valid documents cannot pass as one; the cmp catches
	# whitespace, key order games and duplicate keys (jq keeps the last, so the
	# re-emitted bytes differ) without an NFC library on the box.
	jq -se 'length == 1 and (.[0] | type == "object")' -- "$file" >/dev/null 2>&1 \
		|| ledger_refuse canonical "not exactly one JSON object" || return 1
	# -S: canonical means SORTED keys (plan §3.1) and the box enforces it, not only
	# the build tool -- two documents with the same digest recipe but different key
	# order must not both be "the" generation.
	jq -cS . -- "$file" | cmp -s - "$file" \
		|| ledger_refuse canonical "bytes are not their own jq -cS form" || return 1

	doc_keys="$(ledger_json_list "${LEDGER_DOC_KEYS[@]}")"
	entry_keys="$(ledger_json_list "${LEDGER_ENTRY_KEYS[@]}")"
	channels="$(ledger_json_list "${LEDGER_CHANNELS[@]}")"
	severities="$(ledger_json_list "${LEDGER_SEVERITIES[@]}")"
	ops="$(ledger_json_list "${LEDGER_OPS[@]}")"
	targets="$(ledger_json_list "${PHP_INI_TARGETS[@]}")"
	allowed="$(ledger_json_list "${PHP_INI_ALLOWED[@]}")"
	denied="$(ledger_json_list "${PHP_INI_DENIED[@]}")"

	# One pass over the structure, first failure wins, in the order the plan
	# lists them. Emits "<code>\t<detail>" or nothing. Ints are JSON numbers
	# whose text is all digits: 3.5 and "3" are not ints, and neither is 1.0
	# (jq 1.7 keeps the literal, so the canonical check alone would pass it).
	failure="$(jq -r \
		--argjson doc_keys "$doc_keys" --argjson entry_keys "$entry_keys" \
		--argjson channels "$channels" --argjson severities "$severities" \
		--argjson ops "$ops" --argjson targets "$targets" \
		--argjson allowed "$allowed" --argjson denied "$denied" \
		--arg denied_prefix "$PHP_INI_DENIED_PREFIX" \
		--arg kind "$LEDGER_KIND" --argjson schema "$LEDGER_SCHEMA_VERSION" \
		--arg stream_re "$LEDGER_STREAM_RE" --arg id_re "$LEDGER_ENTRY_ID_RE" \
		--arg adv_re "$LEDGER_ADVISORY_RE" --arg sha_re "$LEDGER_SHA256_RE" \
		--arg value_re "$PHP_INI_VALUE_RE" --arg want_stream "$want_stream" '
		def isint: type == "number" and (tostring | test("^-?[0-9]+$"));
		def isuint: isint and . >= 0;
		def isid: type == "string" and test($id_re);
		def is_sec: test("^SHCP-SEC-");
		def fail($code; $detail): [$code, $detail];
		# --- canonical, the string rule: every string anywhere, keys included,
		# is printable ASCII (0x20-0x7e: no tab, no control, no non-ASCII). The
		# build side (ledger-common: NFC, no control characters) refuses an NFD
		# string or a \t escape; a second "canonical" that accepted them would
		# make the engine the wider of two implementations of one form.
		def ascii: explode | all(. >= 32 and . <= 126);
		def ascii_checks:
			. as $d
			| first(
				(paths | select((.[-1] | type) == "string" and ((.[-1] | ascii) | not))
				       | fail("canonical"; "key \(.[-1] | tojson) at \(.[:-1] | map(tostring) | join(".") | if . == "" then "<root>" else . end) is not printable ASCII")),
				(paths(type == "string") | . as $p | select($d | getpath($p) | ascii | not)
				       | fail("canonical"; "string at \($p | map(tostring) | join(".")) is not printable ASCII")));
		# --- document envelope ---
		def doc_checks:
			if (keys | sort) != ($doc_keys | sort) then
				fail("envelope"; "top-level keys must be exactly \($doc_keys | join(","))")
			elif .kind != $kind then fail("kind"; "kind is \(.kind | tojson), want \($kind | tojson)")
			elif .schema_version != $schema or (.schema_version | isint | not) then
				fail("schema"; "schema_version is \(.schema_version | tojson), want \($schema)")
			elif ([.stream, .product, .series, .os, .channel, .feed_sha256] | any(type != "string")) then
				fail("schema"; "stream, product, series, os, channel and feed_sha256 must be strings")
			elif ([.sequence, .generated_at, .expires_at] | any(isuint | not)) then
				fail("schema"; "sequence, generated_at and expires_at must be non-negative integers")
			elif (.entries | type) != "array" or (.retired | type) != "array" then
				fail("schema"; "entries and retired must be arrays")
			elif (.stream | test($stream_re) | not) then fail("stream"; "stream \(.stream | tojson) does not match the grammar")
			elif .stream != "\(.product)-\(.series)-\(.os)" then
				fail("stream"; "stream \(.stream) is not product-series-os (\(.product)-\(.series)-\(.os))")
			elif $want_stream != "" and .stream != $want_stream then
				fail("stream"; "document is for \(.stream), this box wants \($want_stream)")
			elif (.channel as $c | $channels | index($c)) == null then fail("channel"; "channel \(.channel | tojson) not in \($channels | join(","))")
			elif .sequence < 1 then fail("sequence"; "sequence must be >= 1")
			elif .expires_at <= .generated_at then fail("expires"; "expires_at \(.expires_at) is not after generated_at \(.generated_at)")
			elif (.feed_sha256 | test($sha_re) | not) then fail("canonical"; "feed_sha256 is not 64 lowercase hex")
			else empty end;
		# --- entries, in order: the shared envelope of every entry ---
		def entry_checks:
			. as $d
			| ($d.entries | to_entries[]) as {key: $i, value: $v}
			| if ($v | type) != "object" then fail("envelope"; "entry \($i) is not an object")
			else ($v.id | if type == "string" then . else "entry \($i)" end) as $name
			| if ($v.id | isid | not) then fail("entry-id"; "entry \($i): id \($v.id | tojson) does not match the grammar")
			elif ($v.seq | isint | not) or $v.seq < 1 then fail("entry-seq"; "\($name): seq must be a positive integer")
			elif $i > 0 and $v.seq <= $d.entries[$i - 1].seq then
				fail("entry-seq"; "\($name): seq \($v.seq) is not above the previous entry (\($d.entries[$i - 1].seq))")
			elif ([$entry_keys[] | select(. as $k | $v | has($k) | not)] | length) > 0 then
				fail("envelope"; "\($name): missing \([$entry_keys[] | select(. as $k | $v | has($k) | not)] | join(","))")
			elif ($v.issued_at | isuint | not) then fail("envelope"; "\($name): issued_at must be a non-negative integer")
			elif ($v.embargoed | type) != "boolean" then fail("envelope"; "\($name): embargoed must be a boolean")
			elif ($v.op | type) != "string" or $v.op == "" then fail("envelope"; "\($name): op must be a non-empty string")
			elif $v.severity != null and (($v.severity | type) != "string" or ($severities | index($v.severity)) == null) then
				fail("severity"; "\($name): severity \($v.severity | tojson) not in \($severities | join(",")),null")
			elif ($v.id | is_sec) and $v.severity == null then fail("severity"; "\($name): a SEC entry needs a severity")
			elif ($v.advisory_ids | type) != "array" or any($v.advisory_ids[]; type != "string" or (test($adv_re) | not)) then
				fail("advisory"; "\($name): advisory_ids must be an array of CVE/GHSA/SHCP ids")
			elif ($v.id | is_sec) and ($v.advisory_ids | length) == 0 and $v.embargoed != true then
				fail("advisory"; "\($name): a SEC entry needs advisory_ids or embargoed: true")
			elif ($v.engine_min | type) != "string" then fail("engine-min"; "\($name): engine_min must be a string")
			elif ($v.rollout | type) != "object" or ($v.rollout | keys) != ["canary_percent", "full_at"]
				or ($v.rollout.canary_percent | isint | not) or $v.rollout.canary_percent < 0 or $v.rollout.canary_percent > 100
				or ($v.rollout.full_at != null and ($v.rollout.full_at | isuint | not)) then
				fail("rollout"; "\($name): rollout must be {canary_percent: 0-100, full_at: epoch|null}")
			elif ($v.supersedes | type) != "array" or any($v.supersedes[]; isid | not) then
				fail("supersedes"; "\($name): supersedes must be an array of entry ids")
			else empty end end;
		# --- id space: entries unique, retired sorted/unique/disjoint ---
		def id_checks:
			([.entries[].id]) as $ids | .retired as $r
			| if ($ids | length) != ($ids | unique | length) then
				fail("entry-id"; "duplicate entry id \($ids | group_by(.) | map(select(length > 1)) | .[0][0])")
			elif any($r[]; isid | not) then fail("retired"; "retired must hold entry ids only")
			elif $r != ($r | unique) then fail("retired"; "retired must be sorted and unique")
			elif (($ids - ($ids - $r)) | length) > 0 then
				fail("retired"; "id \(($ids - ($ids - $r))[0]) is both an entry and retired: ids are never reused")
			else empty end;
		# --- supersession: lower seq (earlier, since sorted) or retired; SEC only names SEC ---
		def supersedes_checks:
			. as $d | .retired as $r
			| ($d.entries | to_entries[]) as {key: $i, value: $v}
			| ([$d.entries[:$i][].id] + $r) as $visible
			| ($v.supersedes[]) as $s
			| if $s == $v.id then fail("supersedes"; "\($v.id) supersedes itself")
			elif ($visible | index($s)) == null then
				fail("supersedes"; "\($v.id) supersedes \($s), which is neither an entry with a lower seq nor retired")
			elif ($v.id | is_sec) and ($s | is_sec | not) then
				fail("supersedes"; "\($v.id): a SEC entry may supersede only SEC entries, not \($s)")
			else empty end;
		# --- op fields, for ops this engine compiles; the rest are reported, not refused ---
		def php_ini_checks($v):
			if ($v | keys | sort) != (($entry_keys + ["set", "targets"]) | sort) then
				fail("op-fields"; "\($v.id): php-ini takes exactly the envelope plus targets and set")
			elif ($v.targets | type) != "array" or ($v.targets | length) == 0
				or ($v.targets | length) != ($v.targets | unique | length)
				or any($v.targets[]; type != "string" or (. as $t | $targets | index($t)) == null) then
				fail("op-fields"; "\($v.id): targets must be a non-empty subset of \($targets | join(","))")
			elif ($v.set | type) != "object" or ($v.set | length) == 0 then
				fail("op-fields"; "\($v.id): set must be a non-empty object")
			elif any($v.set | keys[]; (. as $k | $denied | index($k)) != null or startswith($denied_prefix)) then
				fail("op-fields"; "\($v.id): set names a denied php.ini key")
			elif any($v.set | keys[]; (. as $k | $allowed | index($k)) == null) then
				fail("op-fields"; "\($v.id): set key \([$v.set | keys[] | select(. as $k | ($allowed | index($k)) == null)][0]) is not in PHP_INI_ALLOWED")
			elif any($v.set[]; type != "string" or (test($value_re) | not)) then
				fail("op-fields"; "\($v.id): every set value must match \($value_re)")
			else empty end;
		def op_checks:
			.entries[] as $v
			| if $v.op == "php-ini" then php_ini_checks($v) else empty end;
		first(ascii_checks, doc_checks, entry_checks, id_checks, supersedes_checks, op_checks)
		| "\(.[0])\t\(.[1])"
	' -- "$file")" || ledger_refuse canonical "jq could not evaluate the document" || return 1
	if [[ -n "$failure" ]]; then
		code="${failure%%$'\t'*}"; detail="${failure#*$'\t'}"
		ledger_refuse "$code" "$detail"
		return 1
	fi

	# engine_min speaks the updater's own <contract>.<build>[.<conf>] grammar;
	# the ONE parser for it is updater_version_valid, so call it rather than
	# restate the regex inside jq. The comparison against the running engine
	# is the apply path's (plan §2.8); a validator has no running engine.
	# Captured, not piped: a jq failure inside a process substitution is silent
	# under set -e, and a silent loop over nothing would read as "all valid".
	line="$(jq -r '.entries[] | "\(.id)\t\(.engine_min)"' -- "$file")" || ledger_refuse canonical "jq could not list the entries" || return 1
	while IFS=$'\t' read -r id got; do
		[[ -n "$id" ]] || continue
		updater_version_valid "$got" \
			|| ledger_refuse engine-min "${id}: engine_min ${got} is not a <contract>.<build> version" || return 1
	done <<<"$line"

	# The digest covers the canonical bytes minus the key that carries it.
	expected="$(jq -r .feed_sha256 -- "$file")"
	got="$(jq -c 'del(.feed_sha256)' -- "$file" | sha256sum | awk '{print $1}')"
	[[ "$got" == "$expected" ]] \
		|| ledger_refuse canonical "feed_sha256 ${expected} is not the digest of the document without it (${got})" || return 1

	# Omitted-id, scoped to this stream (plan §2.5): ids the box recorded under
	# another stream orphan rather than refuse, so a series upgrade is not
	# blocked by history the new stream never rendered.
	if [[ -n "$box_ledger" ]]; then
		stream="$(jq -r .stream -- "$file")"
		got="$(jq -r --arg s "$stream" --slurpfile doc "$file" '
			[(.entries // {}) | to_entries[] | select(.value.stream == $s) | .key]
			- ([$doc[0].entries[].id] + $doc[0].retired)
			| first // empty' -- "$box_ledger")" \
			|| die "ledger: cannot read box ledger ${box_ledger}"
		[[ -z "$got" ]] \
			|| ledger_refuse omitted-id "${got} is recorded on this box under ${stream} but the document neither lists nor retires it" || return 1
		# Ids are never reused (rule 7): one this box retired by a document can
		# only ever stay in retired[]; an entry under that id is a reuse.
		got="$(jq -r --arg s "$stream" --slurpfile doc "$file" '
			[(.entries // {}) | to_entries[] | select(.value.stream == $s and .value.retired_by == "document") | .key] as $dead
			| [$doc[0].entries[].id | select(. as $x | $dead | index($x))] | first // empty' -- "$box_ledger")" \
			|| die "ledger: cannot read box ledger ${box_ledger}"
		[[ -z "$got" ]] \
			|| ledger_refuse retired "${got} was retired on this box by an earlier generation and is listed as an entry again (ids are never reused)" || return 1
	fi

	# Engine skew, last: a document is reported unsupported only once it is
	# known to be a document at all.
	line="$(jq -r --argjson ops "$ops" '.entries[] | select(.op as $o | ($ops | index($o)) == null) | "\(.id)\t\(.op)"' -- "$file")" || ledger_refuse canonical "jq could not list the entries" || return 1
	while IFS=$'\t' read -r id op; do
		[[ -n "$id" ]] || continue
		printf 'unsupported: %s: %s\n' "$id" "$op"
	done <<<"$line"
	printf 'ok\n'
}

# --- update ledger: fetch, verify, floor, hold (SC-1041, SC-1043) ---
#
# The box half of the ledger (shcp-master docs/security-update-feed-plan.md §2.5,
# §2.6). The verb fetches exactly its own stream's document, verifies it against
# the pinned release primary, refuses anything at or below the per-stream floor
# or on the wrong channel, and only then hands the document to the apply core.
# State lives under ${SHCP_UPDATE_STATE_DIR}/ledger -- NOT under runs/: RUN_KEEP
# would otherwise let daily ledger runs evict the apply journals that carry
# last_security_success, the SC-319 proof. The floor sits beside
# .shcp-version-floor, advance-only, durable, read before any document field.
#
# Exit map (the unit's SuccessExitStatus=3 depends on it): 0 committed/nothing/
# skipped; 3 deferred (fetch, verify, channel, stream, expiry, floor, manifest,
# hold -- nothing applied this window, finding surfaced by `check`); 1 a file
# newly refused or failed-final (OnFailure= fires); 75 the run lock is busy.
LEDGER_DIR="${SHCP_UPDATE_LEDGER_DIR:-${SHCP_UPDATE_STATE_DIR}/ledger}"
LEDGER_JSON="${LEDGER_DIR}/ledger.json"
LEDGER_LAST_RESULT="${LEDGER_DIR}/last-result.json"
LEDGER_RUNS_DIR="${LEDGER_DIR}/runs"
LEDGER_RUN_KEEP=10
LEDGER_FLOOR_DIR="${SHCP_UPDATE_LEDGER_FLOOR_DIR:-/var/lib/shcp}"
LEDGER_MACHINE_ID="${SHCP_UPDATE_MACHINE_ID:-/etc/machine-id}"
# A hold is the fast lever and must not outlive the generation that answers it:
# seven days, the same window an owner approval gets.
LEDGER_HOLD_MAX_LIFETIME=604800
LEDGER_HOLD_KEYS=(kind schema_version stream issued_at expires_at hold_entries retire_now)
LEDGER_HOLD_KIND='update-ledger-hold'
# Staleness: the apply window is daily, so three days without a commit is the
# SC-319 threshold again; the hold check is hourly, so three hours.
LEDGER_STALE_SECONDS="${SHCP_LEDGER_STALE_SECONDS:-259200}"
LEDGER_HOLD_STALE_SECONDS="${SHCP_LEDGER_HOLD_STALE_SECONDS:-10800}"
LEDGER_DETAIL=""
LEDGER_FLOOR_STATE=""

# One base for every product in v1; the stream carries the product. Moving the
# ledger to its own host is this constant and nothing else (plan §2.7).
product_ledger_url() {
	case "$1" in
		shcp|shcp-dns|shmp) printf 'https://repo.shcp.dev/ledger' ;;
	esac
}
ledger_base_url() {
	local base="${SHCP_UPDATE_LEDGER_URL:-$(product_ledger_url "$PRODUCT")}"
	[[ "$base" =~ ^https://[^[:space:]]+$ ]] || return 1
	printf '%s' "${base%/}"
}
# prod unless the smoke seam says staging; anything else is refused, not defaulted.
ledger_channel() {
	local c="${SHCP_UPDATE_LEDGER_CHANNEL:-prod}"
	case "$c" in prod|staging) printf '%s' "$c" ;; *) return 1 ;; esac
}
ledger_document_url() {
	local base; base="$(ledger_base_url)" || return 1
	case "$(ledger_channel)" in
		staging) printf '%s/staging/%s.json' "$base" "$1" ;;
		*)       printf '%s/%s.json' "$base" "$1" ;;
	esac
}
ledger_hold_url() {
	local base; base="$(ledger_base_url)" || return 1
	printf '%s/%s.hold.json' "$base" "$1"
}

# ledger_fetch_signed <url> <out> <max-bytes> -- the document and its detached
# signature, verified against the release keyring with the pinned PRIMARY as
# VALIDSIG's last field (the same match verify_manifest and the withdrawal list
# use: the subkey signs, the primary is the trust). Nothing is read from <out>
# before this returns 0. https only, redirects included.
ledger_fetch_signed() {
	local url="$1" out="$2" max="$3" sig="${2}.sig" status keyring dir
	LEDGER_DETAIL=""
	[[ -f "$RELEASE_KEYRING" ]] || { LEDGER_DETAIL="release keyring absent: ${RELEASE_KEYRING}"; return 1; }
	[[ "$RELEASE_FPR" =~ ^[A-Fa-f0-9]{40}$ ]] || { LEDGER_DETAIL="release fingerprint unpinned"; return 1; }
	if ! curl -fsSL --proto '=https' --proto-redir '=https' --max-time 30 --max-filesize "$max" \
			-H 'Accept-Encoding: identity' "$url" -o "$out" \
		|| ! curl -fsSL --proto '=https' --proto-redir '=https' --max-time 30 --max-filesize "$max" \
			-H 'Accept-Encoding: identity' "${url}.sig" -o "$sig"; then
		LEDGER_DETAIL="fetch failed: ${url}"
		rm -f -- "$out" "$sig"
		return 1
	fi
	# --max-filesize needs Content-Length; re-check what landed.
	if [[ "$(stat -c %s "$out")" -gt "$max" || ! -s "$sig" ]]; then
		LEDGER_DETAIL="document over ${max} bytes or empty signature: ${url}"
		rm -f -- "$out" "$sig"
		return 1
	fi
	# gpgv reads a binary keyring; EL pins the key armored (RPM-GPG-KEY-*).
	keyring="$RELEASE_KEYRING"
	if grep -aqm1 -e '^-----BEGIN PGP PUBLIC KEY BLOCK-----' -- "$RELEASE_KEYRING" 2>/dev/null; then
		dir="$(dirname "$out")"
		keyring="${dir}/release-keyring.gpg"
		GNUPGHOME="$dir" gpg --batch --quiet --yes --dearmor -o "$keyring" "$RELEASE_KEYRING" 2>/dev/null \
			|| { LEDGER_DETAIL="could not dearmor ${RELEASE_KEYRING}"; rm -f -- "$out" "$sig"; return 1; }
	fi
	if ! status="$(gpgv --keyring "$keyring" --status-fd 1 "$sig" "$out" 2>/dev/null)" \
		|| ! grep -Eq "^\[GNUPG:\] VALIDSIG .*[[:space:]]${RELEASE_FPR}[[:space:]]*$" <<<"$status"; then
		LEDGER_DETAIL="signature does not verify against the pinned release primary: ${url}"
		rm -f -- "$out" "$sig"
		return 1
	fi
	return 0
}

# --- floor: {sequence, feed_sha256} per stream, advance-only ------------------
ledger_floor_path() { printf '%s/.shcp-ledger-floor.%s' "$LEDGER_FLOOR_DIR" "$1"; }
# Prints "<sequence> <sha>"; "0 -" when no floor exists yet. A floor file that
# exists but cannot be read as {int, hex} is NOT zero: refusing is the direction
# that cannot be used to replay (return 1).
ledger_floor_read() {
	local f; f="$(ledger_floor_path "$1")"
	[[ -e "$f" || -L "$f" ]] || { printf '0 -'; return 0; }
	[[ -f "$f" && ! -L "$f" ]] || return 1
	jq -er 'if (.sequence|type) == "number" and (.sequence|tostring|test("^[0-9]+$"))
	           and (.feed_sha256|type) == "string" and (.feed_sha256|test("^[0-9a-f]{64}$"))
	        then "\(.sequence) \(.feed_sha256)" else error("floor") end' "$f" 2>/dev/null
}
# ledger_floor_ok <doc> <stream> -- sets LEDGER_FLOOR_STATE=new|current, or
# returns 1 with LEDGER_DETAIL. Called only on a verified, validated document.
ledger_floor_ok() {
	local doc="$1" stream="$2" seq sha floor fseq fsha
	LEDGER_FLOOR_STATE=""
	seq="$(jq -r '.sequence' "$doc")"; sha="$(jq -r '.feed_sha256' "$doc")"
	floor="$(ledger_floor_read "$stream")" || { LEDGER_DETAIL="floor file unreadable: $(ledger_floor_path "$stream")"; return 1; }
	fseq="${floor%% *}"; fsha="${floor#* }"
	if (( seq < fseq )); then
		LEDGER_DETAIL="sequence ${seq} is below the floor ${fseq}"; return 1
	elif (( seq == fseq )); then
		[[ "$sha" == "$fsha" ]] || { LEDGER_DETAIL="sequence ${seq} equals the floor with different bytes"; return 1; }
		LEDGER_FLOOR_STATE=current
	else
		LEDGER_FLOOR_STATE=new
	fi
	return 0
}
ledger_floor_write() {
	local stream="$1" seq="$2" sha="$3" f
	f="$(ledger_floor_path "$stream")"
	[[ ! -L "$f" ]] || return 1
	jq -nc --argjson s "$seq" --arg h "$sha" '{sequence: $s, feed_sha256: $h}' \
		| atomic_write_durable "$f"
}

# --- box ledger state --------------------------------------------------------
ledger_state_dir_ok() {
	local p
	LEDGER_DETAIL=""
	if [[ ! -d "$LEDGER_DIR" ]]; then
		install -d -m 0700 "$LEDGER_DIR" 2>/dev/null || { LEDGER_DETAIL="${LEDGER_DIR} could not be created"; return 1; }
	fi
	p="$LEDGER_DIR"
	while [[ "$p" == /* ]]; do
		[[ ! -L "$p" ]] || { LEDGER_DETAIL="${p} is a symlink"; return 1; }
		[[ "$p" != / ]] || break
		p="$(dirname "$p")"
	done
	chmod 0700 "$LEDGER_DIR" 2>/dev/null || { LEDGER_DETAIL="${LEDGER_DIR} could not be chmod'd to 0700"; return 1; }
	install -d -m 0700 "$LEDGER_RUNS_DIR" 2>/dev/null || { LEDGER_DETAIL="${LEDGER_RUNS_DIR} could not be created"; return 1; }
	return 0
}
ledger_node_id() {
	local id=""
	[[ -r "$LEDGER_MACHINE_ID" ]] && id="$(tr -cd 'a-f0-9' < "$LEDGER_MACHINE_ID" | head -c 64)"
	printf '%s' "$id"
}
# node_id is null, not "", while /etc/machine-id is unreadable: a canaried
# entry then waits (ledger_canary_selected) instead of every such box drawing
# the same lot. It is filled in on the first run that can read it.
ledger_state_init() {
	local n
	[[ ! -L "$LEDGER_JSON" ]] || { LEDGER_DETAIL="${LEDGER_JSON} is a symlink"; return 1; }
	if [[ -f "$LEDGER_JSON" ]]; then
		[[ "$(ledger_state_get '.node_id // ""')" == "" ]] || return 0
		n="$(ledger_node_id)"; [[ -n "$n" ]] || return 0
		ledger_state_set --arg n "$n" '.node_id = $n'
		return
	fi
	jq -nc --arg n "$(ledger_node_id)" '{schema_version: 1, node_id: (if $n == "" then null else $n end), streams: {}, entries: {}, files: {}}' \
		| atomic_write_durable "$LEDGER_JSON"
}
# ledger_state_set [jq args...] <jq filter> -- rewrite ledger.json durably. The
# filter is the LAST argument so a call reads like the jq it wraps.
ledger_state_set() {
	local filter="${!#}" out err
	set -- "${@:1:$#-1}"
	# Never let a failed or empty transform reach the file: a pipe into the
	# writer would replace ledger.json with nothing on any jq error.
	if ! out="$(jq -c "$@" "$filter" "$LEDGER_JSON" 2>"${LEDGER_DIR}/.jq-err")" || [[ -z "$out" ]]; then
		err="$(head -c 400 "${LEDGER_DIR}/.jq-err" 2>/dev/null | tr '\n' ' ')"
		engine_log "ledger: state transform failed: ${err:-no output}"
		rm -f -- "${LEDGER_DIR}/.jq-err"
		return 1
	fi
	rm -f -- "${LEDGER_DIR}/.jq-err"
	printf '%s\n' "$out" | atomic_write_durable "$LEDGER_JSON"
}
ledger_state_get() { jq -r "$@" "$LEDGER_JSON" 2>/dev/null; }

# --- hold --------------------------------------------------------------------
# Exactly the seven keys, nothing else: a hold can only ever make a box do less.
ledger_hold_validate() {
	local file="$1" stream="$2"
	LEDGER_DETAIL=""
	jq -cS . -- "$file" 2>/dev/null | cmp -s - "$file" || { LEDGER_DETAIL="hold bytes are not their own jq -cS form"; return 1; }
	jq -e --arg kind "$LEDGER_HOLD_KIND" --arg stream "$stream" --argjson max "$LEDGER_HOLD_MAX_LIFETIME" \
		--arg idre "$LEDGER_ENTRY_ID_RE" --argjson keys "$(ledger_json_list "${LEDGER_HOLD_KEYS[@]}")" '
		type == "object" and (keys == ($keys | sort))
		and ([paths | .[-1] | select(type == "string")] + [.. | strings] | all(explode | all(. >= 32 and . <= 126)))
		and .kind == $kind and .schema_version == 1 and .stream == $stream
		and (.issued_at|type) == "number" and (.issued_at|tostring|test("^[0-9]+$"))
		and (.expires_at|type) == "number" and (.expires_at|tostring|test("^[0-9]+$"))
		and .expires_at > .issued_at and (.expires_at - .issued_at) <= $max
		and ([.hold_entries, .retire_now] | all(type == "array" and all(.[]; type == "string" and test($idre))
		     and (. == (. | unique | sort))))' -- "$file" >/dev/null 2>&1 \
		|| { LEDGER_DETAIL="hold document fails the hold grammar"; return 1; }
	return 0
}
# ledger_hold_fetch_persist <stream> <scratch> -- fetch, verify, validate, and
# persist the hold under streams[<stream>].hold. A hold older than the one in
# force (issued_at) is refused and the persisted one stays; a missing or
# unverifiable hold is a deferral, never "no hold" (plan §2.6). Stamps
# last_hold_check only on success.
ledger_hold_fetch_persist() {
	local stream="$1" scratch="$2" url file have
	url="$(ledger_hold_url "$stream")" || { LEDGER_DETAIL="ledger url unconfigured"; return 1; }
	file="${scratch}/hold.json"
	ledger_fetch_signed "$url" "$file" 65536 || return 1
	ledger_hold_validate "$file" "$stream" || return 1
	have="$(ledger_state_get --arg s "$stream" '.streams[$s].hold.issued_at // 0')"
	[[ "$have" =~ ^[0-9]+$ ]] || have=0
	if (( $(jq -r '.issued_at' "$file") < have )); then
		LEDGER_DETAIL="served hold ($(jq -r '.issued_at' "$file")) is older than the hold in force (${have})"
		return 1
	fi
	ledger_state_set --arg s "$stream" --argjson h "$(jq -c '{issued_at, expires_at, hold_entries, retire_now}' "$file")" \
		--argjson now "$(date -u +%s)" \
		'.streams[$s] = ((.streams[$s] // {}) + {hold: $h, last_hold_check: $now})' \
		|| { LEDGER_DETAIL="could not persist the hold"; return 1; }
	return 0
}
# The hold in force for a stream: the persisted one unless it has expired.
ledger_hold_in_force() {
	ledger_state_get -c --arg s "$1" --argjson now "$(date -u +%s)" \
		'.streams[$s].hold // {} | if (.expires_at // 0) > $now then . else {} end'
}

# --- document ----------------------------------------------------------------
# ledger_document_fetch_verify <stream> <scratch> -- sets LEDGER_DOC on 0; on 1
# LEDGER_DETAIL names the reason. Order: fetch+signature, grammar (the one
# validator), channel, expiry, floor, manifest cross-check. Nothing after a
# failing step runs; nothing from the document is used before its step passes.
LEDGER_DOC=""
ledger_document_fetch_verify() {
	local stream="$1" scratch="$2" url file out line chan exp now mseq seq manifest
	LEDGER_DOC=""
	url="$(ledger_document_url "$stream")" || { LEDGER_DETAIL="ledger url unconfigured"; return 1; }
	file="${scratch}/document.json"
	ledger_fetch_signed "$url" "$file" "$MAX_MANIFEST_BYTES" || return 1
	if ! out="$(ledger_validate_document "$file" "$stream" "$LEDGER_JSON")"; then
		LEDGER_DETAIL="${out##*$'\n'}"
		return 1
	fi
	while IFS= read -r line; do
		[[ "$line" == unsupported:* ]] && engine_log "ledger: ${line}"
	done <<<"$out"
	chan="$(jq -r '.channel' "$file")"
	[[ "$chan" == "$(ledger_channel)" ]] || { LEDGER_DETAIL="document channel ${chan} is not this box's ($(ledger_channel))"; return 1; }
	exp="$(jq -r '.expires_at' "$file")"; now="$(date -u +%s)"
	(( exp > now )) || { LEDGER_DETAIL="document expired at ${exp}"; return 1; }
	ledger_floor_ok "$file" "$stream" || return 1
	# Freeze detection (plan §2.5): the versions manifest, fetched and replay-
	# checked on every run already, names each stream's current sequence. A served
	# document behind it is a frozen host or a hostile position, not "nothing new".
	manifest="${scratch}/manifest.json"
	manifest_fetch "$manifest" || { LEDGER_DETAIL="versions manifest unavailable for the ledger freshness check"; return 1; }
	seq="$(jq -r '.sequence' "$file")"
	mseq="$(jq -r --arg s "$stream" '.ledger[$s] // empty' "$manifest" 2>/dev/null || true)"
	if [[ "$mseq" =~ ^[0-9]+$ ]] && (( mseq > seq )); then
		LEDGER_DETAIL="stale_ledger: served sequence ${seq} but the versions manifest names ${mseq} for ${stream}"
		return 1
	fi
	LEDGER_DOC="$file"
	return 0
}

# --- runs, results -----------------------------------------------------------
ledger_run_new() {
	local d
	d="$(mktemp -d "${LEDGER_RUNS_DIR}/$(date -u +%Y%m%d-%H%M%S)-XXXXXX")" || return 1
	chmod 0700 "$d"
	printf '%s' "$d"
}
ledger_runs_prune() {
	local d n=0
	for d in $(ls -1 "$LEDGER_RUNS_DIR" 2>/dev/null | sort -r); do
		n=$((n + 1))
		(( n > LEDGER_RUN_KEEP )) && rm -rf -- "${LEDGER_RUNS_DIR:?}/${d}"
	done
	return 0
}
# ledger_result <status> <reason> <stream> <sequence> <run> -- last-result.json,
# the run's journal.json ({verb, mode, stream, sequence, result, findings[],
# at}: what this window did and every finding it raised, kept LEDGER_RUN_KEEP
# runs deep), the engine log, and the JSON verdict on stdout under --json.
LEDGER_MODE=""
ledger_result() {
	local status="$1" reason="$2" stream="$3" seq="${4:-null}" run="${5:-}" doc findings
	[[ "$seq" =~ ^[0-9]+$ ]] || seq=null
	doc="$(jq -nc --arg st "$status" --arg r "$reason" --arg s "$stream" --argjson q "$seq" --arg run "$run" \
		--arg at "$(now_utc)" '{verb: "ledger", status: $st, reason: $r, stream: $s, sequence: $q, run: $run, at: $at}')"
	if [[ -d "$LEDGER_DIR" ]]; then
		printf '%s\n' "$doc" | atomic_write "$LEDGER_LAST_RESULT" 0600 2>/dev/null || true
	fi
	if [[ -n "$run" && -d "$run" ]]; then
		findings="$(printf '%s\n' "${LEDGER_FINDINGS[@]}" | sed '/^$/d' | jq -R 'split("\t") | {id: .[0], path: .[1], state: .[2], detail: .[3]}' | jq -sc .)"
		jq -nc --arg m "$LEDGER_MODE" --arg st "$status" --arg r "$reason" --arg s "$stream" --argjson q "$seq" \
			--argjson f "${findings:-[]}" --arg at "$(now_utc)" \
			'{verb: "ledger", mode: $m, stream: $s, sequence: $q, result: $st, reason: $r, findings: $f, at: $at}' \
			| atomic_write "${run}/journal.json" 0600 2>/dev/null || true
	fi
	engine_log "ledger: ${status}: ${reason}"
	[[ "${JSON_OUTPUT:-0}" == 1 ]] && printf '%s\n' "$doc"
	return 0
}

# --- apply core (plan §2.5, §2.6, §2.8) --------------------------------------
#
# State is per FILE; an entry's state is the worst of its files. A destination
# file is shared by every entry that names it, so its body is COMPOSED from the
# last applied values of every contributing entry in state applied, orphaned,
# eligible or failed(<3) -- never from "files currently applied" alone, which
# would let a canaried re-issue remove the old value from unselected boxes
# before the new one lands. Only retired, superseded and retire_now remove
# values -- and a SUPERSEDED value stays in the body until its superseder is
# applied on that file, so a superseder that never lands cannot open a gap.
# Every write is write-ahead: attempts+1 and pending_sha256 are durable in
# ledger.json before the byte lands, so a crash anywhere leaves a file the next
# run recognises as its own. failed-final is terminal: only a re-issue (a newer
# issued_at) makes that file eligible again; a removal, which nothing can ever
# re-issue, is retried every window and pages once.
#
# An operation plugs in through four functions, ledger_op_<op>_{destinations,
# values,render,activate} (and an optional _prepare for directory rules);
# the core never knows a path, a unit or a key -- those are the op's constants.
#   destinations <entry-json>      -> lines "<path>\t<unit>\t<target>"
#   values       <entry-json>      -> the JSON object this entry contributes
#   render       <path> <contribs> -> body on stdout; <contribs> is a JSON array
#                                     of {id, values} in seq order
#   prepare      <path>            -> 0, or 1 with LEDGER_DETAIL (file refused)
#   activate     <path> <unit>     -> config test + reload/restart; 0, or 1
LEDGER_ATTEMPTS_MAX=3
LEDGER_DIRTY=()
LEDGER_FINDINGS=()
# Root owns every destination and every scan dir. The suites run as the CI
# user, so the owner the ownership checks expect is a seam, the
# SHCP_UPDATE_EXPECT_UID pattern; production never sets it.
LEDGER_OWNER_UID="${SHCP_UPDATE_LEDGER_OWNER_UID:-0}"
# Pre-write backups live HERE, keyed by the destination's digest -- never beside
# the destination: a `<name>.ledger-prev` left in a scan dir by a crash sorts
# after ours and refuses the target forever (prepare's directory rule).
LEDGER_PREV_DIR="${LEDGER_DIR}/prev"

ledger_op_fn() {  # <op> <fn> -> function name, if the op implements it
	local op="${1//-/_}" fn="ledger_op_${1//-/_}_$2"
	declare -F "$fn" >/dev/null 2>&1 || return 1
	printf '%s' "$fn"
}
ledger_op_known() {
	local o
	for o in "${LEDGER_OPS[@]}"; do [[ "$o" == "$1" ]] && { ledger_op_fn "$1" destinations >/dev/null; return; }; done
	return 1
}
ledger_dirty() { local p; for p in "${LEDGER_DIRTY[@]}"; do [[ "$p" == "$1" ]] && return 0; done; LEDGER_DIRTY+=("$1"); }
ledger_finding() {  # <id> <path> <state> <detail>
	LEDGER_FINDINGS+=("$1"$'\t'"$2"$'\t'"$3"$'\t'"$4")
	engine_log "ledger: ${3}: ${1} ${2}: ${4}"
}

# Deterministic per box and per entry: the same box is in the same canary set
# every window, a reinstalled box (new machine-id) draws afresh. A box with no
# node id (machine-id unreadable) never draws: it waits for full_at or 100%,
# because every such box hashing the same empty id would share one fate.
ledger_canary_selected() {  # <id> <percent> <full_at|null> <now>
	local id="$1" pct="$2" full="$3" now="$4" node h n
	(( pct >= 100 )) && return 0
	[[ "$full" == null ]] || (( now < full )) || return 0
	node="$(ledger_state_get '.node_id // ""')"
	[[ -n "$node" ]] || return 1
	h="$(printf '%s%s' "$node" "$id" | sha256sum | awk '{print $1}')"
	n=$(( 16#${h:0:8} % 100 ))
	(( n < pct ))
}

# ledger_retire_ids <stream> <by> <state> <id>... -- retired/superseded: the
# entry's file states and entry state become <by>'s terminal state and its
# files go dirty. A document or hold retirement removes the entry's values from
# every file now; a SUPERSESSION (<by> is an entry id) keeps them in
# .files[p].values until the superseder is applied on p -- ledger_compose keeps
# the value in the body meanwhile and ledger_forget_gone drops it after -- and
# restarts the entry's attempts on each file so the superseder's failures do
# not inherit its count. An id the box never applied is recorded retired so a
# later re-listing is caught by the grammar.
# LEDGER_RETIRE_AT is the retirement's own time (the document's generated_at,
# the hold's issued_at, the superseder's issued_at): an id the box never
# applied has no issued_at of its own, and without this stamp a stale-but-
# signed listing of it would re-apply on a fresh box (plan §2.6).
ledger_retire_ids() {
	local stream="$1" by="$2" state="$3"; shift 3
	local id p at="${LEDGER_RETIRE_AT:-$(date -u +%s)}" keep_values=false
	[[ "$at" =~ ^[0-9]+$ ]] || at="$(date -u +%s)"
	[[ "$by" == document || "$by" == hold ]] || keep_values=true
	for id in "$@"; do
		# An entry re-issued AFTER this retirement (issued_at newer than it) has
		# already superseded it; retiring it again every window would remove
		# and re-add the value on each run.
		# (A document's retired[] is authoritative whatever the times, and final:
		# rule 7. A hold never re-labels a document retirement.)
		[[ "$by" != document && "$(ledger_state_get --arg i "$id" --argjson at "$at" '(((.entries[$i].issued_at // 0) > $at) and ((.entries[$i].retired_by // null) == null)) or ((.entries[$i].retired_by // null) == "document")')" == true ]] && continue
		for p in $(ledger_state_get --arg i "$id" '.entries[$i].files // {} | keys[]'); do ledger_dirty "$p"; done
		# A supersession is recorded once: the superseder is listed every window,
		# and re-stamping the files each time would zero the attempts its own
		# failures are counting on the superseded entry.
		ledger_state_set --arg s "$stream" --arg i "$id" --arg by "$by" --arg st "$state" --argjson at "$at" --argjson keep "$keep_values" '
			((.entries[$i].retired_by // null) != $by) as $fresh
			| .entries[$i] = ((.entries[$i] // {stream: $s, files: {}}) + {state: $st, retired_by: $by, retired_at: $at})
			| if $keep then (if $fresh then .entries[$i].files |= with_entries(.value.state = $st | .value.attempts = 0) else . end)
			  else .entries[$i].files |= with_entries(.value.state = $st) | .files |= with_entries(.value.values |= (del(.[$i]))) end' || return 1
	done
	return 0
}

# ledger_compose <path> -> JSON array [{id, values}] of the entries whose values
# belong in the file right now, in seq order. A live contributor is in the body
# when it is applied or about to be written (eligible/failed -- not while its
# entry is held or waiting: a hold must not be written through by a
# neighbour's write); an orphan always; a SUPERSEDED contributor until its
# superseder is applied on this file -- it leaves the body exactly in the write
# that lands the superseder (a failed one restores it with the old bytes), and
# stays through a neighbour's write while the superseder is failed-final, held
# or waiting here, so a superseder that never lands opens no gap.
ledger_compose() {
	ledger_state_get -c --arg p "$1" '. as $L
		| def writing($e): ($e.files[$p].state // "") as $fs
		    | ($fs == "eligible" or $fs == "failed") and $e.state != "held" and $e.state != "waiting";
		[ ($L.files[$p].values // {}) | to_entries[]
		    | .key as $i | ($L.entries[$i] // {}) as $e
		    | {id: $i, values: .value, seq: ($e.seq // 0), st: ($e.files[$p].state // "none"), by: ($e.retired_by // null)}
		    | select(
		        (.by == null and (.st == "applied" or .st == "orphaned" or writing($e)))
		        or (.by != null and .by != "document" and .by != "hold"
		            and ($L.entries[.by] // {}) as $b | $b.files[$p] != null
		            and $b.files[$p].state != "applied" and (writing($b) | not))) ]
		| sort_by(.seq) | map({id, values})'
}

# ledger_attempts_bump <path> <id>... -- write-ahead: the attempt is durable
# before the action it counts (a write, a removal, a blocked removal).
ledger_attempts_bump() {
	local path="$1"; shift
	(( $# )) || return 0
	ledger_state_set --arg p "$path" --argjson ids "$(printf '%s\n' "$@" | jq -R . | jq -sc .)" \
		'reduce $ids[] as $i (.; .entries[$i].files[$p].attempts = ((.entries[$i].files[$p].attempts // 0) + 1))'
}
# ledger_refuse_live <path> <detail> <ids-name> <held-name> <gone-name> -- a
# refusal is on the live contributors (to write, or applied and standing); when
# contributors are retirements in flight it blocks the REMOVAL: that counts an
# attempt, written ahead, so three blocked windows reach failed-final and page
# -- nothing else ever re-triggers a removal. Never a silent success (the
# hold's undo depends on it).
ledger_refuse_live() {
	local path="$1" detail="$2"
	local -n refuse_ids="$3" refuse_held="$4" refuse_gone="$5"
	(( ${#refuse_ids[@]} + ${#refuse_held[@]} == 0 )) || ledger_mark_files "$path" refused "$detail" "${refuse_ids[@]}" "${refuse_held[@]}"
	(( ${#refuse_gone[@]} == 0 )) || { ledger_attempts_bump "$path" "${refuse_gone[@]}"; ledger_mark_failed "$path" "removal blocked: ${detail}" "${refuse_gone[@]}"; }
}

# The pre-write backup of <path>: ${LEDGER_PREV_DIR}/<sha256 of path>, 0600.
ledger_prev_path() { printf '%s/%s' "$LEDGER_PREV_DIR" "$(printf '%s' "$1" | sha256sum | awk '{print $1}')"; }
ledger_prev_keep() {  # <path> -> 0 with the backup in place
	local prev; prev="$(ledger_prev_path "$1")"
	install -d -m 0700 "$LEDGER_PREV_DIR" 2>/dev/null || return 1
	rm -f -- "$prev"
	cp -p -- "$1" "$prev" 2>/dev/null && chmod 0600 "$prev" 2>/dev/null
}
# ledger_rollback_activate <activate-fn> <path> <unit> <had-previous> -- after a
# failed activation: the previous bytes come back (or the new file goes, when
# there were none), then activation is re-run best-effort so the unit is left
# on the configuration it had -- for panel-php this is what brings shcpd back
# up on the old ini. LEDGER_ROLLBACK says which, for the finding.
ledger_rollback_activate() {
	local activate="$1" path="$2" unit="$3" had="$4" prev what
	prev="$(ledger_prev_path "$path")"
	if [[ "$had" == 1 && -f "$prev" ]]; then
		mv -f -- "$prev" "$path"; chmod 0644 "$path" 2>/dev/null || true; selinux_restore "$path"
		what="previous bytes restored"
	else
		rm -f -- "$path" "$prev"
		what="the new file removed"
	fi
	if "$activate" "$path" "$unit"; then LEDGER_ROLLBACK="${what} and re-activated"
	else LEDGER_ROLLBACK="${what} but did not re-activate"; fi
}
LEDGER_ROLLBACK=""

# ledger_write_path <path> <unit> <op> -- the per-file transaction (§2.5).
ledger_write_path() {
	local path="$1" unit="$2" op="$3" prepare render activate contribs body cur sha pend want had=0
	# Three sets of contributors: ids to write (eligible/failed, still live and
	# not held or waiting), held (applied, still live) and gone (retired or
	# superseded: this write is their removal). A refusal or a failure is
	# recorded on whichever set is affected. failed-final is in none of them:
	# terminal until a re-issue.
	local -a ids=() held=() gone=()
	mapfile -t ids  < <(ledger_state_get --arg p "$path" '.entries | to_entries[] | select((.value.retired_by // null) == null and .value.state != "held" and .value.state != "waiting" and ((.value.files[$p].state // "") == "eligible" or (.value.files[$p].state // "") == "failed")) | .key')
	mapfile -t held < <(ledger_state_get --arg p "$path" '.entries | to_entries[] | select((.value.retired_by // null) == null and (.value.files[$p].state // "") == "applied") | .key')
	mapfile -t gone < <(ledger_state_get --arg p "$path" '.entries | to_entries[] | select((.value.retired_by // null) != null and .value.files[$p] != null) | .key')
	render="$(ledger_op_fn "$op" render)" && activate="$(ledger_op_fn "$op" activate)" || return 1
	# The directory rule is standing, not write-time (§2.3): every destination
	# is re-resolved and comes through here every window, so a foreign file
	# planted AFTER the drop-in landed refuses the APPLIED ids too -- the same
	# finding, state and --adopt road back as a refusal before the first write.
	if prepare="$(ledger_op_fn "$op" prepare)" && ! "$prepare" "$path"; then
		ledger_refuse_live "$path" "$LEDGER_DETAIL" ids held gone; return 0
	fi
	# The write rule: absent -> create; present and ours (sha or pending sha) ->
	# rewrite; anything else is a finding, never a write. Symlinks and files not
	# owned by root are never ours.
	cur=""; sha="$(ledger_state_get --arg p "$path" '.files[$p].sha256 // ""')"; pend="$(ledger_state_get --arg p "$path" '.files[$p].pending_sha256 // ""')"
	if [[ -e "$path" || -L "$path" ]]; then
		if [[ -L "$path" || ! -f "$path" ]]; then ledger_refuse_live "$path" "destination is a symlink or not a regular file" ids held gone; return 0; fi
		[[ "$(stat -c %u "$path")" == "$LEDGER_OWNER_UID" ]] || { ledger_refuse_live "$path" "destination is not owned by root" ids held gone; return 0; }
		cur="$(sha256sum "$path" | awk '{print $1}')"
		# A drifted file is a finding for its APPLIED ids too: "anything else is
		# a finding, never a write" has to hold on a window with nothing to write.
		[[ "$cur" == "$sha" || ( -n "$pend" && "$cur" == "$pend" ) ]] \
			|| { ledger_refuse_live "$path" "present file is not one this engine wrote (unrecorded or changed)" ids held gone; return 0; }
	fi
	# Nothing to write and nothing to remove: the standing rules above were the
	# whole window for this file. (A failed-final contributor's old bytes stay.)
	(( ${#ids[@]} + ${#gone[@]} > 0 )) || return 0
	contribs="$(ledger_compose "$path")"
	if [[ "$(jq 'length' <<<"$contribs")" == 0 ]]; then
		# Nothing contributes any more: the retire path. Remove, activate, forget.
		if [[ -n "$cur" ]]; then
			ledger_attempts_bump "$path" "${gone[@]}"
			ledger_prev_keep "$path" || { ledger_mark_failed "$path" "could not back up the previous bytes under ${LEDGER_PREV_DIR}" "${gone[@]}"; return 0; }
			rm -f -- "$path"
			if ! "$activate" "$path" "$unit"; then
				ledger_rollback_activate "$activate" "$path" "$unit" 1
				ledger_mark_failed "$path" "${unit} did not activate after removing the file; ${LEDGER_ROLLBACK}" "${gone[@]}"
				return 0
			fi
			rm -f -- "$(ledger_prev_path "$path")"
		fi
		ledger_state_set --arg p "$path" 'del(.files[$p]) | .entries |= with_entries(.value.files |= del(.[$p]))'
		return 0
	fi
	body="$("$render" "$path" "$contribs")" || { ledger_refuse_live "$path" "render failed" ids held gone; return 0; }
	want="$(printf '%s\n' "$body" | sha256sum | awk '{print $1}')"
	[[ "$want" != "$cur" ]] || { ledger_mark_files "$path" applied "" "${ids[@]}"; ledger_forget_gone "$path"; return 0; }
	# Write-ahead: attempts and the intended digest are durable before the byte.
	ledger_state_set --arg p "$path" --arg h "$want" --argjson ids "$(printf '%s\n' "${ids[@]}" "${gone[@]}" | sed '/^$/d' | jq -R . | jq -sc .)" --arg u "$unit" '
		.files[$p] = ((.files[$p] // {}) + {pending_sha256: $h, unit: $u})
		| reduce $ids[] as $i (.; .entries[$i].files[$p].attempts = ((.entries[$i].files[$p].attempts // 0) + 1))' || return 1
	if [[ -n "$cur" ]]; then
		ledger_prev_keep "$path" || { ledger_mark_failed "$path" "could not back up the previous bytes under ${LEDGER_PREV_DIR}" "${ids[@]}" "${gone[@]}"; return 0; }
		had=1
	fi
	if ! printf '%s\n' "$body" | atomic_write "$path" 0644; then
		rm -f -- "$(ledger_prev_path "$path")"; ledger_mark_failed "$path" "could not write" "${ids[@]}"; return 0
	fi
	selinux_restore "$path"
	if "$activate" "$path" "$unit"; then
		rm -f -- "$(ledger_prev_path "$path")"
		ledger_state_set --arg p "$path" --arg h "$want" --argjson now "$(date -u +%s)" \
			'.files[$p] += {sha256: $h, pending_sha256: null, written_at: $now}'
		ledger_mark_files "$path" applied "" "${ids[@]}"; ledger_forget_gone "$path"
	else
		ledger_rollback_activate "$activate" "$path" "$unit" "$had"
		ledger_state_set --arg p "$path" '.files[$p].pending_sha256 = null'
		ledger_mark_failed "$path" "${unit} did not activate after the write; ${LEDGER_ROLLBACK}" "${ids[@]}" "${gone[@]}"
	fi
	return 0
}
# After a successful write of <path>, the body no longer carries: contributors
# retired by a document or a hold (their values left at retirement), and
# superseded contributors whose superseder is now applied on this file (or
# never names it). Their values and file records go; the entry stays retired.
ledger_forget_gone() {
	ledger_state_set --arg p "$1" '. as $L
		| [ .entries | to_entries[]
		    | select((.value.retired_by // null) != null and .value.files[$p] != null)
		    | select(.value.retired_by == "document" or .value.retired_by == "hold"
		             or $L.entries[.value.retired_by].files[$p] == null
		             or $L.entries[.value.retired_by].files[$p].state == "applied")
		    | .key ] as $drop
		| .entries |= with_entries(if (.key as $k | $drop | index($k)) != null then .value.files |= del(.[$p]) else . end)
		| if .files[$p] != null then .files[$p].values |= with_entries(select(.key as $k | ($drop | index($k)) == null)) else . end'
}
# ledger_mark_files <path> <state> <detail> <id>... -- per-file state for each
# listed entry; a refused/failed-final file is a finding (paged once, §2.1).
# applied restarts attempts: three is three CONSECUTIVE failures, not three
# writes over the entry's life.
ledger_mark_files() {
	local path="$1" state="$2" detail="$3"; shift 3
	local id
	for id in "$@"; do
		ledger_state_set --arg p "$path" --arg i "$id" --arg st "$state" --arg d "$detail" \
			'.entries[$i].files[$p] = ((.entries[$i].files[$p] // {}) + {state: $st, finding: (if $d == "" then null else $d end)})
			 | if $st == "applied" then .entries[$i].files[$p] += {notified_at: null, attempts: 0} else . end'
		case "$state" in refused|failed-final) ledger_finding "$id" "$path" "$state" "$detail" ;; esac
	done
}
ledger_mark_failed() {  # attempts decide failed vs failed-final
	local path="$1" detail="$2"; shift 2
	local id n
	for id in "$@"; do
		n="$(ledger_state_get --arg p "$path" --arg i "$id" '.entries[$i].files[$p].attempts // 0')"
		if (( n >= LEDGER_ATTEMPTS_MAX )); then ledger_mark_files "$path" failed-final "$detail" "$id"
		else ledger_mark_files "$path" failed "$detail" "$id"; engine_log "ledger: failed (attempt ${n}/${LEDGER_ATTEMPTS_MAX}): ${id} ${path}: ${detail}"; fi
	done
}
# Entry state = worst of its files; recorded for check/mail, never used to
# decide -- except held, waiting and unsupported, which the window set on
# purpose and which stand whatever the files say (a held entry with a failed
# file is held, not failed, and does not hold the generation open).
ledger_entry_states_update() {
	ledger_state_set '
		def rank: {"refused":9,"failed-final":8,"failed":7,"blocked":6,"held":5,"waiting":4,"unsupported":3,"orphaned":2,"applied":1,"superseded":0,"retired":0,"eligible":1};
		.entries |= with_entries(
			if (.value.retired_by // null) != null and ((.value.files // {}) | length) == 0 then .value.state = (if .value.retired_by == "document" or .value.retired_by == "hold" then "retired" else "superseded" end)
			elif .value.state == "held" or .value.state == "waiting" or .value.state == "unsupported" then .
			elif (.value.state == "retired" or .value.state == "superseded") and ((.value.files // {}) | length) == 0 then .
			else .value.state = ((.value.files // {} | to_entries | map(.value.state) | max_by(rank[.] // 0)) // .value.state // "applied") end)'
}

# ledger_apply_document <doc> <hold-json> <stream> -- 0 committed, 1 a finding
# newly needs a person (the generation may still have committed), 3 not
# committed this window (a file is still eligible or failed with attempts left).
ledger_apply_document() {
	local doc="$1" hold="$2" stream="$3" now seq sha e id op issued pct full rec_issued rec_state rec_retired rec_by
	local dests line path unit values held_ids="" hold_issued=0 p
	LEDGER_DIRTY=(); LEDGER_FINDINGS=(); LEDGER_DETAIL=""
	now="$(date -u +%s)"; seq="$(jq -r '.sequence' "$doc")"; sha="$(jq -r '.feed_sha256' "$doc")"
	# A hold_conflict is re-detected every window; the flag is this window's.
	ledger_state_set '.entries |= with_entries(del(.value.hold_conflict))' || return 3
	hold_issued="$(jq -r '.issued_at // 0' <<<"$hold")"; held_ids="$(jq -r '(.hold_entries // [])[]' <<<"$hold")"

	# Stream adoption / orphaning (§2.5): an id this document lists is adopted
	# into this stream with its state kept; an unlisted id from another stream is
	# orphaned -- its APPLIED values stay in every body they contributed to, and
	# its files never written (eligible, failed, refused) are dropped with their
	# values: nothing is on disk to keep, and a record left would hold this
	# stream's generation open for a file the old stream never landed.
	ledger_state_set --arg s "$stream" --argjson listed "$(jq -c '[.entries[].id] + .retired' "$doc")" '
		[ .entries | to_entries[]
		  | select((.value.stream // $s) != $s and ((.key as $k | $listed | index($k)) == null))
		  | .key as $k | (.value.files // {}) | to_entries[] | select(.value.state != "applied" and .value.state != "orphaned")
		  | {id: $k, path: .key} ] as $drop
		| .entries |= with_entries(
			if (.value.stream // $s) == $s then .
			elif (.key as $k | $listed | index($k)) != null then .value.stream = $s
			else .value.files |= with_entries(select(.value.state == "applied" or .value.state == "orphaned") | .value.state = "orphaned") end)
		| reduce $drop[] as $d (.; if .files[$d.path] != null then del(.files[$d.path].values[$d.id]) else . end)
		| .files |= with_entries(select(((.value.values // {}) | length) > 0 or (.value.sha256 // null) != null))' || return 3

	# Retirements: the document's, and the hold's retire_now (recorded as such).
	# A document retirement of an id that a LISTED, live entry supersedes is
	# deferred to the supersession: the value must stay in the body until the
	# superseder is applied on that file (never a gap), and the document's
	# final word on it is recorded below once nothing of it is left on disk.
	local -a rids=() retire_deferred=() now_rids=()
	mapfile -t retire_deferred < <(jq -r --slurpfile d "$doc" '. as $L
		| $d[0].retired[] | select(. as $r | ($L.entries[$r] // null) as $e
		    | $e != null and (($e.files // {}) | length) > 0
		      and ($e.retired_by // null) != "document" and ($e.retired_by // null) != "hold"
		      and any($d[0].entries[]; (.supersedes | index($r)) != null and (($L.entries[.id].retired_by // null) == null)))' "$LEDGER_JSON")
	mapfile -t rids < <(jq -r '.retired[]' "$doc")
	for id in "${rids[@]}"; do
		for p in "${retire_deferred[@]}"; do [[ "$p" == "$id" ]] && continue 2; done
		now_rids+=("$id")
	done
	LEDGER_RETIRE_AT="$(jq -r '.generated_at' "$doc")" ledger_retire_ids "$stream" document retired "${now_rids[@]}" || return 3
	mapfile -t rids < <(jq -r '(.retire_now // [])[]' <<<"$hold")
	LEDGER_RETIRE_AT="$hold_issued" ledger_retire_ids "$stream" hold retired "${rids[@]}" || return 3

	while IFS= read -r e; do
		id="$(jq -r '.id' <<<"$e")"; op="$(jq -r '.op' <<<"$e")"; issued="$(jq -r '.issued_at' <<<"$e")"
		rec_state="$(ledger_state_get --arg i "$id" '.entries[$i].state // ""')"
		rec_issued="$(ledger_state_get --arg i "$id" '.entries[$i].issued_at // 0')"
		rec_retired="$(ledger_state_get --arg i "$id" '.entries[$i].retired_at // 0')"
		rec_by="$(ledger_state_get --arg i "$id" '.entries[$i].retired_by // ""')"
		# retired/superseded is terminal unless re-issued newer than both the
		# entry the box had and the retirement itself; a document retirement is
		# final (ids are never reused, rule 7); a document that lists active
		# what a hold retired is a hold_conflict (§2.6), paged, not a re-apply.
		if [[ "$rec_state" == retired || "$rec_state" == superseded ]] \
				&& { [[ "$rec_by" == document ]] || (( issued <= rec_issued || issued <= rec_retired )); }; then
			if [[ "$rec_by" == hold ]]; then
				ledger_finding "$id" - hold_conflict "listed active but retired on this box by a hold"
				ledger_state_set --arg i "$id" '.entries[$i].hold_conflict = true'
			fi
			continue
		fi
		if ! ledger_op_known "$op"; then
			ledger_state_set --arg i "$id" --arg s "$stream" --arg o "$op" --argjson q "$(jq '.seq' <<<"$e")" --argjson is "$issued" \
				'.entries[$i] = ((.entries[$i] // {files: {}}) + {stream: $s, seq: $q, issued_at: $is, op: $o, state: "unsupported"})'
			engine_log "ledger: unsupported: ${id}: ${op} (needs engine >= $(jq -r '.engine_min' <<<"$e"))"
			continue
		fi
		if grep -qx -- "$id" <<<"$held_ids" && (( issued <= hold_issued )) && [[ "$rec_state" != applied ]]; then
			ledger_state_set --arg i "$id" --arg s "$stream" --argjson q "$(jq '.seq' <<<"$e")" --argjson is "$issued" \
				'.entries[$i] = ((.entries[$i] // {files: {}}) + {stream: $s, seq: $q, issued_at: $is, state: "held"})'
			continue
		fi
		pct="$(jq -r '.rollout.canary_percent' <<<"$e")"; full="$(jq -r '.rollout.full_at' <<<"$e")"
		if ! ledger_canary_selected "$id" "$pct" "$full" "$now"; then
			# Not selected: an applied entry keeps its old value in the body (a
			# re-issue pending); a new one waits.
			[[ "$rec_state" == applied ]] || ledger_state_set --arg i "$id" --arg s "$stream" --argjson q "$(jq '.seq' <<<"$e")" --argjson is "$issued" \
				'.entries[$i] = ((.entries[$i] // {files: {}}) + {stream: $s, seq: $q, issued_at: $is, state: "waiting", waiting: "canary"})'
			continue
		fi
		# Selected and not held: supersede what it replaces, in the same files.
		mapfile -t rids < <(jq -r '.supersedes[]' <<<"$e")
		LEDGER_RETIRE_AT="$issued" ledger_retire_ids "$stream" "$id" superseded "${rids[@]}" || return 3
		values="$("$(ledger_op_fn "$op" values)" "$e")" || { ledger_finding "$id" - refused "op values failed"; continue; }
		dests="$("$(ledger_op_fn "$op" destinations)" "$e")"
		ledger_state_set --arg i "$id" --arg s "$stream" --argjson q "$(jq '.seq' <<<"$e")" --argjson is "$issued" --arg o "$op" \
			--arg em "$(jq -r '.engine_min' <<<"$e")" --argjson reissue "$([[ -n "$rec_state" ]] && (( issued > rec_issued )) && echo true || echo false)" '
			.entries[$i] = ((.entries[$i] // {files: {}}) + {stream: $s, seq: $q, issued_at: $is, op: $o, engine_min: $em})
			| if $reissue then .entries[$i].files |= with_entries(.value.state = "eligible" | .value.attempts = 0) | del(.entries[$i].retired_by, .entries[$i].retired_at, .entries[$i].hold_conflict, .entries[$i].hold_conflict_notified_at) else . end
			| .entries[$i].state = (if .entries[$i].state == "waiting" or .entries[$i].state == "held" or .entries[$i].state == "unsupported" then "eligible" else .entries[$i].state end)'
		# Destinations are re-resolved every window: a new one is eligible, a
		# vanished one is dropped, values are recorded per path. failed-final
		# stands: only the re-issue above (a newer issued_at) resets it.
		local -a seen=()
		while IFS=$'\t' read -r path unit _; do
			[[ -n "$path" ]] || continue
			seen+=("$path")
			ledger_state_set --arg i "$id" --arg p "$path" --arg u "$unit" --argjson v "$values" '
				.files[$p] = ((.files[$p] // {values: {}}) + {unit: $u})
				| (.files[$p].values[$i] != $v) as $changed
				| .files[$p].values[$i] = $v
				| .entries[$i].files[$p] = ((.entries[$i].files[$p] // {attempts: 0})
				    | if (.state // "") == "failed-final" then . elif (.state // "") != "applied" or $changed then .state = "eligible" else . end)'
			ledger_dirty "$path"
		done <<<"$dests"
		for p in $(ledger_state_get --arg i "$id" '.entries[$i].files | keys[]'); do
			local kept=0; for path in "${seen[@]}"; do [[ "$path" == "$p" ]] && kept=1; done
			(( kept )) || { ledger_state_set --arg i "$id" --arg p "$p" 'del(.entries[$i].files[$p]) | del(.files[$p].values[$i])'; ledger_dirty "$p"; }
		done
	done < <(jq -c '.entries[]' "$doc")

	# Write phase, one transaction per dirty file.
	for path in "${LEDGER_DIRTY[@]}"; do
		unit="$(ledger_state_get --arg p "$path" '.files[$p].unit // ""')"
		op="$(ledger_state_get --arg p "$path" '[.entries[] | select(.files[$p] != null) | .op // empty] | first // ""')"
		[[ -n "$op" ]] || continue
		ledger_write_path "$path" "$unit" "$op" || return 3
	done
	# The deferred document retirements: final once the superseder has landed
	# everywhere the superseded value was (no file record left).
	now_rids=()
	for id in "${retire_deferred[@]}"; do
		[[ "$(ledger_state_get --arg i "$id" '(.entries[$i].files // {}) | length')" == 0 ]] && now_rids+=("$id")
	done
	LEDGER_RETIRE_AT="$(jq -r '.generated_at' "$doc")" ledger_retire_ids "$stream" document retired "${now_rids[@]}" || return 3
	ledger_entry_states_update

	# Commit rule (§2.5): nothing eligible or failed-with-attempts-left remains
	# -- on a live entry; a held or waiting entry's files are the hold's or the
	# canary's to resolve, and failed-final is terminal.
	local open_files
	open_files="$(ledger_state_get '[.entries[] | select(.state != "held" and .state != "waiting") | .files[] | select(.state == "eligible" or .state == "failed")] | length')"
	local page
	page="$(ledger_state_get '([.entries[].files[] | select((.state == "refused" or .state == "failed-final") and (.notified_at // null) == null)] | length)
		+ ([.entries[] | select(.hold_conflict == true and (.hold_conflict_notified_at // null) == null)] | length)')"
	if (( open_files == 0 )); then
		ledger_floor_write "$stream" "$seq" "$sha" || { LEDGER_DETAIL="could not advance the floor"; return 3; }
		# last_success moves only when the generation does: a host serving N
		# forever must read as stale (§2.5), not as a daily success.
		ledger_state_set --arg s "$stream" --arg h "$sha" --argjson q "$seq" --argjson now "$now" \
			'.streams[$s] = ((.streams[$s] // {}) + {sequence: $q, feed_sha256: $h, processed_at: $now,
			   last_success: (if (.streams[$s].sequence // -1) == $q then (.streams[$s].last_success // $now) else $now end)})'
	fi
	if (( page > 0 )); then
		LEDGER_DETAIL="$(printf '%s\n' "${LEDGER_FINDINGS[@]}" | awk -F'\t' '{printf "%s %s %s: %s; ", $3, $1, $2, $4}')"
		return 1
	fi
	(( open_files == 0 )) && { LEDGER_DETAIL=""; return 0; }
	LEDGER_DETAIL="${open_files} file(s) still eligible or failed; retried next window"
	return 3
}
# After the result is written: the findings this run paged are acknowledged, so
# a persisting one does not re-fire the notifier every window (§2.1).
ledger_findings_mark_notified() {
	ledger_state_set --argjson now "$(date -u +%s)" \
		'.entries |= with_entries(.value.files |= with_entries(if (.value.state == "refused" or .value.state == "failed-final") and (.value.notified_at // null) == null then .value.notified_at = $now else . end)
		   | if .value.hold_conflict == true and (.value.hold_conflict_notified_at // null) == null then .value.hold_conflict_notified_at = $now else . end)'
}

# ledger_hold_act <stream> <hold-json> -- retire_now, the undo half of the hold:
# the same retire path as a document's retired[], recorded as `by: hold`, no
# floor change. 0 done, 3 something could not be undone this hour.
ledger_hold_act() {
	local stream="$1" hold="$2" path unit op
	LEDGER_DIRTY=(); LEDGER_FINDINGS=()
	local -a rids=()
	mapfile -t rids < <(jq -r '(.retire_now // [])[]' <<<"$hold")
	LEDGER_RETIRE_AT="$(jq -r '.issued_at // 0' <<<"$hold")" ledger_retire_ids "$stream" hold retired "${rids[@]}" || return 3
	for path in "${LEDGER_DIRTY[@]}"; do
		unit="$(ledger_state_get --arg p "$path" '.files[$p].unit // ""')"
		op="$(ledger_state_get --arg p "$path" '[.entries[] | select(.files[$p] != null) | .op // empty] | first // ""')"
		[[ -n "$op" ]] || continue
		ledger_write_path "$path" "$unit" "$op" || return 3
	done
	ledger_entry_states_update
	[[ "$(ledger_state_get '[.entries[].files[] | select(.state == "failed")] | length')" == 0 ]] \
		|| { LEDGER_DETAIL="a retire_now file could not be removed or did not activate; retried next hour"; return 3; }
	if (( ${#LEDGER_FINDINGS[@]} > 0 )); then
		LEDGER_DETAIL="$(printf '%s\n' "${LEDGER_FINDINGS[@]}" | awk -F'\t' '{printf "%s %s %s: %s; ", $3, $1, $2, $4}')"
		return 1
	fi
	return 0
}

# ledger_adopt <path> -- operator-only: take an unrecorded or changed file over
# by recording its current bytes as the engine's; the next window rewrites it.
ledger_adopt() {
	local path="$1" cur
	[[ -f "$path" && ! -L "$path" ]] || die "ledger: --adopt: ${path} is not a regular file"
	ledger_state_get --arg p "$path" '.files[$p] // empty' | grep -q . || die "ledger: --adopt: ${path} is not a ledger destination on this box"
	cur="$(sha256sum "$path" | awk '{print $1}')"
	ledger_state_set --arg p "$path" --arg h "$cur" --argjson now "$(date -u +%s)" '
		.files[$p] += {sha256: $h, pending_sha256: null, adopted_at: $now}
		| .entries |= with_entries(.value.files |= with_entries(if .key == $p and .value.state == "refused" then .value.state = "eligible" | .value.finding = null | .value.notified_at = null else . end))' \
		|| die "ledger: --adopt: could not record ${path}"
	engine_log "ledger: adopted ${path} (${cur})"
}

# ledger_check_advisories -- one JSON advisory per line for cmd_check: a stream
# whose last commit is older than the threshold (unless security runs were
# deliberately skipped since), a hold check that stopped happening, and the
# last result when it was a deferral or a finding. A stream the engine has only
# just started managing (first_seen within the threshold) stays silent.
ledger_check_advisories() {
	[[ -f "$LEDGER_JSON" && ! -L "$LEDGER_JSON" ]] || return 0
	jq -c --argjson now "$(date -u +%s)" --argjson stale "$LEDGER_STALE_SECONDS" --argjson hstale "$LEDGER_HOLD_STALE_SECONDS" '
		(.streams // {}) | to_entries[] | .key as $k | .value as $s |
		( if (($s.skipped_at // 0) > ($s.last_success // 0)) then empty
		  elif (($s.last_success // 0) > 0) then
		      (if ($now - $s.last_success) > $stale
		       then {id: "ledger_stale", stream: $k, age_seconds: ($now - $s.last_success),
		             detail: ("no update-ledger generation has committed on \($k) in \(($now - $s.last_success) / 86400 | floor) day(s)")}
		       else empty end)
		  elif (($s.first_seen // $now) + $stale) < $now then
		      {id: "ledger_stale", stream: $k, age_seconds: ($now - $s.first_seen),
		       detail: ("no update-ledger generation has ever committed on \($k) in \(($now - $s.first_seen) / 86400 | floor) day(s)")}
		  else empty end ),
		( if (($s.last_hold_check // 0) > 0 and ($now - $s.last_hold_check) > $hstale)
		  then {id: "ledger_hold_check_stale", stream: $k, age_seconds: ($now - $s.last_hold_check),
		        detail: ("the hourly ledger hold check last succeeded \(($now - $s.last_hold_check) / 3600 | floor) hour(s) ago")}
		  else empty end )' "$LEDGER_JSON" 2>/dev/null || true
	if [[ -f "$LEDGER_LAST_RESULT" && ! -L "$LEDGER_LAST_RESULT" ]]; then
		jq -c 'select(.status == "deferred" or .status == "findings")
		       | {id: ("ledger_" + .status), stream, sequence, reason, at, detail: ("last ledger run: \(.status): \(.reason)")}' \
			"$LEDGER_LAST_RESULT" 2>/dev/null || true
	fi
}

# ledger_stamp <stream> <key> -- streams[<stream>].<key> = now (first_seen set once).
ledger_stamp() {
	ledger_state_set --arg s "$1" --arg k "$2" --argjson now "$(date -u +%s)" \
		'.streams[$s] = ((.streams[$s] // {}) + {($k): $now}) | .streams[$s].first_seen //= $now'
}

# --- php-ini: the v1 operation (plan §2.3) -----------------------------------
#
# Writes zz-shcp-ledger.ini into every PHP-FPM generation's scan dir (deb
# /etc/php/<v>/fpm/conf.d, rpm /etc/opt/remi/php<vv>/php.d) and into the panel
# PHP's /etc/shcp/conf.d. Destinations come from the UNITS present, never from
# directory globs: a leftover /etc/php/8.4 tree with no php8.4-fpm unit is not a
# destination. The file sorts last in the scan dir, so it wins over vendor
# NN-*.ini and over the installer's php.ini block -- by intent (§2.3).
LEDGER_PHP_DEB_ROOT="${SHCP_UPDATE_PHP_DEB_ROOT:-/etc/php}"
LEDGER_PHP_REMI_ROOT="${SHCP_UPDATE_PHP_REMI_ROOT:-/etc/opt/remi}"
LEDGER_SHCP_CONFD="${SHCP_UPDATE_SHCP_CONFD:-/etc/shcp/conf.d}"
LEDGER_FPM_LOCK_DIR="${SHCP_UPDATE_FPM_LOCK_DIR:-/run/shcp}"
LEDGER_PHP_INI_NAME='zz-shcp-ledger.ini'

# The installed FPM units, one per line: php<v>-fpm (deb) or php<vv>-php-fpm
# (rpm). SHCP_UPDATE_FPM_UNITS is the test seam.
ledger_php_fpm_units() {
	if [[ -n "${SHCP_UPDATE_FPM_UNITS+x}" ]]; then printf '%s\n' $SHCP_UPDATE_FPM_UNITS; return 0; fi
	systemctl list-unit-files --type=service --no-legend 'php*-fpm.service' 'php*-php-fpm.service' 2>/dev/null \
		| awk '{print $1}' | sed 's/\.service$//' | grep -E '^php[0-9.]+-(php-)?fpm$' || true
}
# <unit> -> the scan dir that unit's master reads, or nothing when the tree is
# not there (a unit file without its PHP tree is not a destination either).
ledger_php_fpm_scan_dir() {
	local unit="$1" v
	case "$unit" in
		php*-php-fpm) v="${unit#php}"; v="${v%-php-fpm}"; printf '%s/php%s/php.d' "$LEDGER_PHP_REMI_ROOT" "$v" ;;
		php*-fpm)     v="${unit#php}"; v="${v%-fpm}";     printf '%s/%s/fpm/conf.d' "$LEDGER_PHP_DEB_ROOT" "$v" ;;
		*) return 1 ;;
	esac
}
# <unit> -> the php-fpm binary for `-t`. SHCP_UPDATE_PHP_FPM_BIN is the test seam.
ledger_php_fpm_bin() {
	local unit="$1" v
	[[ -z "${SHCP_UPDATE_PHP_FPM_BIN:-}" ]] || { printf '%s' "$SHCP_UPDATE_PHP_FPM_BIN"; return 0; }
	case "$unit" in
		php*-php-fpm) v="${unit#php}"; v="${v%-php-fpm}"; printf '/opt/remi/php%s/root/usr/sbin/php-fpm' "$v" ;;
		php*-fpm)     v="${unit#php}"; v="${v%-fpm}";     printf '/usr/sbin/php-fpm%s' "$v" ;;
		*) return 1 ;;
	esac
}

ledger_op_php_ini_destinations() {
	local entry="$1" t unit dir
	for t in $(jq -r '.targets[]' <<<"$entry"); do
		case "$t" in
			tenant-fpm)
				while IFS= read -r unit; do
					[[ -n "$unit" ]] || continue
					dir="$(ledger_php_fpm_scan_dir "$unit")" || continue
					[[ -d "$dir" && ! -L "$dir" ]] || continue
					printf '%s/%s\t%s\ttenant-fpm\n' "$dir" "$LEDGER_PHP_INI_NAME" "$unit"
				done < <(ledger_php_fpm_units) ;;
			panel-php)
				printf '%s/%s\t%s\tpanel-php\n' "$LEDGER_SHCP_CONFD" "$LEDGER_PHP_INI_NAME" "$SHCPD_UNIT" ;;
		esac
	done
	return 0
}
ledger_op_php_ini_values() { jq -c '.set' <<<"$1"; }
ledger_op_php_ini_render() {
	local contribs="$2"
	printf '; SHCP update ledger (SC-1041) -- written by shcp-update, do not edit; entries: %s\n' \
		"$(jq -r '[.[].id] | join(" ")' <<<"$contribs")"
	# Last assignment wins in PHP, and contribs arrive in seq order.
	jq -r '.[] | .values | to_entries[] | "\(.key)=\(.value)"' <<<"$contribs"
}
# The engine-owned directory rule (§2.3): the panel-php dir is made root:root
# 0755 and scanned -- a file not owned by root, or one sorting after ours, would
# win over the ledger silently; both refuse the target with a finding. Tenant
# scan dirs are vendor dirs: present, not a symlink, root-owned, nothing more.
ledger_op_php_ini_prepare() {
	local path="$1" dir f
	dir="$(dirname "$path")"
	[[ ! -L "$dir" ]] || { LEDGER_DETAIL="${dir} is a symlink"; return 1; }
	[[ -d "$dir" ]] || { LEDGER_DETAIL="${dir} is absent"; return 1; }
	if [[ "$dir" == "$LEDGER_SHCP_CONFD" ]]; then
		if [[ "$(stat -c %u "$dir")" != "$LEDGER_OWNER_UID" || "$(stat -c %a "$dir")" != 755 ]]; then
			{ [[ "$LEDGER_OWNER_UID" != 0 ]] || chown root:root "$dir" 2>/dev/null; } && chmod 0755 "$dir" 2>/dev/null \
				|| { LEDGER_DETAIL="${dir} could not be made root:root 0755"; return 1; }
		fi
		for f in "$dir"/*; do
			[[ -e "$f" || -L "$f" ]] || continue
			[[ "$(basename "$f")" != "$LEDGER_PHP_INI_NAME" ]] || continue
			if [[ -L "$f" || "$(stat -c %u "$f" 2>/dev/null)" != "$LEDGER_OWNER_UID" ]]; then
				LEDGER_DETAIL="foreign_file: ${f} is not root-owned (or a symlink)"; return 1
			fi
			if [[ "$(basename "$f")" > "$LEDGER_PHP_INI_NAME" ]]; then
				LEDGER_DETAIL="foreign_file: ${f} sorts after ${LEDGER_PHP_INI_NAME} and would override it"; return 1
			fi
		done
	else
		[[ "$(stat -c %u "$dir")" == "$LEDGER_OWNER_UID" ]] || { LEDGER_DETAIL="${dir} is not owned by root"; return 1; }
	fi
	return 0
}
# Activation. FPM: config test, then reload of an ACTIVE unit only, both under
# the per-unit master flock the panel takes around its pool writes (SC-683,
# shcp-base FpmMasterLock); reload re-execs the workers and does not count
# toward StartLimitBurst. Panel PHP: restart shcpd (ini is read at start), probe
# it, then try-restart the long-running workers that scan the same dir.
ledger_op_php_ini_activate() {
	local path="$1" unit="$2"
	if [[ "$unit" == "$SHCPD_UNIT" ]]; then
		systemctl restart "${SHCPD_UNIT}.service" >/dev/null 2>&1 || return 1
		[[ "$(systemctl is-active "${SHCPD_UNIT}.service" 2>/dev/null)" == active ]] || return 1
		health_panel_probe; case $? in 0|2) ;; *) return 1 ;; esac
		if panel_messenger_inventory 2>/dev/null; then
			local w; for w in "${PANEL_MESSENGER_UNITS[@]}"; do systemctl try-restart "$w" >/dev/null 2>&1 || true; done
		fi
		return 0
	fi
	local lock="${LEDGER_FPM_LOCK_DIR}/php-fpm-master-${unit}.lock" bin
	[[ ! -L "$lock" && ( ! -e "$lock" || -f "$lock" ) ]] || return 1
	install -d -m 0755 "$LEDGER_FPM_LOCK_DIR" 2>/dev/null || true
	(umask 077; : >>"$lock") || return 1
	bin="$(ledger_php_fpm_bin "$unit")" || return 1
	(
		exec 207>>"$lock" || exit 1
		flock -w 60 207 || exit 1
		"$bin" -t >/dev/null 2>&1 || exit 1
		[[ "$(systemctl is-active "${unit}.service" 2>/dev/null)" == active ]] || exit 0
		systemctl reload "${unit}.service" >/dev/null 2>&1 || exit 1
		[[ "$(systemctl is-active "${unit}.service" 2>/dev/null)" == active ]] || exit 1
	)
}

cmd_ledger() {
	local mode="" validate="" stream_arg="" box_ledger=""
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--validate) [[ $# -ge 2 ]] || die "ledger: --validate needs a FILE"; mode=validate; validate="$2"; shift 2 ;;
			--stream)   [[ $# -ge 2 ]] || die "ledger: --stream needs a STREAM"; stream_arg="$2"; shift 2 ;;
			--ledger)   [[ $# -ge 2 ]] || die "ledger: --ledger needs a box ledger path"; box_ledger="$2"; shift 2 ;;
			--apply)      mode=apply; shift ;;
			--check)      mode=check; shift ;;
			--hold-check) mode=hold; shift ;;
			--json)       JSON_OUTPUT=1; shift ;;
			--adopt)      [[ $# -ge 2 ]] || die "ledger: --adopt needs a PATH"; mode=adopt; validate="$2"; shift 2 ;;
			*) die "ledger: unknown argument: $1" ;;
		esac
	done
	case "$mode" in
		validate)
			[[ -f "$validate" && ! -L "$validate" && -r "$validate" ]] || die "ledger: cannot read ${validate}"
			[[ -z "$box_ledger" || ( -f "$box_ledger" && -r "$box_ledger" ) ]] || die "ledger: cannot read box ledger ${box_ledger}"
			[[ -z "$box_ledger" ]] || jq -e 'type == "object"' -- "$box_ledger" >/dev/null 2>&1 \
				|| die "ledger: box ledger ${box_ledger} is not a JSON object"
			ledger_validate_document "$validate" "$stream_arg" "$box_ledger"
			return ;;
		apply|check|hold|adopt) ;;
		*) die "ledger: one of --validate FILE | --apply | --check | --hold-check | --adopt PATH is required" ;;
	esac
	[[ -z "$stream_arg" && -z "$box_ledger" ]] || die "ledger: --stream/--ledger go with --validate"

	require_product_resolved
	# The run lock before ANY state write, in every mode but --validate: --check
	# and --hold-check stamp and persist into ledger.json too, and two writers
	# of one file are two writers whatever they call themselves. Busy is exit
	# 75 (the units keep it green, plan §2.1).
	acquire_lock || lock_refused_exit
	LEDGER_MODE="$mode"
	ledger_state_dir_ok || die "ledger: ${LEDGER_DETAIL}"
	ledger_state_init || die "ledger: could not initialise ${LEDGER_JSON}"
	if [[ "$mode" == adopt ]]; then
		ledger_adopt "$validate"
		return 0
	fi
	local stream="" run rc
	if ledger_stream; then
		stream="$LEDGER_STREAM"
		[[ -z "$LEDGER_STREAM_DETAIL" ]] || engine_log "ledger: ${LEDGER_STREAM_DETAIL}"
	fi
	# The ledger is security content and obeys the same switch as the security
	# run (AD-6 reads fail toward enabled). Skipping stamps skipped_at so the
	# staleness advisory stays quiet on a box that chose this.
	if [[ "$mode" != check ]]; then
		local enabled
		enabled="$(settings_get "update.security.enabled" "true")"
		if [[ "$enabled" == "false" || "$enabled" == "0" ]]; then
			[[ -z "$stream" ]] || ledger_stamp "$stream" skipped_at
			ledger_result skipped security_updates_disabled "$stream"
			return 0
		fi
	fi
	[[ -n "$stream" ]] || { ledger_result deferred "stream_unresolved: ${LEDGER_STREAM_DETAIL}" ""; return 3; }
	ledger_stamp "$stream" first_seen
	run="$(ledger_run_new)" || die "ledger: could not create a run directory under ${LEDGER_RUNS_DIR}"
	ledger_runs_prune

	# The hold first, always: a document is never applied without knowing what
	# must not be, and a hold must be able to act without a new document.
	local hold
	if ! ledger_hold_fetch_persist "$stream" "$run"; then
		local hold_detail="$LEDGER_DETAIL"
		# The hold in force is the persisted one, and its retire_now is an undo
		# already ordered: it acts this window whether or not the hold URL
		# answers (a persisted retire_now that waits for the network to come
		# back is a hold that does nothing exactly when it is needed).
		hold="$(ledger_hold_in_force "$stream")"
		if [[ "$(jq -r '(.retire_now // []) | length' <<<"$hold")" != 0 ]]; then
			ledger_hold_act "$stream" "$hold"; rc=$?
			if (( rc == 1 )); then
				ledger_result findings "hold: ${LEDGER_DETAIL} (hold fetch: ${hold_detail})" "$stream" "" "$run"
				ledger_findings_mark_notified; return 1
			fi
			engine_log "ledger: persisted hold acted (rc ${rc}) while the hold fetch failed: ${hold_detail}"
		fi
		ledger_result deferred "hold: ${hold_detail}" "$stream" "" "$run"
		return 3
	fi
	if [[ "$mode" == hold ]]; then
		hold="$(ledger_hold_in_force "$stream")"
		if [[ "$(jq -r '(.retire_now // []) | length' <<<"$hold")" != 0 ]]; then
			ledger_hold_act "$stream" "$hold"; rc=$?
			case "$rc" in
				0) ledger_result ok "hold acted" "$stream" "" "$run"; return 0 ;;
				1) ledger_result findings "hold: ${LEDGER_DETAIL}" "$stream" "" "$run"; ledger_findings_mark_notified; return 1 ;;
				*) ledger_result deferred "hold: ${LEDGER_DETAIL}" "$stream" "" "$run"; return 3 ;;
			esac
		fi
		ledger_result ok "hold checked" "$stream" "" "$run"
		return 0
	fi

	if ! ledger_document_fetch_verify "$stream" "$run"; then
		ledger_result deferred "$LEDGER_DETAIL" "$stream" "" "$run"
		return 3
	fi
	local seq; seq="$(jq -r '.sequence' "$LEDGER_DOC")"
	if [[ "$mode" == check ]]; then
		ledger_result verified "floor ${LEDGER_FLOOR_STATE}" "$stream" "$seq" "$run"
		return 0
	fi
	ledger_apply_document "$LEDGER_DOC" "$(ledger_hold_in_force "$stream")" "$stream"; rc=$?
	case "$rc" in
		0) ledger_result committed "sequence ${seq}" "$stream" "$seq" "$run"; return 0 ;;
		1) ledger_result findings "$LEDGER_DETAIL" "$stream" "$seq" "$run"; ledger_findings_mark_notified; return 1 ;;
		*) ledger_result deferred "$LEDGER_DETAIL" "$stream" "$seq" "$run"; return 3 ;;
	esac
}


# --- installer converge (SC-692, SC-693, SC-694; shcp-master installer-converge-plan) ---
#
# Installer-rendered artifacts (systemd units and drop-ins, shcpd.conf) are frozen
# at install time. The installer's own shcp-converge knows how to render them per
# OS family and refreshes only what SHCP itself last wrote (its authored-bytes
# ledger). This verb delivers it: fetch the installer paired with the running
# panel, verify it, extract it root-only, and run its shcp-converge with a scrubbed
# environment under the run lock.
#
# A VERB, never a stage (SC-432): run_stages short-circuits quiet boxes, a stage
# would be skipped exactly where it is needed, and auto-rollback does not model
# config. stage_finalize only drops a marker (CONVERGE_PENDING) when the panel
# release changed; shcp-update-converge.timer runs `--apply --if-pending`. The
# PostgreSQL feature is the one other thing that makes a run pending
# (pgsql_converge_pending): the panel's enable and disable only change the
# feature marker and start that unit.
#
# Deliberately outside the UPD-14 reconcile region hashed by shcp-base's drift
# fixture: this code reuses manifest_fetch / artifact_fetch_verify by calling them.

CONVERGE_DIR="${SHCP_UPDATE_CONVERGE_DIR:-${SHCP_UPDATE_STATE_DIR}/converge}"
CONVERGE_PENDING="${CONVERGE_DIR}/pending.json"
# Pool refresh is a separate durable obligation. Installer artifacts may already
# be converged while one malformed tenant pool prevents the panel sweep from
# finishing; retaining this marker makes the daily timer retry that independent
# work without re-running (or pretending to roll back) installer convergence.
CONVERGE_POOLS_PENDING="${CONVERGE_DIR}/pools-pending.json"
# The panel reads this exact path (it can traverse CONVERGE_DIR, 0711, but not list it).
CONVERGE_LAST="${CONVERGE_DIR}/last-result.json"
# The daily drift report (plan AD-5): with no marker, --apply --if-pending runs a
# --check at most once per CONVERGE_CHECK_EVERY seconds and stores it here, so drift
# stays visible between releases. Its own file: a check never overwrites the result
# of the apply that last changed the box.
CONVERGE_LAST_CHECK="${CONVERGE_DIR}/last-check.json"
CONVERGE_CHECK_EVERY="${SHCP_UPDATE_CONVERGE_CHECK_EVERY:-82800}"
CONVERGE_DAILY=0
# 1 while an --if-pending run exists only because pgsql_converge_pending said so.
CONVERGE_PGSQL_ONLY=0
# 1 once shcp-converge has run in this invocation.
CONVERGE_RAN=0
# Written by the installer as its last step, at the root of the state tree
# (/var/lib/shcp, root-owned since SC-663; SHCP_UPDATE_VAR_LIB_ROOT is the test
# seam). Until it exists the panel may be half-extracted, so the daily report
# has nothing to converge against.
CONVERGE_INSTALLED_MARKER="${SHCP_UPDATE_INSTALLED_MARKER:-${SHCP_UPDATE_VAR_LIB_ROOT:-/var/lib/shcp}/.installed}"
CONVERGE_MAX_ATTEMPTS=3
CONVERGE_ACTION_BACKOFF_BASE=3600
CONVERGE_ACTION_BACKOFF_CAP=86400
# A deferral (shcp-converge's "a later run may see this met", or a manifest that has
# not caught up with the panel) keeps the marker so the hourly timer retries it, but
# only this many times: then the marker is dropped and the daily drift report takes
# over. Hourly, 24 is about a day.
CONVERGE_MAX_DEFERRALS=24
CONVERGE_OUTPUT_MAX=262144
# The environment shcp-converge sees. Nothing else crosses (SC-693): no
# SHCP_CONVERGE_* test seam, no BASH_ENV, no caller PATH.
CONVERGE_ENV=(PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C HOME=/root)

# The installer owns these files. On upgrade, converge only a preview installation
# it already placed; helper-unit installation remains outside the engine's remit.
CPMOVE_PREVIEW_ROOT="${SHCP_UPDATE_VAR_LIB_ROOT:-/var/lib/shcp}"
CPMOVE_PREVIEW_DIR="${CPMOVE_PREVIEW_ROOT}/cpmove-preview"
CPMOVE_PREVIEW_STATE_DIR="${CPMOVE_PREVIEW_ROOT}/cpmove-preview-state"
CPMOVE_PREVIEW_UNIT_DIR="${SHCP_UPDATE_CPMOVE_PREVIEW_UNIT_DIR:-/etc/systemd/system}"
CPMOVE_PREVIEW_APPARMOR_DIR="${SHCP_UPDATE_CPMOVE_PREVIEW_APPARMOR_DIR:-/etc/apparmor.d}"
CPMOVE_PREVIEW_PORT="${SHCP_UPDATE_CPMOVE_PREVIEW_PORT:-${SHCP_CPMOVE_PREVIEW_PORT:-844}}"
CPMOVE_PREVIEW_SELINUX_MAKEFILE="${SHCP_UPDATE_CPMOVE_PREVIEW_SELINUX_MAKEFILE:-/usr/share/selinux/devel/Makefile}"

cpmove_preview_render() {   # <source> <denied-docroot>
	local text
	text="$(<"$1")" || return 1
	text="${text//@PORT@/$CPMOVE_PREVIEW_PORT}"
	text="${text//@DIR@/$CPMOVE_PREVIEW_DIR}"
	text="${text//@PREVIEW_STATE@/$CPMOVE_PREVIEW_STATE_DIR}"
	text="${text//@STATE@/$CPMOVE_PREVIEW_ROOT}"
	text="${text//@DB_DIR@/$CPMOVE_PREVIEW_ROOT\/db}"
	text="${text//@CERT@/$CPMOVE_PREVIEW_DIR\/listener.crt}"
	text="${text//@KEY@/$CPMOVE_PREVIEW_DIR\/listener.key}"
	text="${text//@DENIED_DOCROOT@/$2}"
	text="${text//@HOME@/$PANEL_LINK}"
	text="${text//@RELEASES@/$RELEASES_DIR}"
	text="${text//@SHCPD@/$SHCPD_BIN}"
	text="${text//@PHP_CONF@/$(dirname -- "$PANEL_ENV")}"
	text="${text//@CHECK_SELINUX_CONTEXT@/$CPMOVE_PREVIEW_CHECK_SELINUX_CONTEXT}"
	if [[ "$1" == *.service ]]; then
		case "$OS_FAMILY" in
			deb) text="$(sed '/^SELinuxContext=/d' <<<"$text")" || return 1 ;;
			rpm) text="$(sed '/^AppArmorProfile=/d' <<<"$text")" || return 1 ;;
			*) return 1 ;;
		esac
	fi
	printf '%s\n' "$text"
}

cpmove_preview_capable() {
	local commands
	[[ -x "$SHCPD_BIN" ]] || return 1
	command -v grep >/dev/null || return 1
	commands="$("$SHCPD_BIN" php-cli "$PANEL_LINK/bin/console" list --raw shcp:cpmove 2>/dev/null)" || return 1
	grep -q '^shcp:cpmove:preview-sweep ' <<<"$commands"
}

cpmove_preview_converge() {   # <verified-installer-tree>
	local tree="$1" listener service timer source docroot start work
	local CPMOVE_PREVIEW_CHECK_SELINUX_CONTEXT
	service="$CPMOVE_PREVIEW_UNIT_DIR/shcp-cpmove-preview-sweep.service"
	timer="$CPMOVE_PREVIEW_UNIT_DIR/shcp-cpmove-preview-sweep.timer"
	case "$OS_FAMILY" in
		deb) listener="${SHCP_UPDATE_CPMOVE_PREVIEW_LISTENER_DEB:-/etc/apache2/sites-enabled/00-shcp-preview.conf}" ;;
		rpm) listener="${SHCP_UPDATE_CPMOVE_PREVIEW_LISTENER_RPM:-/etc/httpd/conf.d/00-shcp-preview.conf}" ;;
		*) return 1 ;;
	esac
	# An older install has no preview units. Do not create helper units here.
	[[ -f "$service" && -f "$timer" && -f "$listener" ]] || {
		engine_log 'cpmove: preview artifacts not installed; leaving helper units to the installer'
		return 0
	}
	[[ ! -L "$service" && ! -L "$timer" && ! -L "$listener" ]] || return 1
	CPMOVE_PREVIEW_CHECK_SELINUX_CONTEXT="$(sed -n 's/^Environment=SHCP_CPMOVE_CHECK_SELINUX_CONTEXT=//p' "$service" | head -n 1)"
	[[ -n "$CPMOVE_PREVIEW_CHECK_SELINUX_CONTEXT" ]] || CPMOVE_PREVIEW_CHECK_SELINUX_CONTEXT='system_u:system_r:shcp_cpmove_check_t:s0'
	[[ "$CPMOVE_PREVIEW_CHECK_SELINUX_CONTEXT" =~ ^[A-Za-z0-9_:.-]+$ ]] || return 1
	[[ "$CPMOVE_PREVIEW_PORT" =~ ^[0-9]+$ ]] && (( CPMOVE_PREVIEW_PORT > 0 && CPMOVE_PREVIEW_PORT < 1024 )) || return 1
	command -v sysctl >/dev/null || return 1
	start="$(sysctl -n net.ipv4.ip_unprivileged_port_start 2>/dev/null)" || return 1
	[[ "$start" =~ ^[0-9]+$ ]] && (( start > CPMOVE_PREVIEW_PORT )) || return 1
	for source in "$tree/samples/apache/00-shcp-preview.conf" \
		"$tree/samples/shcp/systemd/shcp-cpmove-preview-sweep.service" \
		"$tree/samples/shcp/systemd/shcp-cpmove-preview-sweep.timer"; do
		[[ -f "$source" && ! -L "$source" ]] || return 1
	done
	# The installer chose this path from its OS overlay; retain that choice.
	docroot="$(sed -nE 's/^[[:space:]]*DocumentRoot[[:space:]]+([^[:space:]]+)[[:space:]]*$/\1/p' "$listener" | head -n 1)"
	[[ "$docroot" == /* && -d "$docroot" ]] || return 1
	[[ ! -L "$CPMOVE_PREVIEW_DIR" && ! -L "$CPMOVE_PREVIEW_STATE_DIR" ]] || return 1
	install -d -m 0700 -o root -g root "$CPMOVE_PREVIEW_DIR" "$CPMOVE_PREVIEW_STATE_DIR" || return 1
	for source in listener.crt listener.key trust.pem; do
		[[ -f "$CPMOVE_PREVIEW_DIR/$source" && ! -L "$CPMOVE_PREVIEW_DIR/$source" ]] || return 1
	done
	cpmove_preview_render "$tree/samples/apache/00-shcp-preview.conf" "$docroot" \
		| install -m 0644 -o root -g root /dev/stdin "$listener" || return 1
	cpmove_preview_render "$tree/samples/shcp/systemd/shcp-cpmove-preview-sweep.service" "$docroot" \
		| install -m 0644 -o root -g root /dev/stdin "$service" || return 1
	install -m 0644 -o root -g root "$tree/samples/shcp/systemd/shcp-cpmove-preview-sweep.timer" "$timer" || return 1
	case "$OS_FAMILY" in
		deb)
			command -v apparmor_parser >/dev/null || return 1
			source="$tree/samples/apparmor/shcp-cpmove-preview"
			[[ -f "$source" && ! -L "$source" ]] || return 1
			cpmove_preview_render "$source" "$docroot" \
				| install -m 0644 -o root -g root /dev/stdin "$CPMOVE_PREVIEW_APPARMOR_DIR/shcp-cpmove-preview" || return 1
			apparmor_parser -r "$CPMOVE_PREVIEW_APPARMOR_DIR/shcp-cpmove-preview" || return 1
			;;
		rpm)
			for source in semodule semanage restorecon make; do command -v "$source" >/dev/null || return 1; done
			work="$(mktemp -d)" || return 1
			if ! cp "$tree/samples/selinux/shcp_cpmove_check.te" "$tree/samples/selinux/shcp_cpmove_check_port.cil" "$work/" \
				|| ! (cd "$work" && make -f "$CPMOVE_PREVIEW_SELINUX_MAKEFILE" shcp_cpmove_check.pp) \
				|| ! semodule -i "$work/shcp_cpmove_check.pp" -i "$work/shcp_cpmove_check_port.cil"; then
				find "$work" -depth -delete
				return 1
			fi
			find "$work" -depth -delete || return 1
			semanage port -a -t shcp_cpmove_preview_port_t -p tcp "$CPMOVE_PREVIEW_PORT" 2>/dev/null \
				|| semanage port -m -t shcp_cpmove_preview_port_t -p tcp "$CPMOVE_PREVIEW_PORT" || return 1
			semanage fcontext -a -t httpd_config_t "$CPMOVE_PREVIEW_DIR(/.*)?" 2>/dev/null \
				|| semanage fcontext -m -t httpd_config_t "$CPMOVE_PREVIEW_DIR(/.*)?" || return 1
			semanage fcontext -a -t shcp_cpmove_preview_state_t "$CPMOVE_PREVIEW_STATE_DIR(/.*)?" 2>/dev/null \
				|| semanage fcontext -m -t shcp_cpmove_preview_state_t "$CPMOVE_PREVIEW_STATE_DIR(/.*)?" || return 1
			restorecon -R "$CPMOVE_PREVIEW_DIR" "$CPMOVE_PREVIEW_STATE_DIR" || return 1
			;;
	esac
	command -v "$APACHE_CTL" >/dev/null || return 1
	run_bounded 60 "$APACHE_CTL" configtest >/dev/null 2>&1 || return 1
	systemctl reload "$(osf_web_server_unit)" || return 1
	systemctl daemon-reload || return 1
	if cpmove_preview_capable; then
		systemctl enable --now shcp-cpmove-preview-sweep.timer
	else
		engine_log 'cpmove: published panel lacks shcp:cpmove:preview-sweep; timer stays disabled'
		systemctl disable --now shcp-cpmove-preview-sweep.timer
	fi
}

# converge_doc <result> <reason> [inner-json] — the verb's own JSON document.
converge_doc() {
	local inner="${3:-null}"
	jq -nc --arg r "$1" --arg why "$2" --arg pv "$(panel_current_version)" --argjson inner "$inner" \
		'{schema_version:1, verb:"converge-installer", result:$r,
		  reason:(if $why == "" then null else $why end), panel_version:$pv, converge:$inner}'
}

# converge_state_dir_ok — CONVERGE_DIR exists (created 0711) and every path
# component up to / is owned by root (or the test uid) and not group/other-
# writable (a sticky ancestor is tolerated, as for private_scratch_dir). The marker
# decides whether root runs installer code, and root writes the result here, so a
# directory the panel account could rename aside is refused, never trusted.
converge_state_dir_ok() {
	local p st uid mode
	CONVERGE_STATE_DIR_DETAIL=""
	if [[ ! -d "$CONVERGE_DIR" ]]; then
		install -d -m 0711 "$CONVERGE_DIR" 2>/dev/null \
			|| { CONVERGE_STATE_DIR_DETAIL="${CONVERGE_DIR} could not be created"; return 1; }
	fi
	[[ ! -L "$CONVERGE_DIR" ]] \
		|| { CONVERGE_STATE_DIR_DETAIL="${CONVERGE_DIR} is a symlink"; return 1; }
	chmod 0711 "$CONVERGE_DIR" 2>/dev/null \
		|| { CONVERGE_STATE_DIR_DETAIL="${CONVERGE_DIR} could not be chmod'd to 0711"; return 1; }
	p="$CONVERGE_DIR"
	while [[ "$p" == /* ]]; do
		[[ ! -L "$p" ]] \
			|| { CONVERGE_STATE_DIR_DETAIL="${p} is a symlink"; return 1; }
		st="$(stat -c '%u %a' -- "$p" 2>/dev/null)" \
			|| { CONVERGE_STATE_DIR_DETAIL="${p} could not be stat'd"; return 1; }
		uid="${st%% *}" mode="${st##* }"
		[[ ( "$uid" == 0 || "$uid" == "$SHCP_UPDATE_EXPECT_UID" ) && "$mode" =~ ^[0-7]{3,4}$ ]] \
			|| { CONVERGE_STATE_DIR_DETAIL="${p} is not root-owned (uid=${uid})"; return 1; }
		if [[ "$p" == "$CONVERGE_DIR" ]]; then
			(( (8#$mode & 8#022) == 0 )) \
				|| { CONVERGE_STATE_DIR_DETAIL="${p} is group/other-writable (mode=${mode})"; return 1; }
		else
			(( (8#$mode & 8#022) == 0 || (8#$mode & 8#1000) != 0 )) \
				|| { CONVERGE_STATE_DIR_DETAIL="${p} is group/other-writable (mode=${mode})"; return 1; }
		fi
		[[ "$p" == / ]] && return 0
		p="$(dirname -- "$p")"
	done
	return 1
}

# converge_write <dest> <mode> — stdin to <dest>, atomically, in the verified
# CONVERGE_DIR (no component another account can swap, so the temp cannot be a
# planted symlink).
converge_write() {
	local tmp
	tmp="$(mktemp "${CONVERGE_DIR}/.tmp.XXXXXX")" || return 1
	if ! { cat >"$tmp" && chmod "$2" "$tmp" && mv -f -- "$tmp" "$1"; }; then
		rm -f -- "$tmp"; return 1
	fi
}

# converge_pending_read — "<panel_version>|<attempts>|<deferrals>" from the marker, or
# nothing when there is none. A marker that is not a regular file or does not parse
# reads as "|<MAX>|0": fail loud (stopped), never retry blind.
converge_pending_read() {
	[[ -e "$CONVERGE_PENDING" || -L "$CONVERGE_PENDING" ]] || return 0
	local line pv n d
	if [[ -f "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]] \
		&& line="$(jq -er '[(.panel_version // "" | tostring), ((.attempts // 0) | tostring), ((.deferrals // 0) | tostring)] | join("|")' "$CONVERGE_PENDING" 2>/dev/null)" \
		&& IFS='|' read -r pv n d <<<"$line" \
		&& [[ "$pv" =~ ^[0-9.]{0,32}$ && "$n" =~ ^[0-9]{1,3}$ && "$d" =~ ^[0-9]{1,3}$ ]]; then
		printf '%s\n' "$line"
	else
		# '|', not a tab: tab is IFS whitespace, so an empty first field would vanish.
		printf '|%s|0\n' "$CONVERGE_MAX_ATTEMPTS"
	fi
}

# converge_check_due — 0 when no daily drift check is on record, or the last one is
# older than CONVERGE_CHECK_EVERY. The stamp is the check document's own mtime, so a
# check that failed (network down) still waits a day: an outage is not retried hourly.
converge_check_due() {
	local mtime
	[[ -f "$CONVERGE_LAST_CHECK" && ! -L "$CONVERGE_LAST_CHECK" ]] || return 0
	mtime="$(stat -c %Y -- "$CONVERGE_LAST_CHECK" 2>/dev/null)" || return 0
	[[ "$mtime" =~ ^[0-9]+$ ]] || return 0
	local now; now="$(date +%s)"
	# A stamp from the future (the clock was wrong when it was written) is due now,
	# or the report would stop until the clock caught up.
	(( now - mtime >= CONVERGE_CHECK_EVERY || mtime > now + 300 ))
}

# pgsql_unit_up <unit> — 0 when systemd keeps the unit (enabled, enabled-runtime)
# or it is running.
pgsql_unit_up() {
	worker_unit_armed "${1}.service" && return 0
	case "$(unit_state "$1")" in active|activating|reloading) return 0 ;; esac
	return 1
}

# pgsql_converge_pending — 0 when the PostgreSQL feature needs a converge run
# that no panel release asked for. With the marker: the facts file is absent
# (enabled, not set up yet), or its unit is neither kept nor running (enabled
# again after a disable, which keeps the facts file). Without it: the cluster
# record exists and the facts file's unit is still kept or running (disabled,
# not stopped yet). An untrusted marker or record asks for nothing and is logged:
# whoever can plant one must not be able to make root run installer code hourly.
pgsql_converge_pending() {
	local marker record unit
	marker="$(pgsql_record_state "$PG_MARKER_FILE")"
	if [[ "$marker" == untrusted ]]; then
		engine_log "postgresql: IGNORING ${PG_MARKER_FILE}: not a regular file owned by uid ${SHCP_UPDATE_EXPECT_UID} in a directory it owns, or group/other-writable; it requests nothing"
		return 1
	fi
	if [[ "$marker" == trusted ]]; then
		[[ ! -e "$PG_FACTS_FILE" && ! -L "$PG_FACTS_FILE" ]] && return 0
		unit="$(pgsql_facts_unit)"
		[[ -n "$unit" ]] || return 1
		! pgsql_unit_up "$unit"
		return
	fi
	record="$(pgsql_record_state "$PG_CLUSTER_FILE")"
	if [[ "$record" == untrusted ]]; then
		engine_log "postgresql: IGNORING ${PG_CLUSTER_FILE}: not a regular file owned by uid ${SHCP_UPDATE_EXPECT_UID} in a directory it owns, or group/other-writable"
		return 1
	fi
	[[ "$record" == trusted ]] || return 1
	unit="$(pgsql_facts_unit)"
	[[ -n "$unit" ]] && pgsql_unit_up "$unit"
}

# A PostgreSQL change that keeps deferring (a repository down, a package that
# will not install) would otherwise fetch and run the installer every hour for
# good. After n consecutive deferrals the next attempt waits base * 2^(n-1),
# capped at a day. A success, or a different request (the marker created,
# rewritten or removed; the panel's retry rewrites it), starts again.
CONVERGE_PGSQL_BACKOFF="${CONVERGE_DIR}/pgsql-backoff.json"
PG_BACKOFF_BASE="${SHCP_UPDATE_PG_BACKOFF_BASE:-3600}"
PG_BACKOFF_CAP="${SHCP_UPDATE_PG_BACKOFF_CAP:-86400}"

# pgsql_request_key — what identifies the current request: the marker's inode
# and mtime, or "absent".
pgsql_request_key() {
	stat -c '%i:%Y' -- "$PG_MARKER_FILE" 2>/dev/null || printf 'absent\n'
}

# pgsql_backoff_waiting — 0 while the backoff after the last deferral runs.
pgsql_backoff_waiting() {
	local line key n last delay now
	[[ -f "$CONVERGE_PGSQL_BACKOFF" && ! -L "$CONVERGE_PGSQL_BACKOFF" ]] || return 1
	line="$(jq -er '[.key, (.deferrals|tostring), (.last|tostring)] | join("|")' "$CONVERGE_PGSQL_BACKOFF" 2>/dev/null)" || return 1
	IFS='|' read -r key n last <<<"$line"
	[[ "$key" == "$(pgsql_request_key)" && "$n" =~ ^[0-9]{1,3}$ && "$last" =~ ^[0-9]{1,12}$ ]] || return 1
	delay="$PG_BACKOFF_BASE"
	while (( n > 1 && delay < PG_BACKOFF_CAP )); do delay=$(( delay * 2 )); n=$(( n - 1 )); done
	(( delay > PG_BACKOFF_CAP )) && delay="$PG_BACKOFF_CAP"
	now="$(date +%s)"
	(( now < last + delay && last <= now + 300 ))
}

# pgsql_backoff_record — one more consecutive deferral of this request.
pgsql_backoff_record() {
	local n=0 key
	key="$(pgsql_request_key)"
	if [[ -f "$CONVERGE_PGSQL_BACKOFF" && ! -L "$CONVERGE_PGSQL_BACKOFF" ]] \
		&& [[ "$(jq -r '.key // empty' "$CONVERGE_PGSQL_BACKOFF" 2>/dev/null)" == "$key" ]]; then
		n="$(jq -r '.deferrals // 0 | if type == "number" then . else 0 end' "$CONVERGE_PGSQL_BACKOFF" 2>/dev/null || echo 0)"
	fi
	[[ "$n" =~ ^[0-9]{1,3}$ ]] || n=0
	jq -nc --arg k "$key" --argjson n "$(( n + 1 ))" --argjson t "$(date +%s)" \
		'{key:$k, deferrals:$n, last:$t}' | converge_write "$CONVERGE_PGSQL_BACKOFF" 0600 || true
}

# Why the request is not carried out yet, for the panel; the installer writes the
# same one line when its own step defers. The engine writes it only for a
# deferral of its own (no installer to run yet), and only while the marker asks.
PG_DEFERRED_FILE="${SHCP_UPDATE_PG_DEFERRED:-/etc/shcp/postgresql.deferred}"
pgsql_write_deferred() {
	local dir tmp
	[[ "$(pgsql_record_state "$PG_MARKER_FILE")" == trusted ]] || return 0
	dir="$(dirname -- "$PG_DEFERRED_FILE")"
	[[ -d "$dir" && ! -L "$dir" ]] && path_uid0_not_writable "$dir" || return 0
	tmp="$(mktemp "${dir}/.postgresql.deferred.XXXXXX")" || return 0
	if ! { printf '%s\n' "$(printf '%s' "$1" | tr -cd '\40-\176' | cut -c1-400)" >"$tmp" \
		&& chmod 0644 "$tmp" && mv -f -- "$tmp" "$PG_DEFERRED_FILE"; }; then
		rm -f -- "$tmp"
	fi
	return 0
}

# The installer paired with the running panel (SC-694), as a signed manifest
# named it while it was current. A PostgreSQL request on a box one release
# behind would otherwise wait on "skew" until the next update; the artifact is
# still checked against this sha256 and its signature.
CONVERGE_PAIRED="${CONVERGE_DIR}/paired-installer.json"

# converge_paired_record <manifest> — remember the manifest's installer when it
# is the running panel's release.
converge_paired_record() {
	local version url sig sha
	IFS='|' read -r version url sig sha <<<"$(converge_series_entry "$1")"
	[[ -n "$version" && "$version" == "$(panel_current_version)" \
		&& "$url" == https://* && "$sig" == https://* && "$sha" =~ ^[0-9a-f]{64}$ ]] || return 0
	converge_state_dir_ok || return 0
	jq -nc --arg v "$version" --arg u "$url" --arg s "$sig" --arg h "$sha" --arg f "$OS_FAMILY" \
		'{panel_version:$v, os_family:$f, url:$u, sig_url:$s, sha256:$h}' \
		| converge_write "$CONVERGE_PAIRED" 0600 || true
}

# converge_paired_entry <version> — "<version>|<url>|<sig_url>|<sha256>" from the
# record when it names <version> on this family, else nothing (rc 1).
converge_paired_entry() {
	local line version url sig sha
	[[ -f "$CONVERGE_PAIRED" && ! -L "$CONVERGE_PAIRED" ]] || return 1
	line="$(jq -er --arg f "$OS_FAMILY" 'select(.os_family == $f)
		| [.panel_version, .url, .sig_url, .sha256] | map(tostring) | join("|")' "$CONVERGE_PAIRED" 2>/dev/null)" || return 1
	IFS='|' read -r version url sig sha <<<"$line"
	[[ "$version" == "$1" && "$url" == https://* && "$sig" == https://* && "$sha" =~ ^[0-9a-f]{64}$ ]] || return 1
	printf '%s|%s|%s|%s\n' "$version" "$url" "$sig" "$sha"
}

# converge_mark_pending <panel_version> <run_id> — called by stage_finalize after a
# healthy panel flip. A new release starts with zero attempts.
converge_mark_pending() {
	converge_state_dir_ok || return 1
	jq -nc --arg v "$1" --arg run "$2" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
		'{panel_version:$v, run_id:$run, attempts:0, marked_at:$now}' \
		| converge_write "$CONVERGE_PENDING" 0600
}

# converge_record_failure <version> <why> — count a failed APPLY against the
# marker for that release. Never from --check (read-only), never for another
# release's marker, and always a complete document even if the old one was garbled.
# A run made only for PostgreSQL has no marker, and its failure starts one: it ran
# this release's installer, and that failure counts toward the same cap, or a
# broken installer would be retried every hour for good. Its deferrals start none.
converge_record_failure() {
	local version="$1" why="$2" pv n d
	[[ -f "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]] \
		|| { (( CONVERGE_PGSQL_ONLY )) && [[ ! -e "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]]; } \
		|| return 0
	IFS='|' read -r pv n d < <(converge_pending_read) || true
	[[ -z "$pv" || "$pv" == "$version" ]] || return 0
	jq -nc --arg v "$version" --argjson n "$(( ${n:-0} + 1 ))" --argjson d "${d:-0}" --arg why "$why" \
		--arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
		'{panel_version:$v, attempts:$n, deferrals:$d, last_error:$why, last_attempt_at:$now}' \
		| converge_write "$CONVERGE_PENDING" 0600 || true
}

# A failed action does not make the release irrecoverable. Keep its marker and
# count separate from whole-run failures, with an hourly-to-daily retry delay.
converge_record_action_failure() {
	local version="$1" pv n d action_n
	[[ -f "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]] \
		|| { (( CONVERGE_PGSQL_ONLY )) && [[ ! -e "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]]; } \
		|| return 0
	IFS='|' read -r pv n d < <(converge_pending_read) || true
	[[ -z "$pv" || "$pv" == "$version" ]] || return 0
	action_n="$(jq -r '.action_failures // 0 | if type == "number" then . else 0 end' "$CONVERGE_PENDING" 2>/dev/null || echo 0)"
	[[ "$action_n" =~ ^[0-9]{1,9}$ ]] || action_n=0
	jq -nc --arg v "$version" --argjson n "${n:-0}" --argjson d "${d:-0}" \
		--argjson a "$(( action_n + 1 ))" --argjson now "$(date +%s)" \
		'{panel_version:$v, attempts:$n, deferrals:$d, action_failures:$a,
		  last_action_failure_at:$now, last_error:"shcp-converge reported failed actions"}' \
		| converge_write "$CONVERGE_PENDING" 0600 || true
}

converge_action_backoff_waiting() {
	local line n last delay now
	[[ -f "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]] || return 1
	line="$(jq -er '[(.action_failures // 0 | tostring), (.last_action_failure_at // 0 | tostring)] | join("|")' "$CONVERGE_PENDING" 2>/dev/null)" || return 1
	IFS='|' read -r n last <<<"$line"
	[[ "$n" =~ ^[0-9]{1,9}$ && "$last" =~ ^[0-9]{1,12}$ ]] || return 1
	(( n > 0 )) || return 1
	delay="$CONVERGE_ACTION_BACKOFF_BASE"
	while (( n > 1 && delay < CONVERGE_ACTION_BACKOFF_CAP )); do delay=$(( delay * 2 )); n=$(( n - 1 )); done
	(( delay > CONVERGE_ACTION_BACKOFF_CAP )) && delay="$CONVERGE_ACTION_BACKOFF_CAP"
	now="$(date +%s)"
	(( now < last + delay && last <= now + 300 ))
}

# converge_record_deferral <version> <why> — an APPLY for that release was deferred.
# The marker stays, so the next timer run retries, and the deferral is counted; it is
# not a failure, so attempts are untouched. Returns 1 when this was deferral
# CONVERGE_MAX_DEFERRALS: the marker is then removed (bounded retry), and the caller
# says so. No marker for that release: nothing to count, returns 0.
converge_record_deferral() {
	local version="$1" why="$2" pv n d
	[[ -f "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]] || return 0
	IFS='|' read -r pv n d < <(converge_pending_read) || true
	[[ "$pv" == "$version" ]] || return 0
	d=$(( ${d:-0} + 1 ))
	if (( d >= CONVERGE_MAX_DEFERRALS )); then
		rm -f -- "$CONVERGE_PENDING"
		return 1
	fi
	jq -nc --arg v "$version" --argjson n "${n:-0}" --argjson d "$d" --arg why "$why" \
		--arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
		'{panel_version:$v, attempts:$n, deferrals:$d, last_deferral:$why, last_attempt_at:$now}' \
		| converge_write "$CONVERGE_PENDING" 0600 || true
	return 0
}

# converge_deferral_reason <version> <why> — record the deferral and return the reason
# to report: <why>, or <why> plus the give-up notice when the bound was reached.
converge_deferral_reason() {
	if converge_record_deferral "$1" "$2"; then
		printf '%s\n' "$2"
	else
		printf '%s; deferred %s times, so the pending marker was dropped: fix the cause, then run '"'"'shcp-update converge-installer --apply'"'"'\n' "$2" "$CONVERGE_MAX_DEFERRALS"
	fi
}

# converge_clear_pending <version> — only the marker for the release just converged.
converge_clear_pending() {
	local pv n d
	IFS='|' read -r pv n d < <(converge_pending_read) || true
	[[ "$pv" == "$1" ]] && rm -f -- "$CONVERGE_PENDING"
	return 0
}

converge_pools_mark_pending() { # <panel-version>
	converge_state_dir_ok || return 1
	jq -nc --arg v "$1" --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
		'{panel_version:$v, marked_at:$now}' | converge_write "$CONVERGE_POOLS_PENDING" 0600
}

converge_pools_pending_version() {
	[[ -e "$CONVERGE_POOLS_PENDING" || -L "$CONVERGE_POOLS_PENDING" ]] || return 0
	if [[ ! -f "$CONVERGE_POOLS_PENDING" || -L "$CONVERGE_POOLS_PENDING" ]]; then
		return 1
	fi
	jq -er '.panel_version | select(type == "string" and test("^[0-9.]{1,32}$"))' \
		"$CONVERGE_POOLS_PENDING" 2>/dev/null
}

# Refresh canonical pool bytes without changing whether each pool enforces the
# SC-710 token. The marker is release-paired: after rollback it is discarded,
# never executed through a different release's renderer.
converge_pools_apply() { # <panel-version>
	local version="$1" marked console out rc=0
	marked="$(converge_pools_pending_version)" || return 4
	[[ -n "$marked" ]] || return 0
	if [[ "$marked" != "$version" || "$(panel_current_version)" != "$version" ]]; then
		rm -f -- "$CONVERGE_POOLS_PENDING"
		return 3
	fi
	console="${PANEL_LINK}/bin/console"
	[[ -x "$SHCPD_BIN" && -f "$console" \
		&& -f "${PANEL_LINK}/src/Command/PhpRepublishPoolsCommand.php" ]] || return 4
	out="$(mktemp)" || return 4
	"$SHCPD_BIN" php-cli "$console" shcp:php:republish-pools --converge-existing \
		--env=prod --no-interaction >"$out" 2>/dev/null || rc=$?
	db_fix_ownership "$SHCP_UPDATE_DB"
	# SC-782: exit 0 without the "[OK]" summary is a crash; keep the marker pending.
	(( rc == 0 )) && ! console_reported_ok "$out" && rc=1
	rm -f -- "$out"
	if (( rc == 0 )); then
		rm -f -- "$CONVERGE_POOLS_PENDING"
		return 0
	fi
	return 4
}

# converge_series_entry <manifest> — "<version>|<url>|<sig_url>|<sha256>" for the
# running panel's series and this OS family, or nothing. '|'-separated, NOT
# tab: the caller splits with IFS='|', and a tab is IFS whitespace, so an empty
# leading field would vanish (the same reason converge_pending_read uses it).
converge_series_entry() {
	local series; series="$(ver_series "$(panel_current_version)")"
	jq -r --arg s "$series" --arg fam "$OS_FAMILY" '
		[.series[]? | select(.series == $s)] | first // empty
		| [(.version // "-"), (.installer[$fam].url // "-"), (.installer[$fam].sig_url // "-"), (.installer[$fam].sha256 // "-")]
		| join("|")' "$1" 2>/dev/null
}


# converge_output_valid <file> — exactly ONE JSON document of the shape the panel
# caches (schema_version 1, known result, arrays where arrays belong).
converge_output_valid() {
	jq -es 'length == 1 and (.[0] | type == "object" and .schema_version == 1
		and (.result | IN("ok", "findings", "deferred", "refused", "failed"))
		and ((.artifacts // []) | type) == "array"
		and ((.groups // []) | type) == "array"
		and all((.artifacts // [])[]; (.id | type) == "string" and (.state | type) == "string"))' \
		"$1" >/dev/null 2>&1
}

# converge_emit <result> <reason> <exit> [inner-json] — print the document (and, for
# --apply, store it as last-result); an emit failure is itself a failure.
converge_emit() {
	local doc
	if ! doc="$(converge_doc "$1" "$2" "${4:-null}")"; then
		printf '%s\n' '{"schema_version":1,"verb":"converge-installer","result":"failed","reason":"could not build the result document"}'
		return 4
	fi
	if [[ "$CONVERGE_MODE" == apply ]] && converge_state_dir_ok; then
		printf '%s\n' "$doc" | converge_write "$CONVERGE_LAST" 0644 \
			|| engine_log "converge: could not write ${CONVERGE_LAST}"
		if (( CONVERGE_PGSQL_ONLY )); then
			case "$1" in
				deferred)
					pgsql_backoff_record
					# The installer writes its own reason when its step defers.
					(( CONVERGE_RAN )) || pgsql_write_deferred "$2" ;;
				ok) rm -f -- "$CONVERGE_PGSQL_BACKOFF" ;;
			esac
		fi
	elif (( CONVERGE_DAILY )) && converge_state_dir_ok; then
		printf '%s\n' "$doc" | converge_write "$CONVERGE_LAST_CHECK" 0644 \
			|| engine_log "converge: could not write ${CONVERGE_LAST_CHECK}"
	fi
	printf '%s\n' "$doc"
	return "$3"
}

CONVERGE_MODE=""
cmd_converge_installer() {
	local if_pending=0 arg
	for arg in "$@"; do
		case "$arg" in
			--check) CONVERGE_MODE=check ;;
			--apply) CONVERGE_MODE=apply ;;
			--if-pending) if_pending=1 ;;
			# Accepted and ignored: this verb is ALWAYS JSON (its only consumers
			# are the panel's parser and the timer's journal), so there is no
			# text mode for the flag to select. It once set JSON_OUTPUT, which
			# nothing here read — a dead flag that advertised a mode.
			--json) ;;
			*) die "converge-installer: unknown argument '${arg}'" ;;
		esac
	done
	[[ -n "$CONVERGE_MODE" ]] || die "converge-installer: --check or --apply is required"
	[[ "$if_pending" == 0 || "$CONVERGE_MODE" == apply ]] || die "converge-installer: --if-pending goes with --apply"
	local mode="$CONVERGE_MODE"

	osf_detect_family

	if [[ "$mode" == apply ]] && ! converge_state_dir_ok; then
		converge_emit refused "${CONVERGE_STATE_DIR_DETAIL:-${CONVERGE_DIR} is not a root-only directory}; not trusting its marker or writing results" 4
		return $?
	fi

	local pv attempts deferrals
	IFS='|' read -r pv attempts deferrals < <(converge_pending_read) || true
	: "${deferrals:=0}"
	if (( if_pending )); then
		local pg_pending=0
		if [[ ! -e "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" ]] && pgsql_converge_pending; then
			pg_pending=1
		else
			rm -f -- "$CONVERGE_PGSQL_BACKOFF"
		fi
		if (( pg_pending )) && pgsql_backoff_waiting; then
			engine_log "converge: the PostgreSQL change keeps deferring; waiting out the backoff in ${CONVERGE_PGSQL_BACKOFF}"
			pg_pending=0
		fi
		if (( pg_pending )); then
			# No release marker: the run converges the running release's installer,
			# whose PostgreSQL action does the work and defers rather than fails.
			# A deferral is not counted; a failure is (converge_record_failure).
			CONVERGE_PGSQL_ONLY=1
			engine_log "converge: the PostgreSQL feature changed (${PG_MARKER_FILE}); converging the running release"
		elif [[ ! -e "$CONVERGE_PENDING" && ! -L "$CONVERGE_PENDING" \
			&& ! -e "$CONVERGE_POOLS_PENDING" && ! -L "$CONVERGE_POOLS_PENDING" ]]; then
			# Nothing to apply. Once a day, report instead: shcp-converge --check
			# writes nothing, and a check takes no run lock and keeps no attempts.
			if ! converge_state_dir_ok || ! converge_check_due; then
				printf '%s\n' "$(converge_doc idle "no panel release change is waiting to converge")"
				return 0
			fi
			CONVERGE_DAILY=1 CONVERGE_MODE=check mode=check
		fi
		if (( ${attempts:-0} >= CONVERGE_MAX_ATTEMPTS )); then
			converge_emit stopped "converge failed ${CONVERGE_MAX_ATTEMPTS} times for release ${pv:-unknown}; fix the cause, then run 'shcp-update converge-installer --apply'" 4
			return $?
		fi
		# A marker only ever converges its own release (SC-694), and deferrals, failures
		# and success are all counted against that release. One that names another
		# release (left behind by a rollback) or none would never be counted or cleared,
		# so every timer run would retry it forever. Drop it; the daily report takes over.
		local running; running="$(panel_current_version)"
		if (( ! CONVERGE_DAILY )) && [[ -e "$CONVERGE_PENDING" || -L "$CONVERGE_PENDING" ]] \
			&& [[ -n "$running" && "$pv" != "$running" ]]; then
			rm -f -- "$CONVERGE_PENDING"
			converge_emit deferred "the pending marker names release '${pv:-none}' but the panel runs ${running}; dropped it" 3
			return $?
		fi
		if converge_action_backoff_waiting; then
			converge_emit deferred "failed installer actions are waiting out the retry backoff" 3
			return $?
		fi
	elif [[ "$mode" == apply && -e "$CONVERGE_PENDING" ]]; then
		# A person running --apply by hand gets fresh attempts for that release.
		jq -nc --arg v "$pv" '{panel_version:$v, attempts:0, deferrals:0, reset_by:"manual apply"}' \
			| converge_write "$CONVERGE_PENDING" 0600 || true
	fi

	local panel_version; panel_version="$(panel_current_version)"
	if [[ -z "$panel_version" ]]; then
		# The timer fires mid-install: shcp-updater is unpacked, and its timer's
		# OnBootSec has long elapsed, minutes before the installer extracts the panel.
		# Only the markerless daily report is excused, and only before the installer
		# has finished; an installed box with no readable version is still refused.
		if (( CONVERGE_DAILY )) && [[ ! -e "$CONVERGE_INSTALLED_MARKER" && ! -L "$CONVERGE_INSTALLED_MARKER" ]]; then
			printf '%s\n' "$(converge_doc idle "the panel install has not finished")"
			return 0
		fi
		converge_emit refused "the running panel version is unreadable" 4
		return $?
	fi

	# A prior installer apply already verified and converged its signed artifact,
	# but its separately marked pool refresh failed. Retry it daily without a
	# network fetch. The run lock serializes FPM writes with updates.
	if (( if_pending && ! CONVERGE_PGSQL_ONLY )) && [[ ! -e "$CONVERGE_PENDING" && -e "$CONVERGE_POOLS_PENDING" ]]; then
		if ! acquire_lock; then
			converge_emit deferred "run-in-progress: another engine verb holds the run lock; nothing changed, the next timer run retries" 3
			return $?
		fi
		local pools_rc=0
		converge_pools_apply "$panel_version" || pools_rc=$?
		case "$pools_rc" in
			0) converge_emit ok "" 0 ;;
			3) converge_emit deferred "pool convergence marker belonged to another panel release and was dropped" 3 ;;
			*) converge_emit findings "tenant PHP-FPM pool convergence remains pending" 3 ;;
		esac
		return $?
	fi

	# Phase A, no run lock: fetch and verify. A slow download never holds the lock
	# the daily security run needs. Network and repository trouble is NOT counted
	# against the release's attempts: an outage must not stop converge for good.
	local scratch manifest entry version url sig sha work root rc=0
	if ! scratch="$(private_scratch_dir)"; then
		converge_emit refused "no private scratch directory" 4
		return $?
	fi
	manifest="${scratch}/manifest.json"
	if ! manifest_fetch "$manifest"; then
		# DEFERRED, not failed. This is the hourly unit's ordinary weather — a
		# repository outage, DNS, a proxy — and SuccessExitStatus=3 is what
		# keeps the unit green through it; exit 4 here painted the unit `failed`
		# for the whole outage on every box (and in the daily --check path,
		# stamped a `failed` report over a box nothing is wrong with). The
		# marker and its attempt/deferral counters are left untouched: an
		# outage is not an attempt, and it must not consume the deferral budget.
		rm -rf "$scratch"
		converge_emit deferred "manifest fetch or verification failed (network, repository or signature trouble); nothing changed, the marker is untouched, the next timer run retries" 3
		return $?
	fi
	entry="$(converge_series_entry "$manifest")"
	IFS='|' read -r version url sig sha <<<"$entry"
	converge_paired_record "$manifest"
	# A PostgreSQL request does not wait for this box's next update: it takes the
	# installer a signed manifest named for the running release (SC-694 holds).
	if (( CONVERGE_PGSQL_ONLY )) && [[ "$version" != "$panel_version" ]] \
		&& entry="$(converge_paired_entry "$panel_version")"; then
		IFS='|' read -r version url sig sha <<<"$entry"
	fi
	# SC-694: only the installer from the SAME release record as the running panel.
	if [[ -z "$version" || "$version" == - || "$version" != "$panel_version" ]]; then
		rm -rf "$scratch"
		local why="skew: the manifest's current release for this series is '${version:-none}', the panel runs ${panel_version}"
		[[ "$mode" == apply ]] && why="$(converge_deferral_reason "$panel_version" "$why")"
		converge_emit deferred "$why" 3
		return $?
	fi
	if [[ "$url" != https://* || "$sig" != https://* || ! "$sha" =~ ^[0-9a-f]{64}$ ]]; then
		rm -rf "$scratch"
		converge_emit refused "the manifest has no usable installer for ${OS_FAMILY} in release ${version}" 4
		return $?
	fi
	if ! artifact_fetch_verify "$url" "$sig" "$sha" "${scratch}/installer.tar.gz"; then
		rm -rf "$scratch"
		converge_emit failed "installer artifact fetch or verification failed" 4
		return $?
	fi
	if ! installer_artifact_links_absent "$ARTIFACT_HELD"; then
		artifact_close; rm -rf "$scratch"
		[[ "$mode" == apply ]] && converge_record_failure "$version" "the installer artifact carries links"
		converge_emit refused "the verified installer artifact contains a symlink or hard link; refusing to execute from it" 4
		return $?
	fi
	work="${scratch}/tree"
	install -d -m 0700 "$work" || { artifact_close; rm -rf "$scratch"; converge_emit failed "scratch" 4; return $?; }
	tar xzf "$ARTIFACT_HELD" -C "$work" --no-same-owner --no-same-permissions 2>/dev/null || rc=$?
	artifact_close
	if (( rc != 0 )); then
		rm -rf "$scratch"
		[[ "$mode" == apply ]] && converge_record_failure "$version" "the installer artifact did not extract"
		converge_emit failed "the verified installer artifact did not extract" 4
		return $?
	fi
	# The layout rules are shared with reconcile/revendor (installer_tree_root,
	# shcp-updater#186): an ambiguous or unknown layout is a refusal, not "no
	# shcp-converge here".
	if ! root="$(installer_tree_root "$work")"; then
		rm -rf "$scratch"
		[[ "$mode" == apply ]] && converge_record_failure "$version" "the installer artifact has an ambiguous layout"
		converge_emit refused "the verified installer artifact has no single installer tree root; refusing to execute from it" 4
		return $?
	fi
	if [[ ! -f "${root}/shcp-converge" || -L "${root}/shcp-converge" ]]; then
		# Every 1.0 installer artifact carries shcp-converge: a verified artifact
		# without it is a packaging error, refused like an ambiguous layout. The
		# pending marker is kept — nothing converged, and clearing it would hide
		# the error from the next timer.
		rm -rf "$scratch"
		converge_emit refused "installer ${version} carries no shcp-converge — packaging error" 4
		return $?
	fi

	# Phase B, run lock held: nothing else changes the release or units meanwhile.
	# Contention is DEFERRED (3), never refused (4): the hourly timer overlapping
	# a rollback@, reinstall@, resume or an operator apply is ordinary, and
	# converge.service's After= cannot order against a unit an operator starts
	# by hand. Marker and counters untouched — the lock holder did the work
	# that made this hour's run unnecessary, or the next hour retries.
	if [[ "$mode" == apply ]] && ! acquire_lock; then
		rm -rf "$scratch"
		converge_emit deferred "run-in-progress: another engine verb holds the run lock; nothing changed, the next timer run retries" 3
		return $?
	fi
	# Re-check under the lock: an apply may have flipped the panel while phase A
	# was downloading. The installer fetched belongs to the release it was fetched for.
	if [[ "$(panel_current_version)" != "$version" ]]; then
		rm -rf "$scratch"
		converge_emit deferred "skew: the panel changed from ${version} to $(panel_current_version) during the fetch" 3
		return $?
	fi
	local result_doc="${scratch}/result.json" err_doc="${scratch}/stderr" size crc
	# stderr is captured to the scratch, not discarded: a shcp-converge that
	# crashes prints WHY there, and "produced no valid result document" alone
	# sent the operator to a tarball to reproduce it. Its tail rides the reason
	# — bounded (the last 512 bytes) and sanitised to printable ASCII, since it
	# is another program's output landing in a JSON field and the journal.
	env -i "${CONVERGE_ENV[@]}" /bin/bash "${root}/shcp-converge" "--${mode}" >"$result_doc" 2>"$err_doc" || true
	CONVERGE_RAN=1
	size="$(stat -c %s -- "$result_doc" 2>/dev/null || echo 0)"
	if (( size > CONVERGE_OUTPUT_MAX )) || ! converge_output_valid "$result_doc"; then
		local why_bad="shcp-converge produced no valid result document" err_tail=""
		err_tail="$(tail -c 512 -- "$err_doc" 2>/dev/null | tr -cd '\11\40-\176' | tr '\t' ' ' || true)"
		[[ -n "$err_tail" ]] && why_bad="${why_bad}; stderr tail: ${err_tail}"
		rm -rf "$scratch"
		[[ "$mode" == apply ]] && converge_record_failure "$version" "$why_bad"
		converge_emit failed "$why_bad" 4
		return $?
	fi
	local inner inner_result deferred=0 action_failed=0 why=""
	inner="$(jq -c '.' "$result_doc")"
	inner_result="$(jq -r '.result' <<<"$inner")"
	if [[ "$mode" == apply && "$inner_result" == ok ]] && ! cpmove_preview_converge "$root"; then
		rm -rf "$scratch"
		converge_record_failure "$version" "held-preview reconciliation failed"
		converge_emit failed "held-preview reconciliation failed; the pending marker remains" 4 "$inner"
		return $?
	fi
	rm -rf "$scratch"
	case "$inner_result" in
		ok) crc=0 ;;
		findings|deferred) crc=3 ;;
		*) crc=4 ;;
	esac
	if [[ "$mode" == apply && "$inner_result" == failed ]] \
		&& jq -e 'any((.artifacts // [])[]; .state == "failed")
			and all((.groups // [])[]; .result != "failed")
			and (.deferred_reason == null)' <<<"$inner" >/dev/null 2>&1; then
		action_failed=1
	fi
	# A deferral is a precondition a later run may see met: shcp-converge deferred the
	# whole run, or left an artifact deferred:<why>. Clearing the marker on it would
	# leave that artifact unconverged until the next release (installer#1028 item 4).
	if [[ "$inner_result" == deferred ]] \
		|| { (( crc == 3 )) && jq -e 'any((.artifacts // [])[]; .state | startswith("deferred"))' <<<"$inner" >/dev/null 2>&1; }; then
		deferred=1
	fi
	if [[ "$mode" == apply ]]; then
		if (( action_failed )); then
			converge_record_action_failure "$version"
		elif (( crc == 4 )); then
			converge_record_failure "$version" "shcp-converge reported ${inner_result}"
		elif (( deferred )); then
			why="$(converge_deferral_reason "$version" "shcp-converge deferred: $(jq -r '.deferred_reason // ([(.artifacts // [])[] | select(.state | startswith("deferred")) | .id] | join(", "))' <<<"$inner")")"
		else
			converge_clear_pending "$version"
		fi
	fi
	# shcp-converge is installer code and may install packages.
	if [[ "$mode" == apply ]] && ! initramfs_gate; then
		converge_emit failed "$INITRAMFS_ERROR" 4 "$inner"
		return $?
	fi
	# A PostgreSQL install that keeps deferring must not hold back a waiting pool
	# refresh, which otherwise runs only when the installer reports ok.
	if [[ "$mode" == apply && -e "$CONVERGE_POOLS_PENDING" ]] && (( (CONVERGE_PGSQL_ONLY && deferred) || action_failed )); then
		converge_pools_apply "$version" || engine_log "converge: tenant PHP-FPM pool convergence remains pending"
	fi
	# Only an installer result of `ok` proves every installer-owned artifact
	# converged. At that point create the independent pool obligation before
	# attempting it, so a crash or failed owner remains retryable after restart.
	if [[ "$mode" == apply && "$inner_result" == ok ]]; then
		if ! converge_pools_mark_pending "$version"; then
			converge_emit failed "installer converged but the durable pool marker could not be written" 4 "$inner"
			return $?
		fi
		local pools_rc=0
		converge_pools_apply "$version" || pools_rc=$?
		if (( pools_rc != 0 )); then
			converge_emit findings "installer converged; tenant PHP-FPM pool convergence remains pending" 3 "$inner"
			return $?
		fi
	fi
	if (( action_failed )); then
		converge_emit findings "shcp-converge reported failed actions; the pending marker remains for a later retry" 3 "$inner"
		return $?
	fi
	case "$crc" in
		0) converge_emit ok "" 0 "$inner" ;;
		3) if [[ "$inner_result" == deferred ]]; then
			converge_emit deferred "$why" 3 "$inner"
		else
			converge_emit findings "$why" 3 "$inner"
		fi ;;
		*) converge_emit failed "shcp-converge reported failure" 4 "$inner" ;;
	esac
	return $?
}

main() {
	# Global options BEFORE the verb. `--json` is documented as global, but the
	# first argv word used to be taken as the verb unconditionally, so
	# `shcp-update --json check` died with "unknown verb '--json'". Each verb
	# still accepts the flag after itself (the panel's argv shapes rely on it).
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--json)         JSON_OUTPUT=1; shift ;;
			-h|--help|help) usage; exit 0 ;;
			--)             shift; break ;;
			-*)             die "unknown option '${1}' (see --help)" ;;
			*)              break ;;
		esac
	done
	[[ $# -ge 1 ]] || { usage_short >&2; exit 2; }
	local verb="$1"
	shift
	# SIGKILL cannot be trapped, so a run OOM-killed mid-apt can strand the transient
	# no-autostart policy-rc.d; sweep a stale one before any apt path could consult it
	# (shcp-updater#256). Root-only and best-effort -- never fail a verb over it.
	if (( EUID == 0 )); then
		osf_policy_rc_d_self_heal || true
	fi
	case "$verb" in
		check)       cmd_check "$@" ;;
		apply)       cmd_apply "$@" ;;
		resume)      cmd_resume "$@" ;;
		rollback)    cmd_rollback "$@" ;;
		repair-series-pointer) cmd_repair_series_pointer "$@" ;;
		status)      cmd_status "$@" ;;
		self-test)   cmd_self_test "$@" ;;
		maintenance) cmd_maintenance "$@" ;;
		# UPD-14 detection. Read-only, no lock, no network — and never reachable
		# from run_stages (SC-432).
		reconcile-config) cmd_reconcile_config "$@" ;;
		# Installer converge (SC-692/693/694). A verb, never a stage (SC-432).
		converge-installer) cmd_converge_installer "$@" ;;
		# Internal repair: re-chown the panel DB + sidecars after a root console
		# call, symlink/hardlink-safe (SC-663). Called by shcp-update-drift.service
		# ExecStartPost; the ONE deliberately unlocked writer (see its comment).
		fix-db-ownership) cmd_fix_db_ownership "$@" ;;
		# SC-783: audit (with --repair, regenerate) every
		# installed kernel's initramfs. Every package-capable verb runs the same
		# audit on its way out; this is the operator's door, and the sandbox escape's.
		verify-initramfs) cmd_verify_initramfs "$@" ;;
		# What this engine implements (WM-6). No lock, no state: the installer asks
		# while it holds the webmail activation lock.
		capabilities) cmd_capabilities "$@" ;;
		# The webmail transaction (WM-6). Its own verb, never a stage (SC-432).
		webmail) cmd_webmail "$@" ;;
		# The update ledger (plan: security-update-feed; SC-1041). A verb,
		# never a stage (SC-432). --validate needs no root, no lock, no state --
		# the build tool calls it from CI on every rendered stream, and the box
		# calls it before reading any field; --apply/--check/--hold-check/--adopt
		# are root, under the run lock.
		ledger) cmd_ledger "$@" ;;
		*) die "unknown verb '${verb}' (see --help)" ;;
	esac
}

# Source-able for the bash test suite; a direct invocation dispatches.
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
	main "$@"
fi
