#!/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:
#   check [--blockers]                  read-only preflight; structured JSON
#   apply [--from-request | --scope security | --reinstall [--command-uuid <uuid>]]
#         [--resumed-from-self-update <run-id>]
#   resume [--at-boot]                  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 — targets awaiting_reboot only, validates
#         the marker/journal owner, and exits 0 (never fails the unit) when there
#         is nothing to do (shcp-build#97 / SC-532)
#   rollback <run-id> [--yes]           SC-071: confirms when interactive
#   status [--follow]                   progress of the latest run
#   self-test                           dependency / writability probe (JSON)
#   maintenance --set|--clear|--status  operator escape hatch for the flag
#   migrate-security-mechanism [--yes]  UPD-12: retire unattended-upgrades.
#         A STANDALONE verb under its own fixed unit, never a stage of a run —
#         see the section above cmd_migrate_security_mechanism for the three
#         measured reasons why.
#   reconcile-config --check            UPD-14: report configuration drift
#         between what the running software DECLARES it requires and what this
#         box actually 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, add an absent declared env key, install the
#         SC-423 edge-auth block. Takes the run flock, verifies the signed
#         installer artifact before reading it, archives what it touches, and
#         re-checks convergence. A write it cannot converge, or cannot target
#         where --check reads, aborts/coverage-gaps rather than writing blind.
#
# 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 + the one-time conversion of a flat layout); 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/lock/shcp-update.lock
#   SHCP_UPDATE_DB          default /var/lib/shcp/shcp.db — the LIVE panel
#                           database: the settings source, the snapshot source
#                           (UPD-4) and the only file a rollback overwrites
#   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_REBOOT_FILE     path of the deb reboot-required marker (tests)
#   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_VAR_LEGACY default /var/lib/shcp/panel-var
#   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 (blocker when invalid)
#   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_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_UPDATE_MIGRATION_DIR   default ${STATE_DIR}/migrations — where UPD-12's
#                               durable record and config archive live. NOT
#                               under runs/, because run_prune deletes run dirs
#                               past the newest 5 and the record has to outlive
#                               that (SC-431).
#   SHCP_APT_LOCK_PATHS         space-separated apt/dpkg lock files the UPD-12
#                               quiescence gate probes (default: the four real
#                               ones)
#   SHCP_UPDATE_QUIESCE_TIMEOUT/_INTERVAL  bound + poll period of that gate
#                               (default 300 s / 5 s)
#   SHCP_UPDATE_SECURITY_PROOF_MAX_AGE  how old the proof-of-patching marker may
#                               be before UPD-12 refuses (default 30 days;
#                               0 disables the age bound)
#   SHCP_UPDATE_SECURITY_TIMER  default shcp-update-security.timer — the
#                               replacement mechanism UPD-12 checks is in place
#   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
#
# 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/lock/shcp-update.lock}"
SHCP_UPDATE_DB="${SHCP_UPDATE_DB:-/var/lib/shcp/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}")

# 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}"

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 finalize)

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

log() { printf '[shcp-update] %s\n' "$*" >&2; }
die() { printf '[shcp-update] FATAL: %s\n' "$*" >&2; exit 1; }

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 OR an unrecorded parked id as unverifiable and lets
# the resume proceed (never strand a legitimately parked run over a read error
# or a run parked by an engine that predates this field), and refuses ONLY when
# both ids are known and equal.
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.
atomic_write() {
	local dest="$1"
	local mode="${2:-}"
	local tmp
	tmp="$(mktemp "${dest}.tmp.XXXXXX")"
	cat >"$tmp"
	if [[ -n "$mode" ]]; then
		chmod "$mode" "$tmp"
	fi
	mv -f "$tmp" "$dest"
}

# --- 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).
new_run_id() {
	printf '%s-%s' "$(date -u +%Y%m%d-%H%M%S)" \
		"$(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]
# Creates runs/<id>/ and the initial journal document (§5.4 shape).
#
# The two mkdir'd dirs are chmod'd 0711 rather than left to the ambient umask.
# 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}"
	mkdir -p "${RUNS_DIR}/${run_id}"
	chmod 0711 "$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 now "$(now_utc)" \
		'{run_id: $run_id, schema: $schema, trigger: $trigger, scope: $scope,
		  channel: "current",
		  request: $request,
		  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}' \
		| atomic_write "$(journal_path "$run_id")"
}

# journal_update <run_id> <jq-program> [jq args...] — atomic read-modify-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"
}

# 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>
journal_stage_done() {
	local run_id="$1" stage="$2" result_json="$3"
	journal_update "$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)"
}

# --- 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
}

# --- 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)$ ]]; then
		REQ_ERROR="invalid scope '${scope}' (want packages|panel|all|security)"
		return 1
	fi
	if [[ ! "$trigger" =~ ^(auto|manual)$ ]]; then
		REQ_ERROR="invalid trigger '${trigger}' (want auto|manual)"
		return 1
	fi
	if [[ ! "$requested_by" =~ ^[0-9]+$ ]]; then
		REQ_ERROR="invalid requested_by '${requested_by}' (want ^[0-9]+\$)"
		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

	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.
	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)')"
	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
}

# --- 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. A flag written by an OLDER engine
# carries `set_at`, not `timestamp` — fall back to it during a fleet upgrade.
maintenance_write() {
	local run_id="$1" phase="${2-}"
	local now=""
	if [[ -f "$MAINT_FLAG" ]]; then
		now="$(jq -r '.timestamp // .set_at // 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
}

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 ---------------------------------------------------------------------
LOCK_FD=""
acquire_lock() {
	mkdir -p "$(dirname "$SHCP_UPDATE_LOCK")"
	exec {LOCK_FD}>"$SHCP_UPDATE_LOCK"
	if ! flock -n "$LOCK_FD"; then
		return 1
	fi
	return 0
}

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.
	if ( 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_STATUS=""
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"
	elif [[ -r /etc/os-release ]]; then
		local id id_like
		id="$(awk -F= '$1=="ID"{gsub(/"/,"",$2); print tolower($2)}' /etc/os-release)"
		id_like="$(awk -F= '$1=="ID_LIKE"{gsub(/"/,"",$2); print tolower($2)}' /etc/os-release)"
		case " ${id} ${id_like} " in
			*" debian "*|*" ubuntu "*) OS_FAMILY="deb" ;;
			*" rhel "*|*" fedora "*|*" centos "*) OS_FAMILY="rpm" ;;
			*) OS_FAMILY="deb" ;;   # primary target — default when unknown
		esac
	else
		OS_FAMILY="deb"
	fi
	case "$OS_FAMILY" in deb|rpm) ;; *) die "unsupported OS family: ${OS_FAMILY}" ;; 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).
osf_baseline() {
	case "$OS_FAMILY" in
		deb) dpkg-query -W -f='${Package}\t${Version}\n' 2>/dev/null || true ;;
		rpm) rpm -qa --qf '%{NAME}\t%{EVR}\n' 2>/dev/null || true ;;
	esac
}

# osf_refresh_index — refresh the package index. Returns non-zero instead of
# dying so each CALLER picks the severity: preflight treats a stale mirror as
# non-fatal, stage 0 fails the run on it (self-updating against an index that
# would not refresh is deciding from data known to be stale).
osf_refresh_index() {
	case "$OS_FAMILY" in
		deb) apt-get "${APT_LOCK_OPT[@]}" update -qq >/dev/null 2>&1 || return 1 ;;
		rpm) dnf -q makecache >/dev/null 2>&1 || return 1 ;;
	esac
}

# --- 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 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) apt-get "${APT_LOCK_OPT[@]}" install -y shcp-updater >/dev/null 2>&1 ;;
		rpm) dnf -y upgrade shcp-updater >/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)" ]]
}

# apt_index_age_seconds — seconds since the package index was last refreshed.
# Empty (return 1) when it cannot be determined; callers must treat unknown as
# "do not block", never as "stale".
apt_index_age_seconds() {
	[[ "$OS_FAMILY" == "deb" ]] || return 1
	local cand newest="" now
	for cand in /var/lib/apt/periodic/update-success-stamp /var/lib/apt/lists; do
		[[ -e "$cand" ]] || continue
		newest="$(stat -c %Y "$cand" 2>/dev/null || true)"
		[[ -n "$newest" ]] && break
	done
	[[ -n "$newest" ]] || return 1
	now="$(date +%s)"
	echo $(( now - newest ))
}

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

# osf_security_pkgs — names of upgradable packages whose candidate comes from a
# SECURITY origin (AD-6: the set unattended-upgrades would take). deb parses the
# `apt-get -s 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)
			apt-get -s -o Dpkg::Use-Pty=0 upgrade 2>/dev/null | while IFS= read -r line; do
				[[ "$line" == Inst\ * ]] || continue
				local origin="${line#*\(}"; origin="${origin%\)*}"
				if [[ "$origin" =~ (^|[[:space:],])[A-Za-z0-9._-]*-[Ss]ecurity([[:space:],/:]|$) \
					|| "$origin" =~ [A-Za-z0-9._]-security([[:space:],/]|$) ]]; then
					printf '%s\n' "$(awk '{print $2}' <<<"$line")"
				fi
			done ;;
		rpm) dnf -q --security check-update 2>/dev/null | awk 'NF>=3 && $1 ~ /\./ {sub(/\.[^.]+$/,"",$1); print $1}' || true ;;
	esac
}

# 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`).
osf_apply() {
	[[ $# -gt 0 ]] || return 0
	case "$OS_FAMILY" in
		deb)
			DEBIAN_FRONTEND=noninteractive apt-get -y "${APT_LOCK_OPT[@]}" \
				-o Dpkg::Options::=--force-confdef \
				-o Dpkg::Options::=--force-confold \
				--only-upgrade install "$@" ;;
		rpm) dnf -y update "$@" ;;
	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).
# --no-install-recommends so an additive hardening dep cannot drag in a wider set;
# confdef/confold for osf_apply's reason (a maintainer prompt must never hang a
# timer run). The name is validated by the caller (osf_valid_pkg, SC-002) and passed
# as an array element — never word-split, never eval'd.
osf_pkg_install() {
	local pkg="$1"
	case "$OS_FAMILY" in
		deb) DEBIAN_FRONTEND=noninteractive apt-get -y "${APT_LOCK_OPT[@]}" \
			-o Dpkg::Options::=--force-confdef \
			-o Dpkg::Options::=--force-confold \
			--no-install-recommends install "$pkg" ;;
		rpm) dnf -y install "$pkg" ;;
	esac
}

# --- the rollback direction (UPD-5, §4.5 step 2) -------------------------------
# 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
	case "$OS_FAMILY" in
		# `apt-cache show pkg=ver` exits non-zero when that exact version is in no
		# index — exact, and it needs no output parsing.
		deb) apt-cache show "${pkg}=${ver}" >/dev/null 2>&1 ;;
		# --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).
		rpm) dnf -q list --showduplicates "$pkg" 2>/dev/null \
			| awk -v v="$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"
	osf_valid_pkg "$pkg" && osf_valid_pkg_version "$ver" || return 2
	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.
			DEBIAN_FRONTEND=noninteractive apt-get -y "${APT_LOCK_OPT[@]}" \
				-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.
			if rpm -q "$pkg" >/dev/null 2>&1; then
				dnf -y downgrade "${pkg}-${ver}"
			else
				dnf -y install "${pkg}-${ver}"
			fi ;;
	esac
}

# osf_settle_one <pkg> — bring <pkg> to whatever version the CURRENTLY-enabled
# repos offer, downgrading if the box runs a higher one. 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
# candidate IS that series' current, security-maintained version, and
# --allow-downgrades lets apt move DOWN to it from the new-series version the failed
# run installed. This is the SC-476 "settle to current"
# leg — an inability to reach the *exact* prior version is a warning, not a wedge.
#
# deb-only: a series upgrade rewrites Debian apt paths and REFUSES on EL (SC-249),
# so its rollback counterpart is deb-only too.
osf_settle_one() {
	local pkg="$1"
	osf_valid_pkg "$pkg" || return 2
	case "$OS_FAMILY" in
		deb)
			DEBIAN_FRONTEND=noninteractive apt-get -y "${APT_LOCK_OPT[@]}" \
				-o Dpkg::Options::=--force-confdef \
				-o Dpkg::Options::=--force-confold \
				--allow-downgrades install "$pkg" ;;
		rpm) return 2 ;;
	esac
}

# 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.
osf_remove_one() {
	local pkg="$1"
	osf_valid_pkg "$pkg" || return 2
	case "$OS_FAMILY" in
		deb) DEBIAN_FRONTEND=noninteractive apt-get -y "${APT_LOCK_OPT[@]}" remove "$pkg" ;;
		rpm) dnf -y remove "$pkg" ;;
	esac
}

# 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_reboot_required — "true"/"false" (§4.4). deb: the kernel/libc marker file;
# EL: `dnf needs-restarting -r` (exit 1 ⇒ reboot needed).
osf_reboot_required() {
	case "$OS_FAMILY" in
		deb) [[ -f "${SHCP_UPDATE_REBOOT_FILE:-/var/run/reboot-required}" ]] && echo true || echo false ;;
		rpm) if needs-restarting -r >/dev/null 2>&1; then echo false; else echo true; fi ;;
	esac
}

# 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.
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). Anything
# not here is the operator's own and is only reported, never touched.
SHCP_MANAGED_SERVICES=(apache2 httpd dovecot postfix pdns pdns-recursor rspamd valkey mariadb shcpd shcp-worker)
osf_service_is_managed() {
	local want="$1" s
	for s in "${SHCP_MANAGED_SERVICES[@]}"; do [[ "$s" == "$want" ]] && return 0; done
	return 1
}

# blacklist_pkgs — validated package names the operator has excluded from every
# scope (update.blacklist). A malformed entry is dropped, not fatal.
blacklist_pkgs() {
	[[ -f "$SHCP_UPDATE_DB" ]] || return 0
	local raw
	raw="$(sqlite3 -readonly "$SHCP_UPDATE_DB" \
		"SELECT value FROM system_settings WHERE key = 'update.blacklist';" 2>/dev/null)" || 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.
baseline_json() {
	osf_baseline | jq -R -s '
		split("\n") | map(select(length > 0) | split("\t"))
		| map(select(length == 2)) | map({(.[0]): .[1]}) | add // {}'
}

# 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
	"$src" | while IFS= read -r p; do
		[[ -n "$p" ]] || continue
		if ! osf_valid_pkg "$p"; then
			engine_log "ignoring malformed package name from the package manager: ${p}"
			continue
		fi
		[[ -n "${deny[$p]:-}" ]] && continue
		printf '%s\n' "$p"
	done
}

# 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() {
	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
	return 0
}

# --- 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
#   /var/lib/shcp/panel-var    compatibility symlink for pre-rename releases
# Fresh installs are BORN in this layout (installer); converting a pre-UPD-3 flat
# install is this engine's job, because only here is it journaled and reversible.
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}"
# A caller overriding only PANEL_VAR historically wants one isolated scratch
# tree; inherit that override here so tests/tools never inspect production state.
PANEL_VAR_LEGACY_DIR="${SHCP_UPDATE_PANEL_VAR_LEGACY:-${SHCP_UPDATE_PANEL_VAR:-/var/lib/shcp/panel-var}}"
PANEL_STATE_MIGRATION_MARKER="${SHCP_UPDATE_PANEL_STATE_MIGRATION_MARKER:-/var/lib/shcp/.panel-state-migration-units}"
PANEL_KV_DIR="${SHCP_UPDATE_KV_DIR:-/var/lib/shcp/kv}"
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 across the migrated
# (panel.env) and pre-migration (.env.local) layouts. 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:-3DE2B72158369817363C9377AC582BC7BEBB2645}"
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}"
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))

# --- pre-SC-493 repo-binding auto-heal (SC-564, #787) -
# A box provisioned before SC-493 (repo binding single-writer) carries an
# /etc/apt/sources.list.d/shcp.list -- or /etc/yum.repos.d/shcp.repo + /etc/dnf/vars
# -- whose body predates the canonical single-writer format the shcp-keyring/
# shcp-release bootstrap packages now OWN. Until it is rewritten to that exact
# format, a later key rotation shipped via `apt/dnf upgrade shcp-keyring/-release`
# cannot cleanly adopt the file (deb: signed-by= still points at the retired key
# path; rpm: a body diff reappears as shcp.repo.rpmnew, SC-493). The manual seam
# `shcp-reconcile --only repo-binding` (installer#482) fixes this, but a frozen,
# operator-deletable /root/shcp-installer tree reaches no long-lived box on its own.
# repo_binding_heal runs that same repair UNATTENDED on the update path, from
# stage_apt after the OS package work -- but it is a NEW risk class vs the read-only
# reconcile-config --check (SC-432) and the additive-only --apply barred from
# run_stages (SC-432/SC-480): an unattended DESTRUCTIVE rewrite of repo config.
# So it is doubly gated and fails closed (see repo_binding_heal_enabled +
# repo_binding_rpm_is_clean below), atomic with backup/restore, and idempotent --
# a no-op the moment the on-disk binding already equals the canonical body. The
# rewrite RULES (canonical bodies, the parse gates, the operator-clean gate) are
# re-implemented here rather than sourced because run_stages has no installer tree
# on-box -- the SAME reason crs/wpcli are -- and pinned byte-for-byte to the shared
# fixture tests/fixtures/repo-binding/ that shcp-installer and shcp-build assert
# their own writers against (SC-493). All paths overridable for the test suite.
REPO_BINDING_APT_LIST="${SHCP_UPDATE_RB_APT_LIST:-/etc/apt/sources.list.d/shcp.list}"
REPO_BINDING_APT_SOURCES="${SHCP_UPDATE_RB_APT_SOURCES:-/etc/apt/sources.list.d/shcp.sources}"
REPO_BINDING_APT_NEWKEY="${SHCP_UPDATE_RB_APT_NEWKEY:-/usr/share/keyrings/shcp-archive-keyring.gpg}"
REPO_BINDING_APT_OLDKEY="${SHCP_UPDATE_RB_APT_OLDKEY:-/usr/share/keyrings/shcp-archive.gpg}"
REPO_BINDING_RPM_REPO="${SHCP_UPDATE_RB_RPM_REPO:-/etc/yum.repos.d/shcp.repo}"
REPO_BINDING_RPM_KEY="${SHCP_UPDATE_RB_RPM_KEY:-/etc/pki/rpm-gpg/RPM-GPG-KEY-shcp}"
REPO_BINDING_RPM_VARS="${SHCP_UPDATE_RB_RPM_VARS:-/etc/dnf/vars}"
REPO_BINDING_OS_RELEASE="${SHCP_UPDATE_RB_OS_RELEASE:-/etc/os-release}"
# The apt signed-by= / rpm gpgkey= path the CANONICAL body references -- always the
# real production path (it is what apt/dnf read), never a test-staged one, so the
# rendered body matches the shared fixture byte-for-byte on a box under test too.
REPO_BINDING_CANONICAL_APT_KEYRING="/usr/share/keyrings/shcp-archive-keyring.gpg"
# The SHCP REPO signing key fingerprint (distinct from the manifest RELEASE_FPR):
# the deb heal repoints signed-by= at REPO_BINDING_APT_NEWKEY and deletes the
# retired key, so it first proves the new keyring IS this pinned key. 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).
REPO_BINDING_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.
updater_version_valid() {
	[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
}

# 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() {
	local have="$1" need="$2" hc hb nc nb
	updater_version_valid "$have" && updater_version_valid "$need" || return 1
	IFS=. read -r hc hb <<<"$have"
	IFS=. read -r nc nb <<<"$need"
	if [[ "$hc" != "$nc" ]]; then
		decimal_component_ge "$hc" "$nc"
		return
	fi
	decimal_component_ge "$hb" "$nb"
}
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
}

# 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
	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
}

# Rename the shared state root without ever merging two trees. This runs under
# the updater's process-wide flock, before release_link_var can create the new
# root. The legacy alias keeps already-installed releases usable during rollback.
STATE_ROOT_RESULT='{}'
STATE_ROOT_MIGRATION_TIMERS=(
	shcp-aggregate-daily-stats.timer shcp-analytics-aggregate.timer
	shcp-audit-cleanup.timer shcp-backup-health.timer shcp-backup-run.timer
	shcp-bandwidth-accounting.timer shcp-cron-dispatch.timer
	shcp-disk-usage-update.timer shcp-domain-refresh-suffix-data.timer
	shcp-email-analyze-outbound.timer shcp-email-check-ip-reputation.timer
	shcp-email-rotate-dkim-keys.timer shcp-email-vacation-cleanup.timer
	shcp-files-reap-trash.timer shcp-files-reap-uploads.timer
	shcp-firewall-cleanup-expired-bans.timer shcp-firewall-prune-login-attempts.timer
	shcp-firewall-sync-country-blocks.timer shcp-geoip-update.timer
	shcp-license-validate.timer shcp-linux-accounts-audit.timer
	shcp-mail-health-sample.timer shcp-mail-sender-stats.timer
	shcp-mta-sts-republish.timer shcp-network-check-expired.timer
	shcp-network-cleanup-backups.timer shcp-notifications-cleanup.timer
	shcp-operations-escalate.timer shcp-process-scheduled-deletions.timer
	shcp-quota-enforcement-health.timer shcp-quota-sync-all.timer
	shcp-resource-rollup.timer shcp-resource-sample.timer
	shcp-sessions-cleanup.timer shcp-smarthost-drift.timer
	shcp-ssl-check.timer shcp-ssl-renew.timer
	shcp-transfer-reap-redemptions.timer shcp-update-auto.timer
	shcp-update-check.timer shcp-update-security.timer shcp-watchdog.timer)
STATE_ROOT_MIGRATION_SERVICES=(shcpd.service 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-reboot-detect.service shcp-smarthost-sweep-sources.service
	shcp-quota-check-status.service)
for state_root_timer in "${STATE_ROOT_MIGRATION_TIMERS[@]}"; do
	state_root_service="${state_root_timer%.timer}.service"
	# The current process may itself be one of these updater services. Its flock
	# already prevents a second updater; stopping it here would kill the migration.
	[[ "$state_root_service" == shcp-update-*.service ]] \
		|| STATE_ROOT_MIGRATION_SERVICES+=("$state_root_service")
done
unset state_root_timer state_root_service
STATE_ROOT_MIGRATION_UNITS=("${STATE_ROOT_MIGRATION_TIMERS[@]}" "${STATE_ROOT_MIGRATION_SERVICES[@]}")

state_root_unit_allowed() {
	local want="$1" allowed
	for allowed in "${STATE_ROOT_MIGRATION_UNITS[@]}"; do
		[[ "$want" == "$allowed" ]] && return 0
	done
	return 1
}

state_root_unit_is_live() {
	case "$1" in
		active|activating|reloading|deactivating) return 0 ;;
		*) return 1 ;;
	esac
}

state_root_marker_read() {
	local marker="$PANEL_STATE_MIGRATION_MARKER" unit
	STATE_ROOT_MARKER_UNITS=()
	[[ -e "$marker" || -L "$marker" ]] || return 0
	if [[ -L "$marker" || ! -f "$marker" ]]; then
		engine_log "refusing unexpected state-root migration marker type at ${marker}"
		return 1
	fi
	if [[ "$(stat -c '%u' -- "$marker" 2>/dev/null)" != 0 \
			&& "${SHCP_UPDATE_ALLOW_NONROOT_STATE_MIGRATION:-0}" != 1 ]]; then
		engine_log "refusing non-root-owned state-root migration marker ${marker}"
		return 1
	fi
	if [[ "$(stat -c '%a' -- "$marker" 2>/dev/null)" != 600 ]]; then
		engine_log "refusing state-root migration marker with mode other than 0600: ${marker}"
		return 1
	fi
	while IFS= read -r unit || [[ -n "$unit" ]]; do
		[[ -n "$unit" ]] || continue
		if ! state_root_unit_allowed "$unit"; then
			engine_log "refusing unrecognized unit '${unit}' in ${marker}"
			return 1
		fi
		STATE_ROOT_MARKER_UNITS+=("$unit")
	done < "$marker"
	return 0
}

state_root_marker_write() {
	local marker="$PANEL_STATE_MIGRATION_MARKER" tmp="${PANEL_STATE_MIGRATION_MARKER}.tmp.$$" unit
	mkdir -p "$(dirname "$marker")" || return 1
	rm -f -- "$tmp" || return 1
	: > "$tmp" || return 1
	for unit in "$@"; do printf '%s\n' "$unit" >> "$tmp" || { rm -f "$tmp"; return 1; }; done
	chown root:root "$tmp" 2>/dev/null \
		|| [[ "${SHCP_UPDATE_ALLOW_NONROOT_STATE_MIGRATION:-0}" == 1 ]] \
		|| { rm -f "$tmp"; return 1; }
	chmod 0600 "$tmp" || { rm -f "$tmp"; return 1; }
	mv -T -- "$tmp" "$marker" || { rm -f "$tmp"; return 1; }
}

state_root_restore_marked_units() {
	local unit rc=0
	for unit in "${STATE_ROOT_MARKER_UNITS[@]}"; do
		systemctl start "$unit" >/dev/null 2>&1 || rc=1
	done
	if (( rc != 0 )); then
		engine_log "state-root migration: marker retained because unit restoration failed"
		return 1
	fi
	rm -f -- "$PANEL_STATE_MIGRATION_MARKER" || return 1
	return 0
}

state_root_failpoint() {
	[[ "${SHCP_UPDATE_STATE_MIGRATION_FAILPOINT:-}" == "$1" ]] || return 0
	kill -KILL "$BASHPID"
}

state_root_selinux_prepare() {
	command -v semanage >/dev/null 2>&1 || return 0
	local pattern="${PANEL_VAR_DIR}(/.*)?"
	semanage fcontext -a -t httpd_sys_rw_content_t "$pattern" >/dev/null 2>&1 \
		|| semanage fcontext -m -t httpd_sys_rw_content_t "$pattern" >/dev/null 2>&1 \
		|| { engine_log "could not install SELinux context for ${PANEL_VAR_DIR}"; return 1; }
}

state_root_selinux_finish() {
	command -v semanage >/dev/null 2>&1 || return 0
	command -v restorecon >/dev/null 2>&1 || {
		engine_log "semanage is present but restorecon is unavailable"
		return 1
	}
	restorecon -R "$PANEL_VAR_DIR" >/dev/null 2>&1 || {
		engine_log "could not relabel ${PANEL_VAR_DIR}"
		return 1
	}
	# The exact legacy rule may not exist on older hosts. Its absence is already
	# the desired result; a stale rule is harmless while the root-owned alias
	# remains, but remove it whenever semanage can do so.
	semanage fcontext -d "${PANEL_VAR_LEGACY_DIR}(/.*)?" >/dev/null 2>&1 || true
}

state_root_apparmor_prepare() {
	local profile="${SHCP_UPDATE_SHCPD_APPARMOR_PROFILE:-/etc/apparmor.d/shcpd}"
	[[ -f "$profile" ]] || return 0
	grep -Fq "  ${PANEL_VAR_DIR}/ rw," "$profile" \
		&& grep -Fq "  ${PANEL_VAR_DIR}/** rwk," "$profile" || {
		engine_log "installed shcpd AppArmor profile does not grant ${PANEL_VAR_DIR}"
		return 1
	}
	command -v apparmor_parser >/dev/null 2>&1 || {
		engine_log "installed shcpd AppArmor profile cannot be reloaded: apparmor_parser unavailable"
		return 1
	}
	apparmor_parser -r "$profile" >/dev/null 2>&1 || {
		engine_log "could not reload shcpd AppArmor profile"
		return 1
	}
}

state_root_migrate() {
	local old="$PANEL_VAR_LEGACY_DIR" new="$PANEL_VAR_DIR" old_target=""
	# Existing test/development callers historically override PANEL_VAR to a
	# single scratch path. Equal explicit paths mean the rename seam is disabled.
	[[ "$old" != "$new" ]] || { STATE_ROOT_RESULT='{"migrated": false, "reason": "same configured path"}'; return 0; }
	if (( EUID != 0 )) && [[ "${SHCP_UPDATE_ALLOW_NONROOT_STATE_MIGRATION:-0}" != 1 ]]; then
		engine_log "state-root migration requires root"
		STATE_ROOT_RESULT='{"migrated": false, "reason": "root required"}'
		return 1
	fi
	local -a STATE_ROOT_MARKER_UNITS=()
	state_root_marker_read || { STATE_ROOT_RESULT='{"migrated": false, "reason": "invalid recovery marker"}'; return 1; }
	local recovering=0
	[[ -e "$PANEL_STATE_MIGRATION_MARKER" ]] && recovering=1

	if [[ -L "$old" ]]; then
		old_target="$(readlink "$old" 2>/dev/null || true)"
		if [[ "$old_target" == "$new" && -d "$new" && ! -L "$new" \
				&& ( "$(stat -c '%u' -- "$old" 2>/dev/null)" == 0 \
					|| "${SHCP_UPDATE_ALLOW_NONROOT_STATE_MIGRATION:-0}" == 1 ) ]]; then
			if ! state_root_selinux_prepare || ! state_root_apparmor_prepare \
					|| ! state_root_selinux_finish; then
				STATE_ROOT_RESULT='{"migrated": true, "reason": "MAC policy recovery failed"}'
				return 1
			fi
			if (( recovering )) && ! state_root_restore_marked_units; then
				STATE_ROOT_RESULT='{"migrated": true, "reason": "unit restoration failed"}'
				return 1
			fi
			STATE_ROOT_RESULT='{"migrated": false, "reason": "already migrated"}'
			return 0
		fi
		engine_log "refusing unexpected legacy state symlink ${old} -> ${old_target:-unreadable}"
		STATE_ROOT_RESULT='{"migrated": false, "reason": "unexpected legacy symlink"}'
		return 1
	fi
	if [[ -L "$new" ]]; then
		engine_log "refusing symlink at canonical state root ${new}"
		STATE_ROOT_RESULT='{"migrated": false, "reason": "canonical path is symlink"}'
		return 1
	fi
	if [[ -e "$old" && ! -d "$old" ]]; then
		STATE_ROOT_RESULT='{"migrated": false, "reason": "legacy path has unexpected type"}'
		return 1
	fi
	if [[ -e "$new" && ! -d "$new" ]]; then
		STATE_ROOT_RESULT='{"migrated": false, "reason": "canonical path has unexpected type"}'
		return 1
	fi
	if [[ -d "$old" && -d "$new" ]]; then
		engine_log "refusing to merge legacy and canonical state trees"
		STATE_ROOT_RESULT='{"migrated": false, "reason": "both state trees exist"}'
		return 1
	fi

	if [[ ! -e "$old" ]]; then
		[[ -d "$new" ]] || { STATE_ROOT_RESULT='{"migrated": false, "reason": "no state root found"}'; return 0; }
		state_root_selinux_prepare || {
			STATE_ROOT_RESULT='{"migrated": false, "reason": "SELinux policy update failed"}'
			return 1
		}
		state_root_apparmor_prepare || {
			STATE_ROOT_RESULT='{"migrated": false, "reason": "AppArmor policy update failed"}'
			return 1
		}
		ln -s "$new" "$old" || return 1
		chown -h root:root "$old" 2>/dev/null || true
		state_root_failpoint after-alias
		if ! state_root_selinux_finish; then
			rm -f -- "$old" || true
			STATE_ROOT_RESULT='{"migrated": false, "reason": "SELinux relabel failed; alias removed"}'
			return 1
		fi
		if (( recovering )) && ! state_root_restore_marked_units; then
			STATE_ROOT_RESULT='{"migrated": true, "alias_created": true, "reason": "unit restoration failed"}'
			return 1
		fi
		STATE_ROOT_RESULT='{"migrated": false, "alias_created": true, "reason": "canonical tree already present"}'
		return 0
	fi

	local old_dev new_parent_dev unit state
	old_dev="$(stat -c '%d' -- "$old" 2>/dev/null)" || return 1
	mkdir -p "$(dirname "$new")" || return 1
	new_parent_dev="$(stat -c '%d' -- "$(dirname "$new")" 2>/dev/null)" || return 1
	if [[ "$old_dev" != "$new_parent_dev" ]]; then
		STATE_ROOT_RESULT='{"migrated": false, "reason": "different filesystems"}'
		return 1
	fi
	state_root_selinux_prepare || {
		STATE_ROOT_RESULT='{"migrated": false, "reason": "SELinux policy update failed"}'
		return 1
	}
	state_root_apparmor_prepare || {
		STATE_ROOT_RESULT='{"migrated": false, "reason": "AppArmor policy update failed"}'
		return 1
	}

	local -a active_units=()
	if (( recovering )); then
		active_units=("${STATE_ROOT_MARKER_UNITS[@]}")
		# A kill can land between stopping a timer and recording the service it
		# launched. Re-quiesce the complete fixed set before retrying the rename;
		# only marker-listed units are restarted afterwards.
		for unit in "${STATE_ROOT_MIGRATION_UNITS[@]}"; do
			if ! systemctl stop "$unit" >/dev/null 2>&1; then
				STATE_ROOT_RESULT='{"migrated": false, "reason": "could not re-quiesce units"}'
				return 1
			fi
		done
	elif command -v systemctl >/dev/null 2>&1; then
		for unit in "${STATE_ROOT_MIGRATION_UNITS[@]}"; do
			state="$(systemctl is-active "$unit" 2>/dev/null || true)"
			state_root_unit_is_live "$state" && active_units+=("$unit")
		done
		state_root_marker_write "${active_units[@]}" || {
			STATE_ROOT_RESULT='{"migrated": false, "reason": "could not write recovery marker"}'
			return 1
		}
		STATE_ROOT_MARKER_UNITS=("${active_units[@]}")
		# Timers go first so none can launch a fresh console oneshot after the
		# service snapshot. Then re-scan every corresponding service and extend
		# the durable restoration marker before stopping those services.
		for unit in "${active_units[@]}"; do
			[[ "$unit" == *.timer ]] || continue
			if ! systemctl stop "$unit" >/dev/null 2>&1; then
				state_root_restore_marked_units || true
				STATE_ROOT_RESULT='{"migrated": false, "reason": "could not quiesce units"}'
				return 1
			fi
		done
		local candidate present
		for candidate in "${STATE_ROOT_MIGRATION_SERVICES[@]}"; do
			state="$(systemctl is-active "$candidate" 2>/dev/null || true)"
			state_root_unit_is_live "$state" || continue
			present=0
			for unit in "${active_units[@]}"; do [[ "$unit" == "$candidate" ]] && present=1; done
			(( present )) || active_units+=("$candidate")
		done
		state_root_marker_write "${active_units[@]}" || {
			state_root_restore_marked_units || true
			STATE_ROOT_RESULT='{"migrated": false, "reason": "could not update recovery marker"}'
			return 1
		}
		STATE_ROOT_MARKER_UNITS=("${active_units[@]}")
		for unit in "${active_units[@]}"; do
			[[ "$unit" == *.service ]] || continue
			if ! systemctl stop "$unit" >/dev/null 2>&1; then
				state_root_restore_marked_units || true
				STATE_ROOT_RESULT='{"migrated": false, "reason": "could not quiesce units"}'
				return 1
			fi
		done
		state_root_failpoint after-stop
	fi

	if ! mv -T -- "$old" "$new"; then
		state_root_restore_marked_units || true
		STATE_ROOT_RESULT='{"migrated": false, "reason": "rename failed"}'
		return 1
	fi
	state_root_failpoint after-mv
	if ! ln -s "$new" "$old"; then
		engine_log "legacy state alias creation failed; rolling rename back"
		mv -T -- "$new" "$old" || engine_log "CRITICAL: could not restore ${old} from ${new}"
		state_root_restore_marked_units || true
		STATE_ROOT_RESULT='{"migrated": false, "reason": "alias creation failed; rename rolled back"}'
		return 1
	fi
	chown -h root:root "$old" 2>/dev/null || true
	state_root_failpoint after-alias
	if ! state_root_selinux_finish; then
		rm -f -- "$old" || true
		mv -T -- "$new" "$old" || engine_log "CRITICAL: could not roll back ${new} after SELinux failure"
		state_root_restore_marked_units || true
		STATE_ROOT_RESULT='{"migrated": false, "reason": "SELinux relabel failed; rename rolled back"}'
		return 1
	fi
	if ! state_root_restore_marked_units; then
		STATE_ROOT_RESULT='{"migrated": true, "reason": "unit restoration failed"}'
		return 1
	fi
	STATE_ROOT_RESULT='{"migrated": true, "alias_created": true}'
	return 0
}

# <release>/.env.local -> the canonical env. A real file there is preserved as
# .premigration rather than deleted: on a flat install it holds the only copy of
# APP_SECRET and the DB credentials.
# Promote a pre-UPD-3 release's REAL .env.local to the canonical location.
# SC-450.
#
# This is UPD-3 task 2's forward-compat clause, and it is the difference between
# an upgraded box keeping its secrets and losing them. A flat, pre-UPD-3 install
# holds every installer-minted secret in <release>/.env.local and has no
# /etc/shcp/panel.env at all. package.sh strips .env.local from the tarball but
# ships `.env`, so a release flipped into place without a promoted env does not
# fail — it comes up on the tarball's DEFAULTS: APP_SECRET becomes the literal
# `!SHCP_APP_SECRET!`, identical on every box that takes this path; the backup
# and smarthost credential keys become placeholders, making already-sealed
# credentials undecryptable; EDGE_PROXY_SECRET empties, which degrades SC-423 to
# dropping every forwarded header so fail2ban and geo see 127.0.0.1. And because
# those defaults let the container build, `bin/console about` succeeds and
# panel_smoke cannot catch any of it.
#
# Copy, not move: the source stays where it is until the flip has happened and
# the run has been judged healthy, so a rollback still finds it. release_prune
# is what eventually removes it, by which point the canonical copy is the one in
# use.
env_promote_to_canonical() {
	local src="$1" dir tmp
	dir="$(dirname "$PANEL_ENV")"
	mkdir -p "$dir" || { engine_log "could not create ${dir}"; return 1; }
	chmod 0755 "$dir" 2>/dev/null || true

	# Written to a temp and renamed: a half-copied env is a box that boots on
	# a truncated secret set, which is worse than one that refuses to flip.
	tmp="${PANEL_ENV}.promoting.$$"
	rm -f "$tmp"
	cp -- "$src" "$tmp" || { rm -f "$tmp"; engine_log "could not copy ${src}"; return 1; }
	chown "$PANEL_ENV_OWNER" "$tmp" 2>/dev/null \
		|| engine_log "could not chown ${PANEL_ENV} to ${PANEL_ENV_OWNER}"
	chmod 0640 "$tmp" 2>/dev/null || true
	mv -T "$tmp" "$PANEL_ENV" || { rm -f "$tmp"; engine_log "could not install ${PANEL_ENV}"; return 1; }
	engine_log "promoted ${src} to ${PANEL_ENV} (pre-UPD-3 layout carried its env inside the release)"
	return 0
}

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"

	# Pre-UPD-3 box: no canonical env, but the release holds a real one. Promote
	# before the link decision below, which would otherwise return 0 having done
	# nothing and leave the box to boot on `.env` defaults.
	if [[ ! -e "$PANEL_ENV" && -f "$conf" && ! -L "$conf" ]]; then
		env_promote_to_canonical "$conf" || 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
	elif [[ -f "$conf" ]]; then
		mv -f "$conf" "${conf}.premigration" || 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
}

# 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)
	local release managed managed_rc=0
	release="$(panel_link_target)"
	if [[ -n "$release" ]]; then
		managed="$(managed_worker_inventory "$release")" || managed_rc=$?
		(( managed_rc == 1 )) && 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) 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
}

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}" ;;
		*) 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) local p; p="$(aux_worker_unit_path "${1%.service}")"; [[ -f "$p" && ! -L "$p" ]] ;;
		*) return 0 ;;
	esac
}
panel_workers_start() {
	local restore_run_id="${1:-}" 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.
	if [[ -n "$restore_run_id" && -f "$UPLOAD_CLEANUP_UNIT_PATH" ]]; then
		local restore_jf
		restore_jf="$(journal_path "$restore_run_id")"
		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
	if [[ -f "${VERIFY_WORKER_UNIT_PATH:-/nonexistent}" ]] \
			&& ! printf '%s\n' "${PANEL_WORKER_SNAPSHOT[@]}" | grep -q '^shcp-verify-worker.service|[^|]*|true$'; 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
		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" ]]
}

# One-time conversion of a pre-UPD-3 flat install, journaled and REVERSIBLE: the
# tree is moved (same filesystem, so rename(2) — atomic and instant), and if the
# symlink cannot be created the move is undone before returning. Idempotent: an
# already-converted host returns immediately.
LAYOUT_RESULT='{}'
layout_migrate() {
	if [[ -L "$PANEL_LINK" ]]; then
		LAYOUT_RESULT='{"migrated": false, "reason": "already blue/green"}'
		return 0
	fi
	if [[ ! -d "$PANEL_LINK" ]]; then
		LAYOUT_RESULT='{"migrated": false, "reason": "no panel install found"}'
		return 0
	fi

	local ver
	ver="$(panel_current_version)"
	if ! ver_valid "$ver"; then
		# Without a version there is no name to give the release dir, and
		# guessing one would produce a tree the manifest can never match.
		engine_log "flat install has no usable VERSION file — cannot name a release dir"
		LAYOUT_RESULT='{"migrated": false, "reason": "no VERSION in flat install"}'
		return 1
	fi

	local dest="${RELEASES_DIR}/${ver}"
	if [[ -e "$dest" ]]; then
		engine_log "release dir ${dest} already exists — refusing to overwrite it"
		LAYOUT_RESULT="$(jq -nc --arg d "$dest" '{migrated: false, reason: ("release dir exists: " + $d)}')"
		return 1
	fi

	mkdir -p "$RELEASES_DIR" || return 1
	chown root:root "$RELEASES_DIR" 2>/dev/null || true
	chmod 755 "$RELEASES_DIR" 2>/dev/null || true
	engine_log "converting flat install to blue/green: ${PANEL_LINK} -> ${dest}"
	if ! mv "$PANEL_LINK" "$dest"; then
		engine_log "layout migration: move failed, nothing changed"
		return 1
	fi
	if ! panel_flip "$dest"; then
		# Undo: put the tree back exactly where it was. Leaving a host with no
		# /opt/shcp at all is the one outcome worse than not migrating.
		engine_log "layout migration: symlink failed, restoring the flat tree"
		mv "$dest" "$PANEL_LINK" || engine_log "CRITICAL: could not restore ${PANEL_LINK} from ${dest}"
		return 1
	fi
	release_link_var "$dest" || engine_log "layout migration: var/ relink incomplete"
	release_link_env "$dest" || engine_log "layout migration: env relink incomplete"
	LAYOUT_RESULT="$(jq -nc --arg v "$ver" --arg d "$dest" \
		'{migrated: true, version: $v, release_dir: $d}')"
	return 0
}

# --- 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. A manifest with NO generated_at is accepted
# ONLY while no floor is stored yet (rollout, before the generator emits the
# field fleet-wide) — once a stamped manifest has been accepted, an unstamped one
# is refused as older-generation. 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 "$stored" ]]; then
		# No floor yet. Seed it from a well-formed stamp; accept a pre-field
		# manifest transitionally so a rollout does not brick update checks.
		if [[ -n "$got" ]]; then
			if ! got_num="$(ts_canon_num "$got")"; then
				engine_log "manifest generated_at '${got}' is malformed — refusing (SC-472)"
				return 1
			fi
			manifest_floor_write "$got"
		fi
		return 0
	fi

	# A floor exists: an unstamped manifest is now older-generation — refuse it.
	if [[ -z "$got" ]]; then
		engine_log "manifest carries no generated_at but a freshness floor (${stored}) is set — 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 ! 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.
manifest_fetch() {
	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
		# NOT "re-run the installer": the installer refuses on an installed box,
		# so that remedy is impossible for exactly the pre-0.0.43 boxes that hit
		# this (the keyring export only landed in the 0.0.43 installer). Name the
		# remedy an operator can actually perform — re-materialise the key the
		# install stub already imported into the root gpg keyring, by its pinned
		# fingerprint. (shcp-updater#30.)
		engine_log "release keyring ${RELEASE_KEYRING} missing — recover it from the root gpg keyring: gpg --export ${RELEASE_FPR} > ${RELEASE_KEYRING} (the install stub imported that fingerprint; re-running the installer refuses on an installed box)"
		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
	# 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
	mv -f "$tmp" "$out" || { rm -f "$tmp" "$sig"; return 1; }
	rm -f "$sig"
	return 0
}

# 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_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
		installed_updater="${SHCP_UPDATE_ENGINE_VERSION:-$(osf_selfupdate_installed)}"
		if ! updater_version_valid "$installed_updater"; then
			engine_log "panel target: installed updater version '${installed_updater:-unknown}' is not a strict contract.build version"
			return 1
		fi
		if ! updater_version_ge "$installed_updater" "$min_updater"; then
			engine_log "panel target: updater ${installed_updater} 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.
artifact_fetch_verify() {
	local url="$1" sig_url="$2" want_sha="$3" dest="$4"
	local sig="${dest}.sig" got_sha mime

	if ! curl -fsSL --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 [[ "$(stat -c %s "$dest")" -gt "$MAX_ARTIFACT_BYTES" ]]; then
		engine_log "artifact exceeds the size cap"
		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
	local status
	status="$(gpgv --keyring "$RELEASE_KEYRING" --status-fd 1 "$sig" "$dest" 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 "$dest" | 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: mime-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.
	mime="$(file --brief --mime-type "$dest" 2>/dev/null || true)"
	case "$mime" in
		application/gzip|application/x-gzip) ;;
		*) engine_log "artifact mime is '${mime}', not gzip (SC-122)"; return 1 ;;
	esac
	# 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.
	if tar -tzf "$dest" 2>/dev/null | grep -qE '^/|(^|/)\.\.(/|$)'; then
		engine_log "artifact contains absolute or traversing members — refusing to extract"
		return 1
	fi
	return 0
}

# 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_cache_rebuild() {
	local dir="$1"
	[[ -x "$SHCPD_BIN" ]] || { engine_log "cache rebuild: ${SHCPD_BIN} not executable"; return 1; }
	# Drop to the panel user 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
	# function untestable rather than safer.
	local -a runas=()
	if [[ "$EUID" -eq 0 ]] && id -u shcp >/dev/null 2>&1; then
		runas=(runuser -u shcp --)
	fi
	rm -rf "${dir}/var/cache/prod"
	if ! ( cd "$dir" && "${runas[@]+"${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" && "${runas[@]+"${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
}

# `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.
panel_smoke() {
	local dir="$1"
	[[ -x "$SHCPD_BIN" ]] || { engine_log "smoke: ${SHCPD_BIN} not executable"; return 1; }
	( cd "$dir" && "$SHCPD_BIN" php-cli "${dir}/bin/console" about --env=prod --no-interaction ) \
		>/dev/null 2>&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. During the SC-520 (panel
# state-dir migration) transition a signed release unit can still declare the
# /etc/shcpd path while a newer installer generated the key under canonical
# /etc/shcp (or the reverse). The daemon then starts against a path that holds no
# key and dies, yet a binaries+units-only redeploy reports 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 or the SC-520 transition
# /etc/shcpd/<comp>.key; reject 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"|"${base}/etc/shcpd/${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: install the host-arch
# binaries + units, HEAL each daemon's unit-declared HMAC key (see above), 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) and its daemon is
# NOT restarted onto a missing/mismatched key.
# protect_root_unit_sources <release-tree> — SC-531 (SC-355).
#
# Legacy defense for release trees created before whole-tree SC-355 ownership.
# Root installs
# three User=root units FROM that tree — shcp-verify-worker (config/system/
# systemd/) and shcp-backup-stream / shcp-file-broker (helpers/systemd/) — on
# this update AND, later, on a rollback that sources from an OLD tree. If the
# panel account can rewrite a unit's ExecStart, or unlink-and-replace it via a
# writable PARENT directory, root enables attacker code as root: a panel-tier
# bug becomes root RCE (SC-355).
#
# Re-assert root ownership on the SOURCE: every directory on the path to a unit,
# up to the already root-owned ${RELEASES_DIR}, plus the unit files. Leaf-only
# is not enough — a shcp-writable ancestor lets the panel account rename an
# intermediate directory and redirect the path root resolves; root-owning the
# whole chain leaves no writable link to swap. Called BEFORE the staging tree is
# promoted, so the protection is carried through `mv -T "$staging" "$final"` and
# the redeploy that follows reads a root-owned source. A rollback target was
# protected the same way when IT was staged, so it stays safe with no migration
# (no-install-base). Family-agnostic: the SOURCE layout is identical on deb and
# rpm. EUID guard: non-root (unit tests / CI) cannot chown to root and a failure
# there is expected; only root can and must succeed.
protect_root_unit_sources() {
	local tree="$1" rc=0 d f
	[[ -n "$tree" && -d "$tree" && ! -L "$tree" ]] || {
		engine_log "root unit source protection: ${tree} is not a directory"; return 1; }
	local -a chain=(
		"$tree"
		"$tree/config" "$tree/config/system" "$tree/config/system/systemd"
		"$tree/helpers" "$tree/helpers/systemd" "$tree/helpers/bin"
	)
	# The helper-binary arch dirs (helpers/bin/<uname -m>/) are named by the tarball,
	# so they join the chain dynamically. SC-531 locks the .service; #70 locks what it
	# execs: shcp-backup-stream/shcp-file-broker/rclone run User=root, so a shcp-writable
	# arch dir (unlink-and-replace) or binary (rewrite in place) is the same root RCE.
	if [[ -d "$tree/helpers/bin" && ! -L "$tree/helpers/bin" ]]; then
		for d in "$tree/helpers/bin"/*/; do
			[[ -d "$d" && ! -L "$d" ]] && chain+=("${d%/}")
		done
	fi
	for d in "${chain[@]}"; do
		[[ -d "$d" && ! -L "$d" ]] || continue
		chown root:root "$d" && chmod 0755 "$d" || rc=1
	done
	# Unit files: root:root 0644 (still world-readable).
	for d in "$tree/config/system/systemd" "$tree/helpers/systemd"; do
		[[ -d "$d" && ! -L "$d" ]] || continue
		for f in "$d"/*; do
			[[ -f "$f" && ! -L "$f" ]] || continue
			chown root:root "$f" && chmod 0644 "$f" || rc=1
		done
	done
	# Helper binaries: root:root 0755 (executables the root units launch, #70).
	if [[ -d "$tree/helpers/bin" && ! -L "$tree/helpers/bin" ]]; then
		for d in "$tree/helpers/bin"/*/; do
			[[ -d "$d" && ! -L "$d" ]] || continue
			for f in "$d"*; do
				[[ -f "$f" && ! -L "$f" ]] || continue
				chown root:root "$f" && chmod 0755 "$f" || rc=1
			done
		done
	fi
	(( EUID == 0 )) || return 0
	[[ "$rc" -eq 0 ]] || engine_log "root unit source protection: could not root-own a unit source under ${tree}"
	return "$rc"
}

helpers_redeploy() {
	local release="$1" arch bin_dir comp unit rc=0 base
	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
		install -m 0755 -o root -g root "${bin_dir}/shcp-${comp}" "${base}/usr/sbin/shcp-${comp}" \
			|| { engine_log "helpers: failed to install shcp-${comp}"; return 1; }
		if [[ -f "${release}/helpers/systemd/shcp-${comp}.service" && ! -L "${release}/helpers/systemd/shcp-${comp}.service" ]]; then
			install -m 0644 -o root -g root "${release}/helpers/systemd/shcp-${comp}.service" \
				"${base}/lib/systemd/system/shcp-${comp}.service" || return 1
		fi
	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
	systemctl daemon-reload >/dev/null 2>&1 || true
	for comp in backup-stream file-broker; do
		unit="$(helper_effective_unit "$comp")" \
			|| { engine_log "helpers: no unit for shcp-${comp}; cannot reconcile key"; rc=1; continue; }
		helper_key_ensure "$comp" "$unit" \
			|| { engine_log "helpers: HMAC key reconciliation for shcp-${comp} failed; not restarting"; rc=1; continue; }
		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"
			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 (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"
}

# The exact pre-#405 unit is accepted only as a migration input. A mixed or
# partly hardened unit is neither canonical nor legacy.
verify_worker_legacy_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"; 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 (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"
}

# Leave release artifacts untouched; generate the hardened /etc unit from a
# closed canonical input or the one closed pre-#405 input.
verify_worker_unit_normalize_for_install() {
	local src="$1" normalized="$2"
	if verify_worker_unit_contract_ok "$src"; then
		cat -- "$src" > "$normalized"
	elif verify_worker_legacy_unit_contract_ok "$src"; then
		awk '
			$0 == "Environment=GODEBUG=cgocheck=0" {
				print; print "NoNewPrivileges=yes"; print "PrivateTmp=yes"; print "ProtectSystem=strict"; print "ReadWritePaths=/opt/shcp/var /var/lib/shcp"; print "ProtectHome=yes"; print "ProtectKernelTunables=yes"; print "ProtectKernelModules=yes"; print "ProtectControlGroups=yes"; print "RestrictSUIDSGID=yes"; print "LockPersonality=yes"; print "RestrictRealtime=yes"; print "RestrictNamespaces=yes"; print "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6"; print "SystemCallArchitectures=native"; print "SystemCallFilter=@system-service"; print "InaccessiblePaths=-/run/shcp"; next
			}
			{ print }
		' "$src" > "$normalized"
	else
		return 1
	fi
	verify_worker_unit_contract_ok "$normalized"
}

verify_worker_unit_hash_for_release() {
	local src normalized hash
	src="$(verify_worker_source "$1")"
	normalized="$(mktemp "${SHCP_UPDATE_STATE_DIR}/verify-worker-unit.XXXXXX")" || return 1
	verify_worker_unit_normalize_for_install "$src" "$normalized" || { rm -f -- "$normalized"; return 1; }
	hash="$(sha256sum "$normalized" | awk '{print $1}')"
	rm -f -- "$normalized"
	printf '%s\n' "$hash"
}


# 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" || verify_worker_legacy_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 normalized expected actual
	[[ "$(verify_worker_release_state "$release")" == verify ]] || return 1
	src="$(verify_worker_source "$release")"
	normalized="$(mktemp "${SHCP_UPDATE_STATE_DIR}/verify-worker-unit.XXXXXX")" || return 1
	verify_worker_unit_normalize_for_install "$src" "$normalized" || { rm -f -- "$normalized"; return 1; }
	expected="$(sha256sum "$normalized" 2>/dev/null | awk '{print $1}')"; [[ -n "$expected" ]] || { rm -f -- "$normalized"; return 1; }
	install -m 0644 -o root -g root "$normalized" "$VERIFY_WORKER_UNIT_PATH" || { rm -f -- "$normalized"; return 1; }
	rm -f -- "$normalized"
	actual="$(sha256sum "$VERIFY_WORKER_UNIT_PATH" 2>/dev/null | awk '{print $1}')"
	[[ "$actual" == "$expected" ]] && verify_worker_unit_contract_ok "$VERIFY_WORKER_UNIT_PATH" || return 1
	if (( EUID == 0 )); then [[ "$(stat -c '%u:%g:%a' "$VERIFY_WORKER_UNIT_PATH")" == 0:0:644 ]] || return 1; fi
	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
	[[ -f "$db" && ! -L "$db" ]] || return 1
	out="$(sqlite3 -batch -bail "$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
)" || return 1
	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
	[[ "$(sqlite3 -readonly "$db" "SELECT COUNT(*) FROM messenger_messages WHERE queue_name='verify';" 2>/dev/null)" == 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}')"
}

verify_worker_target_release_db_ok() {
	local release="$1" state="$2" db="$SHCP_UPDATE_DB" count console
	console="${release}/bin/console"
	[[ -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; }
	( cd "$release" && "$SHCPD_BIN" php-cli "$console" shcp:update:db --env=prod --no-interaction --json ) \
		>/dev/null 2>&1 || { engine_log "verify worker: target shcp:update:db verification failed"; return 1; }
	[[ -f "$db" && ! -L "$db" ]] || { engine_log "verify worker: target database is absent or unsafe"; return 1; }
	[[ "$(db_integrity "$db")" == ok && "$(db_foreign_keys "$db")" == ok ]] \
		|| { engine_log "verify worker: target database integrity/foreign-key proof failed"; return 1; }
	count="$(sqlite3 -readonly "$db" "SELECT COUNT(*) FROM messenger_messages WHERE queue_name='verify';" 2>/dev/null)" || return 1
	[[ "$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" ]]
}

# aux_worker_redeploy <release-dir> <service> <label> — install + daemon-reload +
# ENABLE (never --now, never start) an auxiliary worker unit from the
# panel release tree. ONE implementation shared by the webhook (PAPI-9), mail health
# (SC-408) and account-transfer import (SC-408, shcp-base#479) workers.
#
# Enable-ONLY at the flip, exactly like upload_cleanup_install_for_release, and for
# the same reason: these are root Messenger consumers, and the transfer worker in
# particular chowns tenant files under /home and imports databases. Activating one at
# the flip — BEFORE stage_db runs shcp:update:db — would run new-release code against
# the pre-migration schema, concurrently with the migration. So the unit is installed
# + enabled here but left STOPPED; stage_restart's worker loop (the single post-stage_db
# forward seam) and panel_workers_start (the rollback seam) are the only places a
# consumer is started, after the schema is proven.
#
# A bring-up failure here is FATAL (SC-513), the same posture as verify: the panel
# routes a message class onto this consumer's transport, and stage_health's unit diff
# is a regression vs the pre-flip baseline — it cannot see a unit introduced by the
# same release. stage_panel is in run_stages' [R] set, so `return 1` auto-rolls-back to
# the prior release, which still routes the message to its old, consumered transport.
#
# A release predating the worker legitimately ships no unit (a rollback target): remove
# any stale out-of-tree unit the newer release left under /etc — a panel symlink flip
# alone cannot — and return 0. A missing unit on a rollback target is not a failure, so
# that path stays tolerant. A box without systemd (CI): skip.
aux_worker_redeploy() {
	local release="$1" svc="$2" label="$3"
	local src="${release}/config/system/systemd/${svc}.service"
	local dest="/etc/systemd/system/${svc}.service"

	command -v systemctl >/dev/null 2>&1 || { engine_log "${label}: no systemctl, skipping"; return 0; }

	# Rolling back to a release from before this worker must also undo the newer
	# release's out-of-tree systemd state. The unit lives under /etc, so a panel
	# symlink flip alone cannot remove it. Tolerant — a missing unit is expected here.
	if [[ ! -f "$src" ]]; then
		engine_log "${label}: no unit in ${release}; removing stale unit if present"
		systemctl stop "${svc}.service" >/dev/null 2>&1 \
			|| engine_log "${label}: failed to stop stale unit (tolerant on a rollback target, continuing)"
		systemctl disable "${svc}.service" >/dev/null 2>&1 \
			|| engine_log "${label}: failed to disable stale unit (tolerant on a rollback target, continuing)"
		if ! rm -f -- "$dest"; then
			engine_log "${label}: failed to remove stale ${dest} (tolerant on a rollback target, continuing)"
		fi
		systemctl daemon-reload >/dev/null 2>&1 \
			|| engine_log "${label}: daemon-reload after stale-unit removal failed (tolerant, continuing)"
		return 0
	fi

	# Install + enable only. Any failure is FATAL (see header): return non-zero so
	# stage_panel fails and auto-rolls-back.
	install -m 0644 -o root -g root "$src" "$dest" \
		|| { engine_log "${label}: failed to install ${dest}"; return 1; }
	if (( EUID == 0 )); then
		[[ "$(stat -c '%u:%g:%a' "$dest" 2>/dev/null)" == '0:0:644' ]] \
			|| { engine_log "${label}: installed unit owner/mode is not root:root 0644"; return 1; }
	fi
	systemctl daemon-reload >/dev/null 2>&1 \
		|| { engine_log "${label}: daemon-reload failed"; return 1; }
	# NEVER --now, NEVER start/restart: the activation seam is post-stage_db. Leaving
	# it stopped here is the whole point — a root import/DB consumer must not run
	# against a half-migrated schema.
	systemctl enable "${svc}.service" >/dev/null 2>&1 \
		|| { engine_log "${label}: enable failed"; return 1; }
	[[ "$(unit_state "$svc")" != active ]] \
		|| { engine_log "${label}: unit became active before the post-stage_db restart seam"; return 1; }
	return 0
}

# Thin entry points over aux_worker_redeploy — install + enable (left stopped), FATAL
# on bring-up failure, activated post-stage_db in stage_restart like upload-cleanup:
#   webhook    (PAPI-9)        — outbound webhook delivery.
#   mailhealth (SC-408)        — mail health metric samples.
#   transfer   (SC-408, #479)  — SHMP P1b account-transfer import (ImportAccountMessage):
#     the ROOT, deliberately-UNHARDENED consumer of the `transfer` transport peeled off
#     `privileged` so a multi-hour import never head-of-line-blocks the shared root
#     worker. Same fatal posture as verify (SC-513): the routing change must not outrun
#     the consumer.
webhook_worker_redeploy()    { aux_worker_redeploy "$1" shcp-webhook-worker "webhook worker"; }
mailhealth_worker_redeploy() { aux_worker_redeploy "$1" shcp-mailhealth-worker "mailhealth worker"; }
transfer_worker_redeploy()   { aux_worker_redeploy "$1" shcp-transfer-worker "transfer worker"; }

# 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 ]]; }
license_recover_inventory() {
	local release="$1" decl="${1}/config/system/required-state.json" unit src
	[[ -f "$decl" && ! -L "$decl" ]] || return 2
	jq -e 'type=="object" and ((keys - ["env","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)) and
		([.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: malformed or 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() {
	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
			# installer#691 shipped the pair before shcp-base carried canonical
			# sources. Admit only those exact historical LF bytes, never an arbitrary
			# root-local unit, so such fresh installs can cross this release once.
			case "${unit}:${hash}" in
				shcp-license-recover.service:c513674e12745f98a3f5caf4f38389bf01e6ae394ce36276696c7374c87a27d5|\
				shcp-license-recover.timer:c44447df51a42b1bd17fcc38ce7630309638b347e3bc1e4670aa8ae5003e7c2b) trusted=true ;;
			esac
			[[ "$trusted" == true ]] || { engine_log "license recovery: refusing unmanaged local ${unit}"; 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=$?
	if (( rc == 2 )); then
		local jf; jf="$(journal_path "$CURRENT_RUN_ID")"
		jq -e --arg r "$release" '.panel.previous_release_dir == $r' "$jf" >/dev/null 2>&1 || return 1
		license_recover_restore_snapshot
		return $?
	fi
	(( rc == 0 )) || return 1
	license_recover_snapshot_once || 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"
	license_recover_inventory "$release" || 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() {
	local release="$1" decl="${1}/config/system/required-state.json" unit src
	[[ -f "$decl" && ! -L "$decl" ]] || return 2
	jq -e 'type=="object" and ((keys - ["env","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
		[[ "$unit" =~ ^shcp-[a-z0-9]+(-[a-z0-9]+)*-worker\.service$ ]] \
			|| { engine_log "managed workers: unsafe unit name ${unit}"; return 1; }
		case "$unit" in shcp-worker.service|shcp-backup-worker.service|shcp-verify-worker.service|shcp-upload-cleanup.service) continue ;; esac
		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"
}

# 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=$?
	if (( rc == 2 )); then
		local jf; jf="$(journal_path "$CURRENT_RUN_ID")"
		jq -e --arg r "$release" '.panel.previous_release_dir == $r' "$jf" >/dev/null 2>&1 || return 1
		engine_log "managed workers: legacy rollback target has no inventory"; inventory=""; rc=0
	fi
	(( 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
		systemctl disable "$unit" >/dev/null 2>&1 || return 1
		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"
}

# First activation is post-schema: enable, then start, both fatal (SC-513).
managed_workers_activate() {
	local release="$1" inventory rc unit
	rc=0; inventory="$(managed_worker_inventory "$release")" || rc=$?
	(( rc == 2 )) && return 0
	(( rc == 0 )) || return 1
	while IFS= read -r unit; do
		[[ -n "$unit" ]] || continue
		systemctl enable "$unit" >/dev/null 2>&1 || return 1
		systemctl start "$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
	[[ -f "$db" ]] || { engine_log "upload cleanup: canonical Messenger database ${db} is absent"; return 1; }
	out="$(sqlite3 -batch -bail "$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
)" || { engine_log "upload cleanup: transactional queue reassignment failed"; return 1; }
	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
	[[ "${SHCP_UPDATE_TEST_QUEUE_PROOF_MISMATCH:-0}" == 1 ]] && affected=$((affected + 1))
	expected_da=$((dbefore + sb))
	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
	[[ "$(sqlite3 -readonly "$db" "SELECT COUNT(*) FROM messenger_messages WHERE queue_name='upload_cleanup';" 2>/dev/null)" == 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
}

upload_cleanup_restore_journaled_state() {
	local run_id="$1" jf enabled active
	jf="$(journal_path "$run_id")"
	enabled="$(jq -r '.upload_cleanup.snapshot.enabled // false' "$jf" 2>/dev/null || echo false)"
	active="$(jq -r '.upload_cleanup.snapshot.active // false' "$jf" 2>/dev/null || echo false)"
	if [[ "$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 [[ "$active" == true ]]; then
		systemctl start "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
		[[ "$(unit_state shcp-upload-cleanup)" == active ]] || return 1
	else
		systemctl stop "$UPLOAD_CLEANUP_UNIT" >/dev/null 2>&1 || return 1
		[[ "$(unit_state shcp-upload-cleanup)" != active ]] || return 1
	fi
}

# --- 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
}

# 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.
db_fix_ownership() {
	local db="$1" sidecar
	[[ -e "$db" ]] || return 0
	chown "$DB_OWNER" "$db" 2>/dev/null \
		|| engine_log "could not chown ${db} to ${DB_OWNER}"
	chmod 0640 "$db" 2>/dev/null || true
	for sidecar in "${db}-wal" "${db}-shm" "${db}-journal"; do
		[[ -e "$sidecar" ]] || continue
		chown "$DB_OWNER" "$sidecar" 2>/dev/null || true
		chmod 0640 "$sidecar" 2>/dev/null || true
	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"
	rm -f "${f}-wal" "${f}-shm" "${f}-journal"
}

# 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_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
	# install -d applies -m to the FINAL component only (verified), so the two
	# calls give db-backups 0750 and pre-update 0700 without touching /var/lib.
	local parent="${dir%/*}"
	if [[ -n "$parent" ]]; then
		install -d -m 0750 "$parent" 2>/dev/null || true
		chown "$DB_OWNER" "$parent" 2>/dev/null || true
	fi
	if ! install -d -m 0700 "$dir" 2>/dev/null; then
		DB_SNAPSHOT_WHY="cannot create ${dir}"
		return 1
	fi
	chown "$DB_OWNER" "$dir" 2>/dev/null \
		|| engine_log "snapshot: could not chown ${dir} to ${DB_OWNER}"
	chmod 0700 "$dir" 2>/dev/null || true
	# 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.
db_snapshot_create() {
	local db="$1"
	local dest="$2"
	DB_SNAPSHOT_WHY=""
	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
	# Idempotent, because a resumed run re-enters this stage from scratch:
	# `.backup` replaces its destination outright (verified — a destination
	# preloaded with foreign tables comes out holding exactly the source's), so
	# the previous attempt's file, truncated or not, is simply overwritten. The
	# sidecars go too: they belong to the OLD snapshot's lineage.
	rm -f "$dest"
	db_sidecars_remove "$dest"
	# Create the destination 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 "$dest" 2>/dev/null; then
		DB_SNAPSHOT_WHY="cannot create ${dest}"
		return 1
	fi
	chown "$DB_OWNER" "$dest" 2>/dev/null \
		|| engine_log "snapshot: could not chown ${dest} to ${DB_OWNER}"
	# -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".
	local err
	if ! err="$(sqlite3 -readonly -cmd ".timeout ${DB_BUSY_MS}" "$db" \
			".backup '${dest}'" 2>&1)"; then
		DB_SNAPSHOT_WHY="sqlite3 .backup failed: ${err//$'\n'/ }"
		return 1
	fi
	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
	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 follows the LAYOUT_RESULT pattern: 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.
DB_RESTORE_RESULT='{}'
db_restore_from_snapshot() {
	local snap="$1"
	local db="$2"
	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 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"
				systemctl stop shcpd.service >/dev/null 2>&1 || true
				;;
			*)
				engine_log "db restore: shcpd is ${shcpd_state:-unknown}; not stopping it"
				;;
		esac
	fi

	# 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
		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
		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
	chmod "$mode" "$staged" 2>/dev/null || true
	if [[ -n "$owner" ]]; then
		chown "$owner" "$staged" 2>/dev/null || true
	else
		chown "$DB_OWNER" "$staged" 2>/dev/null || true
	fi
	sync "$staged" 2>/dev/null || true

	# --- 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
		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"
	sync "${db%/*}" 2>/dev/null || true

	# 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.
	# WHOEVER STOPS shcpd OWNS RESTARTING IT — on every path out of here, including
	# success. An explicit `systemctl stop` suppresses Restart=always, so systemd
	# will NOT bring it back. rollback_run DOES honour the reported
	# `.shcpd_restart_required`, but it is not the only caller and a restore has to
	# be safe on its own; leaving the restart to the caller meant a SUCCESSFUL
	# restore took the panel offline indefinitely —
	# strictly worse than the race the stop was added to remove, and it would also
	# have taken away the HTTP surface SC-318 relies on for recovery (`/up`, and
	# the operator's route back once the flag is cleared).
	#
	# Restarted here, AFTER the verification reads, so the fingerprint compares a
	# database nothing else has opened since the swap.
	local shcpd_restarted=false
	if [[ "$shcpd_was_active" == true ]]; then
		if systemctl start shcpd.service >/dev/null 2>&1; then
			shcpd_restarted=true
			engine_log "db restore: shcpd restarted"
		else
			engine_log "db restore: shcpd FAILED TO RESTART — the panel is offline, start it by hand"
		fi
	fi

	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" \
		--argjson verified "$verified" --argjson ok "$restore_ok" \
		--argjson stopped "$shcpd_was_active" --argjson restarted "$shcpd_restarted" \
		'{ok: $ok, restored: true, snapshot: $s, db: $db, integrity: $i,
		  content_matches_snapshot: $verified,
		  shcpd_stopped_for_swap: $stopped,
		  shcpd_restarted: $restarted,
		  shcpd_restart_required: ($stopped and ($restarted | not))}')"
	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
		engine_log "self-update: package index refresh failed"
		return 1
	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"
			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.
		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).
		exec bash "$0" apply --resumed-from-self-update "$CURRENT_RUN_ID"
	fi
	STAGE_RESULT='{"updated": false}'
}

# 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=()

	# (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 — BLOCKER when explicitly invalid; a state that
	# cannot be read is a warning, not a refusal (can't-check != invalid).
	local lic
	lic="${SHCP_UPDATE_LICENSE_STATE:-$(settings_get 'license.state' '')}"
	case "$lic" in
		valid|active|licensed|ok) : ;;
		expired|invalid|suspended|revoked)
			SERIES_PF_BLOCKERS+=("the panel license is '${lic}' — a series upgrade is not permitted until it is valid") ;;
		*)
			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).

	# u-u refusal (UPD-12 migration seam): a running unattended-upgrades holds
	# A second security mechanism is a WARNING here, not a refusal, and the
	# distinction is load-bearing rather than cosmetic.
	#
	# Refusing created a deadlock. run_stages returns on a failed stage ABOVE the
	# convergence block that is the only writer of last_security_success, and the
	# UPD-12 migration verb requires that marker as its proof that the replacement
	# mechanism actually works. So on every host that still has
	# unattended-upgrades — precisely the hosts the migration exists for — the
	# marker could never be written and the verb could never run. The panel made
	# it worse by offering the retire button only while the blocker was present,
	# i.e. only when the verb was guaranteed to refuse.
	#
	# Coexisting is now safe: every lock-taking apt call passes
	# DPkg::Lock::Timeout, so a concurrent unattended-upgrades run makes this one
	# WAIT rather than fail. The condition is still reported — check --blockers
	# surfaces it and the panel still shows it — but it no longer stops the run
	# that has to succeed before the box can be migrated.
	local uu_warning="null"
	if unattended_upgrades_armed; then
		uu_warning='"unattended-upgrades is installed and armed — two security mechanisms are live on this host; retire it (UPD-12)"'
		engine_log "preflight: unattended-upgrades is armed — proceeding (apt calls wait on the lock); retire it with migrate-security-mechanism"
	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

	# 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="$(apt_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"
	fi

	# Record the package baseline (§4.2-1): packages.before drives the diff.
	journal_update "$CURRENT_RUN_ID" '.packages.before = $b' --argjson b "$(baseline_json)"

	# 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 [[ -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=""
	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)"
			[[ -n "$PANEL_CANDIDATE" ]] && WORKSET_EMPTY=0
		elif [[ "$REQ_SCOPE" == "panel" ]]; 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.
			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

	# 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[@]}"}")
	fi

	local candidate_version=""
	if [[ -n "$PANEL_CANDIDATE" ]]; then
		candidate_version="$(jq -r '.version // empty' <<<"$PANEL_CANDIDATE")"
	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" \
		'{blockers: [], workset_empty: $empty,
		  panel_candidate: (if $panel == "" then null else $panel end),
		  warnings: ((if $uu == null then [] else [$uu] end) + $sw),
		  baseline: $baseline}')"
}

# 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=""

	# 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"
	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"

		# 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 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),
		  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() {
	journal_update "$CURRENT_RUN_ID" '.packages.after = $a' --argjson a "$(baseline_json)"
	local changed
	changed="$(journal_changed_json "$CURRENT_RUN_ID")"
	journal_update "$CURRENT_RUN_ID" '.packages.changed = $c' --argjson c "$changed"
	printf '%s' "$changed"
}

# --- 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_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 mechanism is a .repo + dnf which has NO apt-pin
# lock equivalent, so a series run on rpm REFUSES cleanly rather than writing
# Debian paths onto an EL box (EL series upgrade is deferred, v1).
#
# 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 upgrade is deb-only in v1; refusing to rewrite apt paths on an ${OS_FAMILY} host (EL series upgrade deferred)"
		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"
	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}"

	# 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 new_deb
	if [[ -n "$opts" ]]; then
		new_deb="${dtype} ${opts} ${url} ${new_suite} ${comps}"
	else
		new_deb="${dtype} ${url} ${new_suite} ${comps}"
	fi
	{
		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; }

	# SC-029 parse-back on the TMP (live file still untouched): the written deb
	# line must re-tokenize to exactly the intended <codename>-<REQ_SERIES>.
	local 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 < "$tmp"
	if [[ "$written_n" -ne 1 || "$written_suite" != "$new_suite" ]]; then
		rm -f "$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 ! mv -f "$tmp" "$list"; then
		rm -f "$tmp"
		engine_log "series suite rewrite: could not rename ${tmp} into ${list}"
		return 1
	fi
	engine_log "series suite rewrite: ${list} 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
}

# 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 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: EL series upgrade is deferred, so there is no rpm
# suite/pin to revert). 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) 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" ]]; 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" ]] && 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" ]] || 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='{"restored": false, "reason": "could not restore etc/apt/sources.list.d/shcp.list"}'
			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 (SC-249: EL series upgrade is deferred, so a series run never rewrites rpm
# paths and there is nothing to roll back INTO yet — this is the preparedness twin
# that keeps the rpm arm from being an unhandled OS_FAMILY case, not a live path).
# 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.
runtime_dep_allowed() {
	local pkg="$1"
	case "$OS_FAMILY" in
		deb) case "$pkg" in bind9-dnsutils) return 0 ;; esac ;;
		rpm) case "$pkg" in bind-utils) 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 (a release predating #183).
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 (a release predating
# the field), 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() {
	local pin="$1" installed
	[[ -x "$WPCLI_BIN" ]] || return 1
	installed="$("$WPCLI_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
	dir="$(mktemp -d "${SHCP_UPDATE_STATE_DIR}/.wpcli.XXXXXX")" || 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

	# chmod/chown BEFORE the rename so the binary is never briefly present at its
	# final path half-permissioned; mv -f then swings it in (mirrors the installer).
	if ! chmod 0755 "$phar" || ! chown root:root "$phar"; then
		engine_log "wpcli: could not set mode/owner on the verified phar — refusing"
		return 1
	fi
	install -d -m 755 "$(dirname "$WPCLI_BIN")" || return 1
	if ! mv -f "$phar" "$WPCLI_BIN"; then
		engine_log "wpcli: could not install the verified phar to ${WPCLI_BIN}"
		return 1
	fi

	# Parse-back: the thing just installed must run AND be the pinned version.
	if ! wpcli_installed_pin_matches "$pin"; then
		engine_log "wpcli: post-install verification failed — ${WPCLI_BIN} does not report v${pin}"
		return 1
	fi
	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 (a release
# predating the field), 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: a fresh el10
# install written by an installer predating the stamp re-applies once, then the stamp
# it 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.
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"
	if command -v restorecon >/dev/null 2>&1; then
		restorecon -R "$MODSEC_CRS_DIR" >/dev/null 2>&1 || true
	fi
}

# 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
	dir="$(mktemp -d "${SHCP_UPDATE_STATE_DIR}/.crs.XXXXXX")" || 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
	[[ -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
}

# repo_binding_heal_enabled -- the OPT-IN gate. This step is a destructive rewrite
# of repo config, so unlike the always-on crs/wpcli/runtime-dep helpers beside it
# it stays DORMANT until Senternal turns it on in the SIGNED manifest -- the same
# trust-anchored, timing-controllable channel the daemon pins ride (gpgv + pinned
# VALIDSIG + SC-472). A pre-field manifest, an absent manifest, or `enabled` not
# exactly true => off, so the engine ships this ready but inert and the rollout is
# staged after the population has the new keyring/release. SHCP_UPDATE_REPO_BINDING_HEAL
# (1|0) is the test lever and an operator emergency override; it wins over the
# manifest either way. (SC-564)
repo_binding_heal_enabled() {
	case "${SHCP_UPDATE_REPO_BINDING_HEAL:-}" in
		1) return 0 ;;
		0) return 1 ;;
	esac
	local manifest="${RUNS_DIR}/${CURRENT_RUN_ID}/manifest.json"
	[[ -r "$manifest" ]] || return 1
	local on
	on="$(jq -r '.repo_binding_heal.enabled // false' "$manifest" 2>/dev/null || printf 'false')"
	[[ "$on" == "true" ]]
}

# repo_binding_el_major -- the box's EL major (10) from os-release VERSION_ID,
# SHCP_UPDATE_OS_VERSION overriding for tests. Prints the major and returns 0, or
# returns 1 and prints nothing. Mirrors shcp-installer utils.sh derive_el_major so
# an EL 11 addition is a one-line edit in each repo, not a scattered case.
repo_binding_el_major() {
	local v="${SHCP_UPDATE_OS_VERSION:-}"
	if [[ -z "$v" && -r "$REPO_BINDING_OS_RELEASE" ]]; then
		v="$(awk -F= '$1=="VERSION_ID"{gsub(/"/,"",$2); print $2; exit}' "$REPO_BINDING_OS_RELEASE")"
	fi
	case "$v" in
		10|10.*) printf '10'; return 0 ;;
		*)       return 1 ;;
	esac
}

# repo_binding_render_apt <base> <suite> <file> -- write the canonical single-writer
# shcp.list body (shcp-installer write_shcp_apt_binding's twin, pinned to the shared
# fixture shcp.list.expected). The signed-by= keyring is the real production path,
# never the test-staged newkey, so the body is byte-identical on a box under test.
repo_binding_render_apt() {
	local base="$1" suite="$2" file="$3"
	cat > "$file" <<EOF || return 1
# SHCP apt repository
# managed by shcp-keyring (do not edit; remove file to opt out)
# Codename and series are bound at install time.
deb [signed-by=${REPO_BINDING_CANONICAL_APT_KEYRING}] ${base}/apt ${suite} main
EOF
	chmod 644 "$file"
}

# repo_binding_render_rpm <base> <major> <series> <repo_file> <vars_dir> -- write the
# canonical static .repo body + the three dnf vars that carry all host variance
# (shcp-installer write_shcp_rpm_binding's twin, pinned to shcp.repo.expected). The
# vars are written with a trailing newline to match create_secure_file byte-for-byte,
# so an installer-converged box reads as converged here and this is a true no-op.
repo_binding_render_rpm() {
	local base="$1" major="$2" series="$3" repo_file="$4" vars_dir="$5"
	mkdir -p "$vars_dir" || return 1
	printf '%s\n' "$base"   > "${vars_dir}/shcpbase"   || return 1
	printf '%s\n' "$major"  > "${vars_dir}/shcpel"     || return 1
	printf '%s\n' "$series" > "${vars_dir}/shcpseries" || return 1
	chmod 644 "${vars_dir}/shcpbase" "${vars_dir}/shcpel" "${vars_dir}/shcpseries" || return 1
	# Quoted heredoc: $shcpbase/$shcpel/$shcpseries are dnf vars, not shell.
	cat > "$repo_file" <<'RPMEOF' || return 1
[shcp]
name=SHCP Hosting Control Panel
baseurl=$shcpbase/rpm/el$shcpel/$shcpseries/
enabled=1
priority=10
gpgcheck=1
repo_gpgcheck=1
module_hotfixes=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-shcp
RPMEOF
	chmod 644 "$repo_file"
}

# repo_binding_parse_apt_line <deb-line> -- print "<base>\t<suite>" from an existing
# active `deb ...` line (base with a trailing /apt or /debian trimmed), or return 1.
# PURE. Preserves an old box's base+suite so the heal never silently rebinds its
# series (that is the series-upgrade engine's job, SC-249). Twin of shcp-installer
# parse_apt_binding_line; re-applies the URL gate so a mangled line is a refusal.
repo_binding_parse_apt_line() {
	local line="$1"
	line="${line%$'\r'}"
	[[ "$line" =~ ^[[:space:]]*deb[[:space:]] ]] || return 1
	line="$(sed -E 's/\[[^]]*\]//' <<<"$line")"   # drop the [ ... ] option group whole
	local -a f
	read -ra f <<<"$line"
	local url="${f[1]:-}" suite="${f[2]:-}"
	[[ -n "$url" && -n "$suite" ]] || return 1
	[[ "$suite" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || return 1
	local base="${url%/}"
	case "$base" in
		*/apt)    base="${base%/apt}" ;;
		*/debian) base="${base%/debian}" ;;
		*)        return 1 ;;
	esac
	[[ "$base" =~ ^https?://[^[:space:]$]+$ && "$base" != */ ]] || return 1
	printf '%s\t%s\n' "$base" "$suite"
}

# repo_binding_parse_rpm_baseurl <baseurl> -- print "<base>\t<major>\t<series>" from a
# pre-single-writer literal baseurl (`<base>/rpm/el<major>/<series>/`), or return 1.
# PURE. Twin of shcp-installer parse_rpm_baseurl: rejects a $-bearing series (an
# already-var'd value dnf would re-expand) and CRLF via the ^[0-9]+\.[0-9]+$ gate.
repo_binding_parse_rpm_baseurl() {
	local url="$1"
	url="${url%$'\r'}"
	url="${url%/}"
	[[ "$url" == *"/rpm/el"* ]] || return 1
	local base="${url%%/rpm/el*}"
	local tail="${url##*/rpm/el}"
	local major="${tail%%/*}"
	local series="${tail#*/}"
	[[ "$base" =~ ^https?://[^[:space:]$]+$ ]] || return 1
	[[ "$series" =~ ^[0-9]+\.[0-9]+$ ]] || return 1
	printf '%s\t%s\t%s\n' "$base" "$major" "$series"
}

# repo_binding_rpm_is_clean <repo-file> -- the operator-safety GATE. Returns 0 only
# when the .repo is cleanly OURS to heal: exactly one stanza [shcp]; every KEY is a
# managed SHCP key (no operator additions like proxy=/sslverify=, no extra
# [shcp-source]/[shcp-debuginfo] stanza); AND every fixed-value key that is PRESENT
# still carries its canonical VALUE. An operator-customised .repo is left
# byte-untouched -- clobbering a proxy= (or silently flipping an enabled=0 back on)
# is worse than not healing -- and a foreign .repo merely sitting at this path is
# never touched. No marker gate: a pre-SC-493 box predates any marker, so a marker
# gate would skip the very boxes this heal exists to fix (SC-493). It accepts either
# the RPM-GPG-KEY-shcp basename or the shcp-repo.gpg.key URL basename a pre-SC-493
# installer box references, or the heal would skip real boxes.
#
# NAME/VALUE both matter, and only format-invariant values are pinned. A genuine
# pre-SC-493 installer box (shcp-installer test_reconcile_repo_binding.sh) carries
# enabled=1 priority=10 gpgcheck=1 repo_gpgcheck=1 module_hotfixes=1, a single
# gpgkey, a VARYING name= (old verbose form) and a LITERAL baseurl -- so format
# staleness only ever OMITS a key or reformats name/baseurl, never changes a
# fixed-value key. Therefore: a MISSING fixed-value key is staleness (healed by
# adding it); a PRESENT one whose value diverges from canonical is operator INTENT
# (fail closed). name and baseurl are the two values the heal legitimately rewrites,
# so their values are unchecked here. gpgkey must be exactly ONE managed entry: a
# same-line second key path (`gpgkey=file://...RPM-GPG-KEY-shcp https://evil/x.gpg`)
# would be silently dropped on rewrite, so >1 whitespace-split entry => not clean.
#
# All three repo-binding writers use this same value-aware contract (SC-493,
# SC-564).  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") { }
				else if (parts[1] !~ /^https?:\/\/[^\/?#[:space:]]+(\/[^?#[:space:]]*)*\/shcp-repo[.]gpg[.]key$/) 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"
}

# repo_binding_keyring_trusted <newkey> <oldkey> -- is the new apt keyring safe to
# repoint signed-by= at (and the old key safe to delete)? 0 iff it is byte-equal to
# the retired key OR its primary fingerprint is the pinned SHCP repo key. Twin of
# shcp-installer _keyring_is_trusted. Fails closed when gpg is unavailable.
repo_binding_keyring_trusted() {
	local newkey="$1" oldkey="$2"
	[[ -e "$oldkey" ]] && cmp -s "$newkey" "$oldkey" && return 0
	command -v gpg >/dev/null 2>&1 || return 1
	local gpg_home fpr
	gpg_home="$(mktemp -d)" || return 1
	fpr="$(gpg --homedir "$gpg_home" --no-default-keyring --keyring "$newkey" \
			--with-colons --fingerprint 2>/dev/null \
			| awk -F: '/^fpr:/ {print $10; exit}')"
	rm -rf "$gpg_home"
	[[ -n "$fpr" && "$fpr" == "$REPO_BINDING_GPG_FPR" ]]
}

# repo_binding_heal_deb -- re-implements shcp-reconcile's _reconcile_repo_binding_deb
# on the update path. Preserves base+suite, rewrites shcp.list to the canonical body,
# retires the old key -- atomic (stage in the target dir, back up, mv, validate,
# restore on failure). Echoes a one-line detail on a rewrite (caller journals it);
# returns 0 on rewrite, 2 on a fail-closed no-op (converged/absent/unsafe), 1 on a
# genuine failure. NEVER exits (best-effort, like crs_ensure_pin).
repo_binding_heal_deb() {
	local list="$REPO_BINDING_APT_LIST" newkey="$REPO_BINDING_APT_NEWKEY"
	local oldkey="$REPO_BINDING_APT_OLDKEY" sources="$REPO_BINDING_APT_SOURCES"

	if [[ ! -f "$list" ]]; then
		if [[ -e "$sources" ]]; then
			engine_log "repo binding: ${sources} (deb822) present but this heal only handles one-line shcp.list -- skipping (reinstall to migrate)"
			return 2
		fi
		return 2   # no shcp.list -- SHCP apt repo not configured here
	fi

	local active
	active="$(grep -E '^[[:space:]]*deb[[:space:]]' "$list" | head -n1 || true)"
	if [[ -z "$active" ]]; then
		engine_log "repo binding: ${list} has no active deb line -- skipping (fail-closed)"
		return 2
	fi
	local parsed base suite
	if ! parsed="$(repo_binding_parse_apt_line "$active")"; then
		engine_log "repo binding: cannot parse the deb line in ${list} -- skipping (fail-closed)"
		return 2
	fi
	base="${parsed%%$'\t'*}"; suite="${parsed#*$'\t'}"

	local staged
	staged="$(mktemp "$(dirname "$list")/.shcp-rb.XXXXXX")" || { engine_log 'repo binding: mktemp failed'; return 1; }
	if ! repo_binding_render_apt "$base" "$suite" "$staged"; then
		rm -f "$staged"; engine_log 'repo binding: failed to render canonical shcp.list'; return 1
	fi

	# Converged iff the live file already equals the canonical body AND the retired
	# key is gone. Byte compare, not a marker grep.
	if [[ ! -e "$oldkey" ]] && cmp -s "$staged" "$list"; then
		rm -f "$staged"; return 2
	fi

	if [[ ! -s "$newkey" ]]; then
		rm -f "$staged"
		engine_log "repo binding: ${newkey} missing -- cannot repoint trust; leaving ${list} as-is (fail-closed)"
		return 2
	fi
	if ! repo_binding_keyring_trusted "$newkey" "$oldkey"; then
		rm -f "$staged"
		engine_log "repo binding: ${newkey} is not the pinned SHCP key and differs from ${oldkey} -- refusing to repoint trust (fail-closed)"
		return 2
	fi

	local backup="${list}.pre-heal.$$"
	if ! cp -p "$list" "$backup"; then
		rm -f "$staged"
		engine_log "repo binding: cannot back up ${list} before rewrite -- leaving it untouched"
		return 1
	fi
	chmod 644 "$staged"
	if ! mv -f "$staged" "$list"; then
		rm -f "$staged"; mv -f "$backup" "$list" 2>/dev/null
		engine_log "repo binding: failed to install new ${list} (restored original)"
		return 1
	fi
	if ! grep -q '^# managed by shcp-keyring' "$list" \
	   || ! grep -qF "signed-by=${REPO_BINDING_CANONICAL_APT_KEYRING}" "$list"; then
		mv -f "$backup" "$list" 2>/dev/null
		engine_log "repo binding: post-write validation failed on ${list} (restored original)"
		return 1
	fi
	rm -f "$backup"
	rm -f "$oldkey"
	printf 'deb: rewrote %s to shcp-keyring format (base %s, suite %s)' "$list" "$base" "$suite"
	return 0
}

# repo_binding_heal_rpm -- re-implements shcp-reconcile's _reconcile_repo_binding_rpm
# on the update path, GATED by repo_binding_rpm_is_clean (operator config is never
# clobbered). Preserves base+series, re-derives the box major (refusing an in-place
# EL major mismatch -- beyond this cleanup), rewrites the .repo + dnf vars atomically
# (vars durable first: the .repo references them and a missing var aborts every dnf
# transaction). Same echo/return contract as the deb half; NEVER exits.
repo_binding_heal_rpm() {
	local repo="$REPO_BINDING_RPM_REPO" keyfile="$REPO_BINDING_RPM_KEY"
	local vars_dir="$REPO_BINDING_RPM_VARS"

	[[ -f "$repo" ]] || return 2   # no shcp.repo -- SHCP dnf repo not configured here

	if ! repo_binding_rpm_is_clean "$repo"; then
		engine_log "repo binding: ${repo} is operator-customized (proxy=, extra stanza, or a non-managed key) -- not healing (fail-closed)"
		return 2
	fi

	local -a litter=()
	local f
	for f in "${repo}.rpmnew" "${repo}.rpmsave" "${repo}.rpmorig"; do
		[[ -e "$f" ]] && litter+=("$f")
	done

	local box_major
	if ! box_major="$(repo_binding_el_major)"; then
		engine_log "repo binding: cannot derive EL major from ${REPO_BINDING_OS_RELEASE}/SHCP_UPDATE_OS_VERSION -- skipping (fail-closed)"
		return 2
	fi

	local base="" series=""
	[[ -s "${vars_dir}/shcpseries" ]] && series="$(head -n1 "${vars_dir}/shcpseries" | tr -d '\r')"
	[[ -s "${vars_dir}/shcpbase" ]]   && base="$(head -n1 "${vars_dir}/shcpbase"   | tr -d '\r')"
	# baseurl ONLY from inside the [shcp] stanza -- a hand-added stanza's baseurl
	# would otherwise rebind the box to the wrong series.
	local cur_baseurl
	cur_baseurl="$(awk -F= '
		/^[[:space:]]*\[/ { in_shcp = ($0 ~ /^[[:space:]]*\[shcp\][[:space:]]*$/) }
		in_shcp && tolower($0) ~ /^[[:space:]]*baseurl[[:space:]]*=/ {
			sub(/^[^=]*=/,""); gsub(/^[[:space:]]+|[[:space:]]+$/,""); print; exit
		}' "$repo")"
	if [[ -n "$cur_baseurl" ]]; then
		local rparsed pbase pmajor pseries
		if rparsed="$(repo_binding_parse_rpm_baseurl "$cur_baseurl")"; then
			pbase="${rparsed%%$'\t'*}"; rparsed="${rparsed#*$'\t'}"
			pmajor="${rparsed%%$'\t'*}"; pseries="${rparsed#*$'\t'}"
			[[ -z "$base" ]]   && base="$pbase"
			[[ -z "$series" ]] && series="$pseries"
			if [[ "$pmajor" =~ ^[0-9]+$ && "$pmajor" != "$box_major" ]]; then
				engine_log "repo binding: box is el${box_major} but ${repo} baseurl says el${pmajor} -- an in-place EL upgrade is beyond this cleanup; skipping (fail-closed)"
				return 2
			fi
		fi
	fi
	if [[ -z "$series" || ! "$series" =~ ^[0-9]+\.[0-9]+$ ]]; then
		engine_log "repo binding: cannot determine the bound series for ${repo} -- skipping (fail-closed)"
		return 2
	fi
	if [[ -z "$base" || ! "$base" =~ ^https?://[^[:space:]$]+$ ]]; then
		engine_log "repo binding: cannot determine the repo base URL for ${repo} -- skipping (fail-closed)"
		return 2
	fi

	local scratch staged_repo staged_vars
	scratch="$(mktemp -d)" || { engine_log 'repo binding: mktemp -d failed'; return 1; }
	staged_repo="${scratch}/shcp.repo"; staged_vars="${scratch}/vars"; mkdir -p "$staged_vars"
	if ! repo_binding_render_rpm "$base" "$box_major" "$series" "$staged_repo" "$staged_vars"; then
		rm -rf "$scratch"; engine_log 'repo binding: failed to render canonical shcp.repo'; return 1
	fi

	local converged=1
	cmp -s "$staged_repo" "$repo" || converged=0
	for f in shcpbase shcpel shcpseries; do
		cmp -s "${staged_vars}/${f}" "${vars_dir}/${f}" 2>/dev/null || converged=0
	done
	[[ ${#litter[@]} -eq 0 ]] || converged=0
	if [[ $converged -eq 1 ]]; then
		rm -rf "$scratch"; return 2
	fi

	if [[ ! -s "$keyfile" ]]; then
		rm -rf "$scratch"
		engine_log "repo binding: ${keyfile} missing -- cannot heal ${repo}; leaving it as-is (fail-closed)"
		return 2
	fi

	local backup="${repo}.pre-heal.$$"
	if ! cp -p "$repo" "$backup"; then
		rm -rf "$scratch"
		engine_log "repo binding: cannot back up ${repo} before rewrite -- leaving it untouched"
		return 1
	fi
	local staged_final
	staged_final="$(mktemp "$(dirname "$repo")/.shcp-rb.XXXXXX")" \
		|| { rm -f "$backup"; rm -rf "$scratch"; engine_log 'repo binding: mktemp failed'; return 1; }
	# Vars durable first (the .repo references them), then the .repo atomically.
	if ! repo_binding_render_rpm "$base" "$box_major" "$series" "$staged_final" "$vars_dir"; then
		rm -f "$staged_final" "$backup"; rm -rf "$scratch"
		engine_log "repo binding: failed to write ${vars_dir} / staged .repo"; return 1
	fi
	chmod 644 "$staged_final"
	if ! mv -f "$staged_final" "$repo"; then
		rm -f "$staged_final"; mv -f "$backup" "$repo" 2>/dev/null; rm -rf "$scratch"
		engine_log "repo binding: failed to install new ${repo} (restored original)"
		return 1
	fi
	if ! grep -q '^gpgkey=file://' "$repo"; then
		mv -f "$backup" "$repo" 2>/dev/null; rm -rf "$scratch"
		engine_log "repo binding: post-write validation failed on ${repo} (restored original)"
		return 1
	fi
	rm -f "$backup"; rm -rf "$scratch"
	for f in "${litter[@]}"; do rm -f "$f"; done
	local msg
	msg="$(printf 'rpm: rewrote %s + dnf vars (el%s/%s)' "$repo" "$box_major" "$series")"
	[[ ${#litter[@]} -gt 0 ]] && msg="${msg}; removed ${litter[*]}"
	printf '%s' "$msg"
	return 0
}

# repo_binding_heal -- the gated post-upgrade repair. Called from stage_apt beside
# crs_ensure_pin. Two gates, both fail-closed: (1) repo_binding_heal_enabled -- the
# signed-manifest opt-in, off by default; (2) the per-family operator-safety gate
# inside the deb/rpm halves. A rewrite is journaled to .packages.repo_binding
# (never .packages.changed, so a later release rollback leaves the healed binding
# in place -- a repo-format fix is not part of the release's package transaction); a
# genuine failure to .packages.repo_binding_shortfall. A gate-declined or converged
# no-op journals nothing. ALWAYS returns 0: a repo-binding shortfall must never fail
# the stage / arm auto_rollback -- that would roll back an otherwise-good upgrade over
# a best-effort repair (same posture as crs_ensure_pin). (SC-564)
repo_binding_heal() {
	repo_binding_heal_enabled || return 0
	command -v jq >/dev/null 2>&1 || return 0
	# errexit-safe rc capture: the engine runs under set -euo pipefail, so a bare
	# detail="$(f)" would abort the whole run when f returns non-zero (a no-op or a
	# shortfall). Testing it in an if suspends errexit and preserves the 0/1/2 code.
	local detail="" rc=0
	case "${OS_FAMILY:-}" in
		deb) if detail="$(repo_binding_heal_deb)"; then rc=0; else rc=$?; fi ;;
		rpm) if detail="$(repo_binding_heal_rpm)"; then rc=0; else rc=$?; fi ;;
		*)   engine_log "repo binding: unknown OS_FAMILY '${OS_FAMILY:-}' -- skipping"; return 0 ;;
	esac
	case "$rc" in
		0)  engine_log "repo binding: ${detail}"
			journal_update "$CURRENT_RUN_ID" '.packages.repo_binding = $v' --arg v "$detail" ;;
		2)  : ;;   # fail-closed no-op (converged / not configured / unsafe) -- already logged if noteworthy
		*)  engine_log "repo binding: heal FAILED (non-fatal) -- the binding stays as-is until the next successful update"
			journal_update "$CURRENT_RUN_ID" '.packages.repo_binding_shortfall = $v' --arg v "heal failed (rc=${rc})" ;;
	esac
	return 0
}

# 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() {
	# Series-run hook: rewrite the apt suite + re-render the daemon pins for the
	# target series before any package work. No-op for a routine run.
	if [[ "$SERIES_RUN" == "1" ]]; then
		if ! series_rewrite_suite || ! series_rerender_pins; then
			stage_error apt "series apt suite/pin rewrite failed"
			return 1
		fi
		# Latch that THIS run rewrote the suite + pins. A rollback reads this one
		# boolean to decide it must revert them 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 both writes succeed, so it is never set for
		# a run whose rewrite failed.
		journal_update "$CURRENT_RUN_ID" '.series = ((.series // {}) + {suite_rewritten: true, target: $s})' \
			--arg s "$REQ_SERIES"
		# 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 appears — deliver it before the panel restarts.
		# No apt_record_diff runs on this path, so the dep never enters
		# .packages.changed and a later rollback leaves it (SC-543).
		runtime_deps_ensure
		# A new panel release is also exactly when a bumped wp-cli pin arrives; the
		# manifest carries it, so re-pin here too. Same journaling rationale (kept
		# out of .packages.changed) and same non-fatal posture as the dep delivery.
		wpcli_ensure_pin
		# A bumped OWASP CRS pin arrives the same way; re-apply the digest-verified
		# ruleset on el10 boxes (no-op elsewhere). Same posture as wpcli (#304, SC-485).
		crs_ensure_pin
		# Pre-SC-493 repo-binding auto-heal (SC-564, #787):
		# gated + fail-closed + idempotent; never fails the stage.
		repo_binding_heal
		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).
		local changelog=""
		for p in "${targets[@]}"; do
			changelog+="$(osf_changelog "$p")"$'\n'
		done
		if [[ -n "${changelog//[$'\n' ]/}" ]]; 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

		if ! osf_apply "${targets[@]}"; 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
			stage_error apt "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)"

	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 runtime-dep delivery runs LAST — after apt_record_diff has snapshotted
	# .packages.changed — so an installed dep is journaled to .packages.deps_added and
	# never joins the rollback ledger (SC-543). Never
	# fails the stage.
	runtime_deps_ensure
	# wp-cli currency, same posture: journaled to .packages.wpcli (never
	# .packages.changed), best-effort, never fails the stage (SC-078/SC-089).
	wpcli_ensure_pin
	# OWASP CRS ruleset currency, same posture: journaled to .packages.crs (never
	# .packages.changed), best-effort, never fails the stage (#304, SC-485). On
	# deb the CRS is a distro package the osf_apply above already refreshed, so
	# crs_ensure_pin no-ops there (MODSEC_CRS_DIR is absent).
	crs_ensure_pin
	# Pre-SC-493 repo-binding auto-heal (SC-564, #787):
	# gated + fail-closed + idempotent; never fails the stage / arms auto_rollback.
	repo_binding_heal
}
# 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:
#   layout migrate → 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() {
	# This must precede layout_migrate/release_link_var: either can create the
	# canonical directory and turn an old-only host into an ambiguous dual tree.
	if ! state_root_migrate; then
		engine_log "panel: state-root migration failed"
		STAGE_RESULT="$STATE_ROOT_RESULT"
		return 1
	fi
	journal_update "$CURRENT_RUN_ID" '.state_root_migration = $m' --argjson m "$STATE_ROOT_RESULT"
	# Convert a pre-UPD-3 flat install first. Journaled and reversible, and
	# idempotent afterwards — this is the only place that conversion happens.
	if ! layout_migrate; then
		engine_log "panel: layout migration failed"
		STAGE_RESULT="$LAYOUT_RESULT"
		return 1
	fi
	if [[ "$(jq -r '.migrated // false' <<<"$LAYOUT_RESULT")" == "true" ]]; then
		journal_update "$CURRENT_RUN_ID" '.layout_migration = $m' --argjson m "$LAYOUT_RESULT"
	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
			STAGE_RESULT="$(jq -nc --arg c "$current" \
				'{skipped: "preflight found no eligible panel release", 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.
				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 (~line 5073): a helper
				# that will not restart is health's problem, not a rollback trigger.
				helpers_redeploy "$intent_dir" || 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}')"
				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}"

	local dl_dir="${RUNS_DIR}/${CURRENT_RUN_ID}/download"
	mkdir -p "$dl_dir" || return 1
	local tarball="${dl_dir}/shcp-base-${version}.tar.gz"
	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" || return 1
	rm -rf "$staging"
	mkdir -p "$staging" || return 1
	# --no-same-owner: the archive's recorded uids mean nothing here, and honouring
	# them would scatter foreign ownership through the release tree.
	if ! tar xzf "$tarball" -C "$staging" --no-same-owner; then
		engine_log "panel: extraction failed"
		rm -rf "$staging"
		STAGE_RESULT='{"failed": "extraction failed"}'
		return 1
	fi
	local target_receivers
	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.
	#
	# release_link_env's promotion covers the pre-UPD-3 path (layout_migrate ran
	# first and the old flat tree carried a real .env.local). It cannot cover a
	# box that is ALREADY blue/green yet has no canonical env — layout_migrate
	# returns early there, and the staged tree has no .env.local of its own
	# because package.sh strips it. Without this gate that box flips 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_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 || {
		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.
	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
		STAGE_RESULT='{"failed": "could not quiesce every shared Messenger consumer"}'
		return 1
	fi
	# Every failure path below MUST call panel_workers_start before returning
	# (see the note on that function).

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

	if [[ -e "$final" && "$final" != "$previous" ]]; then
		rm -rf "$final"
	fi
	if ! mv -T "$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_workers_start || true
		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_workers_start || true
		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_workers_start || true
		STAGE_RESULT="$(jq -nc --arg v "$version" '{failed: "release failed its post-rebuild smoke test", version: $v}')"
		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_workers_start || true
		STAGE_RESULT='{"failed": "symlink flip failed"}'
		return 1
	fi

	helpers_redeploy "$final" || 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 aux_worker_redeploy).
	# 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"
	return 0
}

# 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
	}

	# 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)"

	local out="${RUNS_DIR}/${CURRENT_RUN_ID}/db-update.log"
	engine_log "db: ${strategy} via ${console}"
	local rc=0
	( cd "$release" && "$SHCPD_BIN" php-cli "$console" shcp:update:db \
		--env=prod --no-interaction ) >"$out" 2>&1 || rc=$?
	chmod 0600 "$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="$(head -c "$MAX_DB_LOG_BYTES" "$out" 2>/dev/null \
		| tr -cd '\11\12\15\40-\176' || true)"

	if [[ $rc -ne 0 ]]; then
		local why
		why="$(tail -n 5 "$out" 2>/dev/null | 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

	# 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

	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)}')"
	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.
	local override_reconcile
	if [[ $ovr_rc -ne 0 ]]; 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 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 ]]; 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.
		local why
		why="$(tail -n 5 "$out" 2>/dev/null | tr -cd '\11\12\40-\176' \
			| tr '\n' ' ' || true)"
		if [[ $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_rc -ne 0 ]]; 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" ]]
}

# 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"
}

# 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 mariadb 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
	managed_release="$(jq -r '.panel.release_dir // empty' "$jf" 2>/dev/null || true)"
	[[ -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)
				# A package/security-only run can execute the new engine while the
				# old panel release predates these assets. Do not block security
				# patching; a run that actually installed a new panel MUST carry them.
				if jq -e '.panel != null' "$jf" >/dev/null 2>&1; then
					engine_log "restart: new panel release lacks auth-log protection assets"
					STAGE_RESULT='{"failed":"new panel release lacks auth-log protection assets"}'
					return 1
				fi
				authlog_status='release-predates-assets'
				engine_log 'restart: current panel predates SC-050 auth-log assets; security/package work continues'
				;;
			*)
				engine_log 'restart: auth-log protection did not converge'
				STAGE_RESULT='{"failed":"auth-log protection did not converge"}'
				return 1
				;;
		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')"

	# 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.
	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"
	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=() needs_manual=()
	local svc
	while IFS= read -r svc; do
		svc="${svc%.service}"
		[[ -n "$svc" ]] || continue
		if ! osf_service_is_managed "$svc"; then
			needs_manual+=("$svc")   # operator's own service — report, don't touch
			continue
		fi
		# 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")
			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 [[ "$svc" == "mariadb" && "$allow_db" != "true" ]]; then
			needs_manual+=("$svc")   # DB restart withheld by policy
			continue
		fi
		if systemctl restart "${svc}.service" >/dev/null 2>&1; then
			restarted+=("$svc")
		else
			needs_manual+=("$svc")   # tried and failed — surface it
		fi
	done < <(osf_restart_services)

	# §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 systemctl restart shcpd.service >/dev/null 2>&1; then restarted+=("shcpd"); else needs_manual+=("shcpd"); fi
		fi
	fi

	# §4.2-6 "start workers". stage_panel and stage_db both STOP them, and a
	# deliberate `systemctl stop` is not undone by Restart=always — nothing else
	# in the engine, in systemd, or in the unit files brings them back. The
	# needrestart pass above cannot: it names running processes, and a stopped
	# unit has none. Without this the run finishes healthy, the maintenance flag
	# clears, and the panel serves with BOTH Messenger consumers dead — so every
	# async and privileged message (Linux users, vhosts, certbot, DNS) queues
	# forever with nothing to drain it, and nothing reports a problem.
	# Unconditional and idempotent: starting an already-running unit is a no-op,
	# and this stage is the one place that knows the window is over.
	local wsvc
	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
	for wsvc in shcp-worker shcp-backup-worker shcp-upload-cleanup; do
		case "$wsvc" in
			shcp-upload-cleanup) [[ -f "$UPLOAD_CLEANUP_UNIT_PATH" ]] || continue ;;
		esac
		if systemctl start "${wsvc}.service" >/dev/null 2>&1; then
			restarted+=("$wsvc")
		else
			needs_manual+=("$wsvc")
			engine_log "restart: ${wsvc} FAILED to start — the queues will not drain"
		fi
	done

	local manual_json restarted_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))')"

	journal_update "$CURRENT_RUN_ID" '.restart_required = $s' --argjson s "$manual_json"
	STAGE_RESULT="$(jq -nc --argjson reboot "$reboot" \
		--argjson restarted "$restarted_json" --argjson manual "$manual_json" \
		--arg authlog "$authlog_status" \
		'{reboot_required: $reboot, restarted: $restarted, needs_manual_restart: $manual,
		  authlog_protection: $authlog}')"
}
# --- 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() {
	printf '%s\n' "${SHCP_MANAGED_SERVICES[@]}" shcp-backup-worker shcp-verify-worker shcp-webhook-worker shcp-mailhealth-worker shcp-transfer-worker
	local release managed managed_rc=0
	release="$(panel_link_target)"
	if [[ -n "$release" ]]; then
		managed="$(managed_worker_inventory "$release")" || managed_rc=$?
		(( managed_rc == 1 )) && 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
	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=""
health_http_probe() {
	HEALTH_HTTP_DETAIL=""
	local url
	url="$(panel_health_url)"
	if [[ -z "$url" ]]; then
		HEALTH_HTTP_DETAIL="skipped: health URL is empty"
		return 2
	fi
	if ! command -v curl >/dev/null 2>&1; then
		HEALTH_HTTP_DETAIL="skipped: no curl"
		return 2
	fi
	local body code rc=0
	if ! body="$(mktemp)"; then
		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
	if [[ "$code" == "404" ]]; then
		# shcpd answered, so the panel IS serving — it simply has no /up route.
		# That is every panel older than UPD-0, i.e. exactly the upgrade path
		# this engine exists to perform. Rolling those back would be absurd.
		rm -f "$body"
		HEALTH_HTTP_DETAIL="skipped: ${url} answered 404 — this panel has no /up route (pre-UPD-0)"
		return 2
	fi
	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
	if ( cd "$PANEL_LINK" && run_bounded "$HEALTH_CONSOLE_TIMEOUT" \
			"$SHCPD_BIN" php-cli "$console" about --env=prod --no-interaction ) \
			>/dev/null 2>&1; then
		HEALTH_CONSOLE_DETAIL="bin/console about exited 0"
		return 0
	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_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
	out="$( ( cd "$PANEL_LINK" && run_bounded "$HEALTH_CONSOLE_TIMEOUT" \
		"$SHCPD_BIN" php-cli "$console" shcp:update:health \
		--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
		HEALTH_PANEL_DETAIL="shcp:update:health exited 0"
		return 0
	fi
	if [[ -z "$parsed" ]]; then
		# APPARATUS ABSENT, not a verdict. UpdateHealthCommand emits its JSON
		# object on BOTH verdicts, so a non-zero exit with nothing parseable on
		# stdout means the command was not there to run: every panel tree older
		# than UPD-0 throws CommandNotFoundException, prints to stderr (which we
		# discard) and exits 1. Calling that `unhealthy` would auto-roll-back
		# every upgrade FROM a pre-UPD-0 panel — i.e. exactly the upgrades this
		# engine exists to perform — and would report a mechanically perfect
		# rollback TO a pre-UPD-0 release as partial. Same reasoning as the 404
		# case in health_http_probe.
		HEALTH_PANEL_DETAIL="skipped: shcp:update:health exited ${prc} with no report — this panel has no such command (pre-UPD-0)"
		return 2
	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 rc=0
	health_http_probe || rc=$?
	case $rc in 0) http_up=true ;; 1) http_up=false ;; *) http_up=null ;; esac
	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
	jq -nc --argjson units "$units" --arg v "$(panel_current_version)" \
		--argjson up "$http_up" --argjson console "$console_ok" \
		--argjson panel "$panel_ok" --arg now "$(now_utc)" \
		'{units: $units,
		  panel_version: (if $v == "" then null else $v end),
		  http_up: $up, console_ok: $console, panel_health_ok: $panel,
		  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
		health_check_add "$name" skip \
			"${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"
}

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

health_check_add() {   # <name> <true|false|skip> <detail>
	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)) ;;
		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_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

	# 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

	# 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"
		result="$(jq -nc --arg s "$forced" --arg now "$(now_utc)" \
			'{status: $s, attempts: 0, checked_at: $now, forced: true,
			  checks_performed: 0, checks_skipped: 0, checks: [], advisories: [],
			  panel_report: null}')"
	else
		HEALTH_STATUS="unhealthy"
		local a
		for (( a = 1; a <= HEALTH_ATTEMPTS; a++ )); do
			attempts=$a
			health_checks_once
			if [[ "$HEALTH_ALL_OK" == true ]]; then
				HEALTH_STATUS="healthy"
				break
			fi
			HEALTH_STATUS="unhealthy"
			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" \
				'{status: $s, attempts: $a, checked_at: $now,
				  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
		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
}

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

	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
}

# 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
	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
		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.
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
	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)"
	# 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
	snapshot="$(snapshot_path_of_run "$run_id")"
	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.suite_rewritten the
	# moment stage_apt rewrote the suite+pins, so this reads one boolean rather than
	# re-deriving the series relationship (REQ_SERIES is not populated in the manual
	# verb). 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 [[ "$(jq -r '.series.suite_rewritten // false' "$jf" 2>/dev/null || echo false)" == "true" ]]; 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=()
	# 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
	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
	local rollback_source_has_cleanup=false
	if { [[ -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 is dormant per SC-249 — a series run refuses on EL, so series_rollback is
	# never true there today — but the arm is wired so it is correct the day it isn't.)
	local apt_reverted=null apt_revert_json=null
	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"
				series_rollback_restore_rpm "$series_ctar" && revert_ok=true
				revert_json="$SERIES_RPM_REVERT_RESULT" ;;
		esac
		apt_revert_json="$revert_json"
		if [[ "$revert_ok" == true ]]; then
			apt_reverted=true
			# 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

	# --- step 2: packages ---------------------------------------------------------
	# Errors are HELD, not fatal (§4.5 step 2). osf_apply is never called here.
	local -a pkg_results=()
	local restored=0 total=0
	local entry pkg from ok why
	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")"
		ok=false
		if ! osf_valid_pkg "$pkg"; then
			why="malformed package name in the journal"
		elif [[ -z "$from" ]]; then
			# from: null ⇒ the run INSTALLED it; undo by removing it.
			if osf_remove_one "$pkg"; then
				ok=true; why="removed (the run installed it)"
			else
				why="removal failed"
			fi
		elif ! osf_valid_pkg_version "$from"; then
			why="malformed version in the journal"
		elif ! osf_version_available "$pkg" "$from"; then
			if [[ "$series_rollback" == true ]]; 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.
				if osf_settle_one "$pkg"; then
					ok=true
					why="settled to the reverted series' current version (exact ${from} aged out of the repo)"
					warnings+=("${pkg}: rolled to the reverted series' current version; exact ${from} is no longer in the repo")
				else
					why="exact ${from} aged out and the reverted series' current version could not be installed"
				fi
			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"; then
			ok=true; why="restored ${from}"
		else
			why="the package manager refused the downgrade to ${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}: ${why}")
		else
			engine_log "rollback: ${pkg} NOT restored — ${why}"
			shortfalls+=("${pkg}: ${why}")
		fi
		pkg_results+=("$(jq -nc --arg p "$pkg" --arg w "$from" --argjson ok "$ok" --arg r "$why" \
			'{pkg: $p, want: (if $w == "" then null else $w end), ok: $ok, reason: $r}')")
	done < <(jq -c '.[]' <<<"$changed")

	# --- 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 [[ -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
			db_restored=true
			db_reason="restored and verified 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="$(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"; then
		verify_rollback_ready=false
		shortfalls+=("verify worker: target release schema/shared-table verification failed after panel flip")
	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

	# --- 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 [[ "$db_restored" == "false" || "$verify_rollback_ready" != true || "$cleanup_rollback_ready" != true ]]; then
		engine_log "rollback: workers LEFT STOPPED — the database state is unknown"
		workers_reason="left stopped deliberately: the database state is unknown"
		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-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"; 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
	if [[ "$shcpd_needed" == true ]]; then
		if systemctl restart shcpd.service >/dev/null 2>&1; 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
	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"
	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.
	HEALTH_STATUS="$saved_health_status"
	if [[ "$health_after" != "healthy" ]]; 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
	[[ "$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"
	[[ "$health_after" == "healthy" ]] || outcome="rolled_back_partial"

	local maint_cleared=false
	if [[ "$outcome" == "rolled_back" && "$maint_foreign" != true ]]; then
		maintenance_clear_if_mine "$run_id"
		[[ -f "$MAINT_FLAG" ]] || maint_cleared=true
	elif [[ "$outcome" != "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 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}')"

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

	journal_stage_done "$run_id" "rollback" "$ROLLBACK_RESULT"
	journal_finish "$run_id" "$outcome" "$err"
	ROLLBACK_OUTCOME="$outcome"
	engine_log "rollback of ${run_id}: ${outcome}"

	# §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 every rollback path writes a terminal status.
# 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})"
		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
		ROLLBACK_OUTCOME="failed"
		engine_log "auto-rollback FAILED for ${run_id}"
		journal_finish "$run_id" "failed" "${reason}; the rollback itself failed"
	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

	# 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

	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)"
	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
			continue
		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" ]]; then
			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

		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.
			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 '
					if (.failed? | type) == "string" then .failed
					elif (.blockers? | type) == "array" and (.blockers | length) > 0
						then (.blockers[0] | tostring)
					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.
						journal_stage_start "$run_id" "finalize"
						STAGE_RESULT='{}'
						if stage_finalize; then
							journal_stage_done "$run_id" "finalize" "$STAGE_RESULT"
						else
							journal_stage_done "$run_id" "finalize" '{"failed": true}'
						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.
	if [[ "$terminal" == "healthy" || "$terminal" == "noop" ]]; then
		case "$(jq -r '.scope // empty' "$(journal_path "$run_id")" 2>/dev/null)" in
			security|packages|all)
				journal_update "$run_id" '.last_security_success = $now' --arg now "$(now_utc)" ;;
		esac
	fi

	case "$terminal" in
		noop|healthy) journal_finish "$run_id" "$terminal" ;;
		unhealthy)    journal_finish "$run_id" "unhealthy" \
			"health check reported unhealthy; the run applied nothing that could be rolled back" ;;
		*) : ;;   # 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=$?
	[[ $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 ------------------------------------------------------------------------

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
	: "$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"

	# 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}"
		RESUMED_FROM_SELF_UPDATE=1
		acquire_lock || die "another shcp-update run holds ${SHCP_UPDATE_LOCK}"
		CURRENT_RUN_ID="$resumed_run_id"
		trap on_engine_exit EXIT
		engine_log "resuming run ${resumed_run_id} after self-update hop"
		run_stages "$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"
			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 || die "another shcp-update run holds ${SHCP_UPDATE_LOCK}"

	local run_id
	run_id="$(new_run_id)"
	journal_init "$run_id" "$trigger" "$scope" "$request_json" \
		"$( [[ $reinstall -eq 1 ]] && echo true || echo false )"
	CURRENT_RUN_ID="$run_id"
	trap on_engine_exit EXIT
	engine_log "run ${run_id} started (trigger=${trigger} scope=${scope})"

	run_stages "$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 ))

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

	# Marker FIRST (0600 root; 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.
	if ! jq -nc --arg r "$run_id" --arg s "$pending" --arg now "$now" \
			'{run_id: $r, resume_stage: $s, armed_at: $now}' \
			| atomic_write "$AWAITING_REBOOT_MARKER" 0600; 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"
	journal_update "$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}'
	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" ]]
}

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

	# 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: another run holds ${SHCP_UPDATE_LOCK}; leaving the marker for the next boot and exiting clean"
			return 0
		fi
		die "another shcp-update run holds ${SHCP_UPDATE_LOCK}"
	fi

	CURRENT_RUN_ID="$run_id"
	trap on_engine_exit EXIT

	if [[ $parked -eq 1 ]]; then
		# The marker and the journal live under /var/lib/shcp, which the PANEL
		# user owns (installer creates it shcp:shcp 0750). A compromised panel
		# runtime could rename the root-owned update/ subtree aside and plant a
		# fake marker + journal, turning this boot-time root path into a run over
		# attacker-controlled fields. So trust NOTHING by content: require the
		# marker, the journal, AND the run's directory to be owned by the expected
		# uid and not group/other-writable. A non-root attacker cannot forge a
		# root-owned file, and `stat` (no -L) reports a symlink's OWN ownership, so
		# a planted regular file or symlink is refused on the uid check. Validating
		# the run DIRECTORY too closes the last case — a run dir replaced by a
		# symlink to a root-owned dir (whose real journal.json would otherwise stat
		# root-owned): the symlink dir is attacker-owned and refused. Same
		# SHCP_UPDATE_EXPECT_UID seam the request-file owner check uses.
		local ok=1 marker_run=""
		if [[ -e "$AWAITING_REBOOT_MARKER" ]]; then
			path_uid0_not_writable "$AWAITING_REBOOT_MARKER" || ok=0
			marker_run="$(jq -r '.run_id // empty' "$AWAITING_REBOOT_MARKER" 2>/dev/null || true)"
			[[ "$marker_run" == "$run_id" ]] || ok=0
		fi
		path_uid0_not_writable "${RUNS_DIR}/${run_id}" || ok=0
		path_uid0_not_writable "$(journal_path "$run_id")" || ok=0
		if [[ $ok -ne 1 ]]; then
			engine_log "resume: refusing ${run_id} — the awaiting-reboot marker or its journal is not root-owned or does not match the run (fail closed)"
			# Do not delete a file whose ownership we could not vouch for.
			[[ $at_boot -eq 1 ]] && return 0
			die "awaiting-reboot marker/journal failed validation for run ${run_id}"
		fi

		# 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. Only when
		# BOTH ids are known and equal do we refuse: an empty parked id (a run from
		# before this field) or an unreadable current id is "cannot confirm", 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)"
			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
	run_stages "$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
		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
		st="$(jq -r '.status // empty' "$(journal_path "$d")" 2>/dev/null || true)"
		case "$st" in
			failed|unhealthy|rolled_back_partial)
				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_run_id() {
	[[ -d "$RUNS_DIR" ]] || return 0
	ls -1 "$RUNS_DIR" 2>/dev/null | grep -E '^[0-9]{8}-[0-9]{6}-[0-9a-f]{6}$' \
		| sort | tail -1
}

cmd_check() {
	local blockers_mode=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--blockers) blockers_mode=1; shift ;;
			--json)     JSON_OUTPUT=1; shift ;;
			*) die "check: unknown argument: $1" ;;
		esac
	done
	: "$blockers_mode"  # check currently always emits the blocker view; the
	                    # candidate/pending view arrives with UPD-2.

	# 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

	# 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. Recover it from the root gpg keyring: gpg --export \($f) > \($k) (the install stub imported that fingerprint). Re-running the installer refuses on an installed box.")}')")
	fi

	# 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

	if lock_is_held; then
		blockers+=("$(jq -nc '{id: "lock_held",
			detail: "another shcp-update run holds the lock"}')")
	fi

	local stale
	stale="$(latest_run_id_with_status running)"
	if [[ -n "$stale" ]] && ! lock_is_held; 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_is_held; 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')"
		# `timestamp` is what the engine writes now; `set_at` is the fallback for a
		# flag left by an older engine mid-fleet-upgrade (base#1152).
		m_at="$(jq -r '.timestamp // .set_at // "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 mechanisms on one host (AD-6). Same predicate preflight uses — these
	# two callers used to disagree, and the disagreement WAS the bug: this one
	# tested an APT-owned timer, so the blocker latched forever.
	if unattended_upgrades_armed; then
		blockers+=("$(jq -nc '{id: "unattended_upgrades_active",
			detail: "unattended-upgrades is installed and armed — retire it with `shcp-update migrate-security-mechanism` before engine-managed updates"}')")
	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

	local idx_age
	idx_age="$(apt_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: ("package index last refreshed \($a / 86400 | floor) day(s) ago — pending counts are computed from it")}')")
	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.
	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

	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" \
		'{blockers: $b, advisories: $a}'
}

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
	[[ -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}"

	# 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 || die "another shcp-update run holds ${SHCP_UPDATE_LOCK}"
	CURRENT_RUN_ID="$run_id"
	trap on_engine_exit EXIT
	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

	rollback_run "$run_id" "manual rollback requested" manual

	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}'
	else
		log "rollback of ${run_id}: ${ROLLBACK_OUTCOME}"
	fi
	# 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" ]]
}

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
	local 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 \
			&& 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"
			;;
		status|"")
			if [[ -f "$MAINT_FLAG" ]]; then
				cat "$MAINT_FLAG"
			else
				jq -n '{maintenance: false}'
			fi
			;;
	esac
}

# --- UPD-12: retiring unattended-upgrades ----------------------------------------
# SC-431 is the spec; this is the implementation.
#
# WHY A STANDALONE VERB AND NOT A STAGE. Three separate failures, each of which
# was reachable in the code above rather than hypothetical:
#
#   1. A purge inside a run lands in that run's .packages.changed as
#      {"pkg": "unattended-upgrades", "from": "<ver>", "to": null}, and
#      rollback_run's restore direction reads exactly that shape as "the run
#      installed it, put it back". A later auto-rollback would REINSTALL the
#      incumbent — with these units still masked, so the host ends up carrying a
#      package nothing runs and an operator who believes the migration held.
#   2. run_stages short-circuits on an empty work set: stage_preflight sets
#      WORKSET_EMPTY, the runner journals `noop` and skips every post-preflight
#      stage. That is the state of a healthy, fully-patched host — i.e. exactly
#      the hosts that still need migrating would never execute the migration.
#   3. Stage 0 is stage_self_update, which does `exec bash "$0" apply
#      --resumed-from-self-update`. Any work sharing that process is REPLACED
#      mid-flight; the surviving process finishes a different job and exits 0.
#
# So nothing here is reachable from run_stages, STAGES does not name it, and the
# verb takes the engine lock exactly like the other mutating verbs.
MIGRATION_DIR="${SHCP_UPDATE_MIGRATION_DIR:-${SHCP_UPDATE_STATE_DIR}/migrations}"
UU_RECORD="${MIGRATION_DIR}/unattended-upgrades.json"
UU_ARCHIVE="${MIGRATION_DIR}/unattended-upgrades-configs.tar.gz"
UU_CLAIM="${MIGRATION_DIR}/unattended-upgrades-retirement.request.json"
UU_MIGRATION_LOCK="${MIGRATION_DIR}/unattended-upgrades-retirement.lock"
UU_RECORD_SCHEMA=1
UU_CLAIM_SCHEMA=1
UU_MIGRATION_NAME="unattended-upgrades-retirement"
UU_MIGRATION_LOCK_FD=""
UU_CLAIM_UUID=""
UU_CLAIM_PHASE=""

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}$ ]]
}

uu_migration_lock() {
	install -d -m 0700 "$MIGRATION_DIR" || return 1
	[[ -d "$MIGRATION_DIR" && ! -L "$MIGRATION_DIR" ]] || return 1
	[[ "$(stat -c '%u:%a' -- "$MIGRATION_DIR" 2>/dev/null)" == "${EUID}:700" ]] || return 1
	if [[ ! -e "$UU_MIGRATION_LOCK" && ! -L "$UU_MIGRATION_LOCK" ]]; then
		local tmp
		tmp="$(mktemp "${UU_MIGRATION_LOCK}.tmp.XXXXXX")" || return 1
		chmod 0600 "$tmp" || { rm -f -- "$tmp"; return 1; }
		sync -f "$tmp" 2>/dev/null || { rm -f -- "$tmp"; return 1; }
		ln -- "$tmp" "$UU_MIGRATION_LOCK" 2>/dev/null || true
		rm -f -- "$tmp"
		sync -f "$MIGRATION_DIR" 2>/dev/null || return 1
	fi
	[[ -f "$UU_MIGRATION_LOCK" && ! -L "$UU_MIGRATION_LOCK" ]] || return 1
	[[ "$(stat -c '%u:%a:%h' -- "$UU_MIGRATION_LOCK" 2>/dev/null)" == "${EUID}:600:1" ]] || return 1
	local identity identity_fd
	identity="$(stat -c '%d:%i' -- "$UU_MIGRATION_LOCK" 2>/dev/null)" || return 1
	# Read/write without truncation. A contaminated lock path must never become a
	# root-powered arbitrary-file truncation primitive.
	exec {UU_MIGRATION_LOCK_FD}<>"$UU_MIGRATION_LOCK" || return 1
	identity_fd="$(stat -Lc '%d:%i' -- "/proc/$$/fd/${UU_MIGRATION_LOCK_FD}" 2>/dev/null)" || return 1
	[[ "$identity" == "$identity_fd" ]] || return 1
	flock "$UU_MIGRATION_LOCK_FD" || return 1
	[[ "$identity" == "$(stat -c '%d:%i' -- "$UU_MIGRATION_LOCK" 2>/dev/null)" ]] || return 1
}

uu_claim_load() {
	UU_CLAIM_UUID=""
	UU_CLAIM_PHASE=""
	[[ -e "$UU_CLAIM" || -L "$UU_CLAIM" ]] || return 1
	[[ -f "$UU_CLAIM" && ! -L "$UU_CLAIM" ]] || return 2
	local owner mode links identity identity_after doc
	owner="$(stat -c '%u' -- "$UU_CLAIM" 2>/dev/null)" || return 2
	mode="$(stat -c '%a' -- "$UU_CLAIM" 2>/dev/null)" || return 2
	links="$(stat -c '%h' -- "$UU_CLAIM" 2>/dev/null)" || return 2
	[[ "$owner" == "$EUID" && "$mode" == 600 && "$links" == 1 ]] || return 2
	identity="$(stat -c '%d:%i' -- "$UU_CLAIM" 2>/dev/null)" || return 2
	doc="$(cat -- "$UU_CLAIM" 2>/dev/null)" || return 2
	identity_after="$(stat -c '%d:%i' -- "$UU_CLAIM" 2>/dev/null)" || return 2
	[[ "$identity" == "$identity_after" ]] || return 2
	local parsed
	parsed="$(jq -er --argjson s "$UU_CLAIM_SCHEMA" --arg m "$UU_MIGRATION_NAME" '
		select(type == "object") |
		select((keys | sort) == ["command_uuid", "migration", "phase", "schema"]) |
		select(.schema == $s and .migration == $m) |
		select((.command_uuid | type) == "string") |
		select(.phase == "claimed" or .phase == "authorized") |
		[.command_uuid, .phase] | @tsv' <<<"$doc" 2>/dev/null)" || return 2
	IFS=$'\t' read -r UU_CLAIM_UUID UU_CLAIM_PHASE <<<"$parsed"
	command_uuid_valid "$UU_CLAIM_UUID" || { UU_CLAIM_UUID=""; return 2; }
	return 0
}

uu_claim_write_phase() {
	local command_uuid="$1" phase="$2" doc
	command_uuid_valid "$command_uuid" || return 1
	[[ "$phase" == "claimed" || "$phase" == "authorized" ]] || return 1
	doc="$(jq -n --argjson s "$UU_CLAIM_SCHEMA" --arg m "$UU_MIGRATION_NAME" \
		--arg u "$command_uuid" --arg p "$phase" \
		'{schema:$s,migration:$m,command_uuid:$u,phase:$p}')" || return 1
	printf '%s\n' "$doc" | atomic_write "$UU_CLAIM" 0600 || return 1
	[[ -f "$UU_CLAIM" && ! -L "$UU_CLAIM" ]] || return 1
	[[ "$(stat -c '%u:%a:%h' -- "$UU_CLAIM" 2>/dev/null)" == "${EUID}:600:1" ]] || return 1
	sync -f "$UU_CLAIM" 2>/dev/null || return 1
	sync -f "$MIGRATION_DIR" 2>/dev/null || return 1
}

uu_record_uuid() {
	[[ -f "$UU_RECORD" && ! -L "$UU_RECORD" ]] || return 1
	jq -er 'select(type == "object" and .schema == 1 and
		.migration == "unattended-upgrades") | .command_uuid |
		select(type == "string")' "$UU_RECORD" 2>/dev/null
}

uu_record_valid() {
	[[ -f "$UU_RECORD" && ! -L "$UU_RECORD" ]] || return 1
	[[ "$(stat -c '%u:%a:%h' -- "$UU_RECORD" 2>/dev/null)" == "${EUID}:600:1" ]] || return 1
	local command_uuid migrated_at proof_at proof_run normalized_at identity identity_after doc validated_doc
	identity="$(stat -c '%d:%i' -- "$UU_RECORD" 2>/dev/null)" || return 1
	doc="$(cat -- "$UU_RECORD" 2>/dev/null)" || return 1
	identity_after="$(stat -c '%d:%i' -- "$UU_RECORD" 2>/dev/null)" || return 1
	[[ "$identity" == "$identity_after" ]] || return 1
	validated_doc="$(jq -ce 'select(type == "object") |
		select((keys | sort) == (["archive", "host", "masked_units", "migrated_at", "migration", "proof", "purged", "replacement", "schema"] | sort) or
			(keys | sort) == (["archive", "command_uuid", "host", "masked_units", "migrated_at", "migration", "proof", "purged", "replacement", "schema"] | sort)) |
		select(.schema == 1 and .migration == "unattended-upgrades") |
		select((.migrated_at | type) == "string" and (.host | type) == "string" and (.host | length) > 0 and
			(.proof | type) == "object" and (.replacement | type) == "object" and
			(.archive | type) == "object" and (.purged | type) == "array" and
			(.masked_units | type) == "array") |
		select((.proof | keys | sort) == ["last_security_success", "run_id"] and
			(.proof.run_id | type) == "string" and (.proof.run_id | length) > 0 and
			(.proof.last_security_success | type) == "string") |
		select((.replacement | keys) == ["timer"] and (.replacement.timer | type) == "string" and
			(.replacement.timer | length) > 0) |
		select((.archive | keys | sort) == ["files", "path", "sha256", "state"] and
			(.archive.path | type) == "string" and (.archive.path | length) > 0 and
			(.archive.state == "created" or .archive.state == "existing" or .archive.state == "empty") and
			(.archive.sha256 == null or ((.archive.sha256 | type) == "string" and (.archive.sha256 | test("^[0-9a-f]{64}$")))) and
			(.archive.files | type) == "array" and all(.archive.files[]; type == "string" and length > 0)) |
		select(all(.purged[]; type == "object" and (keys | sort) == ["pkg", "version"] and
			(.pkg | type) == "string" and (.pkg | length) > 0 and (.version | type) == "string")) |
		select(all(.masked_units[]; type == "string" and length > 0)) |
		if has("command_uuid") then select((.command_uuid | type) == "string") else . end' <<<"$doc" 2>/dev/null)" || return 1
	command_uuid="$(jq -er '
		if has("command_uuid") then
			.command_uuid | select(type == "string")
		else "" end' <<<"$validated_doc" 2>/dev/null)" || return 1
	[[ -z "$command_uuid" ]] || command_uuid_valid "$command_uuid" || return 1
	proof_run="$(jq -er '.proof.run_id' <<<"$validated_doc" 2>/dev/null)" || return 1
	run_id_valid "$proof_run" || return 1
	migrated_at="$(jq -er '.migrated_at' <<<"$validated_doc" 2>/dev/null)" || return 1
	[[ "$migrated_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || return 1
	normalized_at="$(date -u -d "$migrated_at" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)" || return 1
	[[ "$normalized_at" == "$migrated_at" ]] || return 1
	proof_at="$(jq -er '.proof.last_security_success' <<<"$validated_doc" 2>/dev/null)" || return 1
	[[ "$proof_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || return 1
	normalized_at="$(date -u -d "$proof_at" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)" || return 1
	[[ "$normalized_at" == "$proof_at" ]]
}

cmd_queue_security_migration() {
	local command_uuid=""
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--command-uuid) [[ $# -ge 2 ]] || die "queue-security-migration: --command-uuid requires a value"; command_uuid="$2"; shift 2 ;;
			*) die "queue-security-migration: unknown argument: $1" ;;
		esac
	done
	[[ "$EUID" -eq 0 || "${SHCP_UPDATE_ALLOW_NON_ROOT_TESTS:-0}" == 1 ]] \
		|| die "queue-security-migration: root is required"
	command_uuid_valid "$command_uuid" \
		|| die "queue-security-migration: invalid lowercase command UUID"
	uu_migration_lock || die "queue-security-migration: cannot lock ${UU_MIGRATION_LOCK}"

	local rc=0
	if uu_claim_load; then
		if [[ "$UU_CLAIM_UUID" == "$command_uuid" ]]; then
			jq -n --arg u "$command_uuid" --arg p "$UU_CLAIM_PHASE" \
				'{outcome:"same-owner",command_uuid:$u,phase:$p}'
			return 0
		fi
		die "queue-security-migration: claim belongs to another command"
	else
		rc=$?
		(( rc == 1 )) || die "queue-security-migration: malformed or unsafe claim; operator cleanup required"
	fi

	if [[ -e "$UU_RECORD" || -L "$UU_RECORD" ]]; then
		uu_record_valid || die "queue-security-migration: malformed or unsafe immutable record; operator cleanup required"
		if uu_single_mechanism; then
			jq -n --arg u "$command_uuid" '{outcome:"already-converged",command_uuid:$u}'
			return 0
		fi
		die "queue-security-migration: immutable migration record exists but the host is not converged; operator cleanup required"
	fi

	local tmp doc
	tmp="$(mktemp "${UU_CLAIM}.tmp.XXXXXX")" || die "queue-security-migration: cannot create claim"
	doc="$(jq -n --argjson s "$UU_CLAIM_SCHEMA" --arg m "$UU_MIGRATION_NAME" --arg u "$command_uuid" \
		'{schema:$s,migration:$m,command_uuid:$u,phase:"claimed"}')" || { rm -f -- "$tmp"; die "queue-security-migration: cannot build claim"; }
	printf '%s\n' "$doc" >"$tmp" || { rm -f -- "$tmp"; die "queue-security-migration: cannot write claim"; }
	chmod 0600 "$tmp" || { rm -f -- "$tmp"; die "queue-security-migration: cannot protect claim"; }
	sync -f "$tmp" 2>/dev/null || { rm -f -- "$tmp"; die "queue-security-migration: cannot sync claim"; }
	if ! ln -- "$tmp" "$UU_CLAIM" 2>/dev/null; then
		rm -f -- "$tmp"
		if uu_claim_load && [[ "$UU_CLAIM_UUID" == "$command_uuid" ]]; then
			jq -n --arg u "$command_uuid" --arg p "$UU_CLAIM_PHASE" \
				'{outcome:"same-owner",command_uuid:$u,phase:$p}'
			return 0
		fi
		die "queue-security-migration: conflicting or unsafe claim; operator cleanup required"
	fi
	rm -f -- "$tmp"
	sync -f "$MIGRATION_DIR" 2>/dev/null || die "queue-security-migration: claim installed but directory sync failed"
	jq -n --arg u "$command_uuid" '{outcome:"claimed",command_uuid:$u,phase:"claimed"}'
}

cmd_authorize_security_migration_claim() {
	local command_uuid=""
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--command-uuid) [[ $# -ge 2 ]] || die "authorize-security-migration-claim: --command-uuid requires a value"; command_uuid="$2"; shift 2 ;;
			*) die "authorize-security-migration-claim: unknown argument: $1" ;;
		esac
	done
	[[ "$EUID" -eq 0 || "${SHCP_UPDATE_ALLOW_NON_ROOT_TESTS:-0}" == 1 ]] \
		|| die "authorize-security-migration-claim: root is required"
	command_uuid_valid "$command_uuid" \
		|| die "authorize-security-migration-claim: invalid lowercase command UUID"
	uu_migration_lock || die "authorize-security-migration-claim: cannot lock ${UU_MIGRATION_LOCK}"
	[[ ! -e "$UU_RECORD" && ! -L "$UU_RECORD" ]] \
		|| die "authorize-security-migration-claim: immutable migration record exists"
	local rc=0
	if uu_claim_load; then :; else rc=$?
		(( rc == 1 )) && die "authorize-security-migration-claim: claim is absent"
		die "authorize-security-migration-claim: malformed or unsafe claim; operator cleanup required"
	fi
	[[ "$UU_CLAIM_UUID" == "$command_uuid" ]] \
		|| die "authorize-security-migration-claim: claim belongs to another command"
	if [[ "$UU_CLAIM_PHASE" == "authorized" ]]; then
		jq -n --arg u "$command_uuid" '{outcome:"authorized",command_uuid:$u,phase:"authorized"}'
		return 0
	fi
	[[ "$UU_CLAIM_PHASE" == "claimed" ]] \
		|| die "authorize-security-migration-claim: claim phase is invalid"
	uu_claim_write_phase "$command_uuid" authorized \
		|| die "authorize-security-migration-claim: could not durably authorize claim"
	jq -n --arg u "$command_uuid" '{outcome:"authorized",command_uuid:$u,phase:"authorized"}'
}

cmd_release_security_migration_claim() {
	local command_uuid=""
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--command-uuid) [[ $# -ge 2 ]] || die "release-security-migration-claim: --command-uuid requires a value"; command_uuid="$2"; shift 2 ;;
			*) die "release-security-migration-claim: unknown argument: $1" ;;
		esac
	done
	[[ "$EUID" -eq 0 || "${SHCP_UPDATE_ALLOW_NON_ROOT_TESTS:-0}" == 1 ]] \
		|| die "release-security-migration-claim: root is required"
	command_uuid_valid "$command_uuid" \
		|| die "release-security-migration-claim: invalid lowercase command UUID"
	uu_migration_lock || die "release-security-migration-claim: cannot lock ${UU_MIGRATION_LOCK}"
	[[ ! -e "$UU_RECORD" && ! -L "$UU_RECORD" ]] \
		|| die "release-security-migration-claim: immutable migration record exists"
	local rc=0
	if uu_claim_load; then :; else rc=$?
		if (( rc == 1 )); then
			jq -n --arg u "$command_uuid" '{outcome:"absent",command_uuid:$u}'
			return 0
		fi
		die "release-security-migration-claim: malformed or unsafe claim; operator cleanup required"
	fi
	[[ "$UU_CLAIM_UUID" == "$command_uuid" ]] \
		|| die "release-security-migration-claim: claim belongs to another command"
	[[ "$UU_CLAIM_PHASE" == "claimed" ]] \
		|| die "release-security-migration-claim: authorized claim cannot be released"
	rm -f -- "$UU_CLAIM" || die "release-security-migration-claim: could not remove claim"
	sync -f "$MIGRATION_DIR" 2>/dev/null \
		|| die "release-security-migration-claim: claim removed but directory sync failed"
	jq -n --arg u "$command_uuid" '{outcome:"released",command_uuid:$u}'
}

# The incumbent's packages. apt-listchanges is in the set because the installer
# installs the pair together (shcp-installer functions/packages.sh) and it is on
# the host for one reason: to feed unattended-upgrades its changelogs.
UU_PURGE_PKGS=(unattended-upgrades apt-listchanges)

# Units this verb stops and masks — and the AD-6 line, which matters:
#   unattended-upgrades.service   the incumbent's OWN unit.
#   apt-daily-upgrade.timer       APT's, not the incumbent's, but it is the thing
#                                 that RUNS the incumbent, so it is masked (not
#                                 removed, not reconfigured).
# apt-daily.timer is deliberately NOT here. It refreshes the package index and
# downloads — APT doing APT's job, useful to us — and it belongs to a package we
# are not migrating. Nor does this verb write APT::Periodic: with the timer above
# masked that is dead config, and writing it would contradict the masking (AD-4).
#
# Masking BEFORE the purge is also what makes the mask outlive it: the mask is a
# symlink to /dev/null under /etc/systemd/system, which dpkg does not own, so a
# later `apt install unattended-upgrades` cannot silently re-arm the host.
UU_MASK_UNITS=(unattended-upgrades.service apt-daily-upgrade.timer)

# Config files dpkg does not list as conffiles but the installer writes anyway
# (20auto-upgrades is generated, not shipped). Relative to CONFIG_ROOT, like
# config_tar_create's members — that is what makes the archive step testable
# against a fixture root instead of the live /etc.
UU_EXTRA_CONFIGS=(etc/apt/apt.conf.d/50unattended-upgrades
	etc/apt/apt.conf.d/20auto-upgrades
	etc/apt/listchanges.conf)

# The apt/dpkg locks the quiescence gate probes. All four, because which one is
# held depends on how far the other transaction got.
APT_LOCK_PATHS=()
read -r -a APT_LOCK_PATHS <<<"${SHCP_APT_LOCK_PATHS:-/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock}" || true

SHCP_UPDATE_QUIESCE_TIMEOUT="${SHCP_UPDATE_QUIESCE_TIMEOUT:-300}"
SHCP_UPDATE_QUIESCE_INTERVAL="${SHCP_UPDATE_QUIESCE_INTERVAL:-5}"
# 30 days. A marker from a year ago proves the replacement patched this host
# once, not that it still does; the incumbent must not be removed on that.
SHCP_UPDATE_SECURITY_PROOF_MAX_AGE="${SHCP_UPDATE_SECURITY_PROOF_MAX_AGE:-2592000}"
SHCP_UPDATE_SECURITY_TIMER="${SHCP_UPDATE_SECURITY_TIMER:-shcp-update-security.timer}"

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

pkg_installed_version() {   # deb; empty when absent
	pkg_is_installed "$1" || return 0
	dpkg-query -W -f '${Version}' "$1" 2>/dev/null || true
}

# 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 this host was patched by the replacement", which is
# not the same as "it wasn't" — and the only safe reading of either is to leave
# the incumbent alone.
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
}

# apt_locks_busy — names every apt/dpkg lock currently held, one per line.
#
# THE LOCK, not unit state, and that distinction is the whole point of the check:
# `systemctl stop apt-daily-upgrade.service` returns success while the worker it
# started keeps running (KillMode=process, measured). Purging under that worker
# is how a half-finished dpkg transaction happens.
#
# A lock we cannot OPEN counts as busy. Not being able to look is not the same as
# looking and seeing nothing, and only one of those two readings is safe.
apt_locks_busy() {
	# Ask APT whether it can take its own locks. Do NOT probe with flock(1):
	# apt (apt-pkg/contrib/fileutl.cc) and dpkg (lib/dpkg/lock.c) lock with
	# fcntl(F_SETLK), and flock(2) NOTES is explicit that the two are independent
	# on Linux — so a flock probe ACQUIRES while a real apt transaction holds the
	# file and reports the box quiescent. Measured on a live Debian 13 host:
	# holding lock-frontend with fcntl, `flock -n` returned 0 while
	# `apt-get -o DPkg::Lock::Timeout=0 check` returned 100. With no holder the
	# same command returns 0, so it does not false-positive on an idle box.
	#
	# A blind probe here is not a cosmetic defect: it is the gate that stands
	# between an irreversible purge and a live dpkg transaction.
	if [[ "$OS_FAMILY" != "deb" ]]; then
		return 0
	fi
	if ! command -v apt-get >/dev/null 2>&1; then
		return 0
	fi
	if ! apt-get -o DPkg::Lock::Timeout=0 check >/dev/null 2>&1; then
		printf '%s\n' "apt/dpkg lock held"
	fi
	return 0
}

UU_QUIESCE_BUSY=""
apt_wait_quiescent() {
	local timeout="$SHCP_UPDATE_QUIESCE_TIMEOUT"
	local interval="$SHCP_UPDATE_QUIESCE_INTERVAL"
	# A zero or negative interval would spin forever without ever reaching the
	# bound; the bound is the whole point of this function.
	(( interval > 0 )) || interval=1
	local waited=0 busy
	UU_QUIESCE_BUSY=""
	while :; do
		busy="$(apt_locks_busy | tr '\n' ' ')"
		busy="${busy% }"
		[[ -z "$busy" ]] && return 0
		if (( waited >= timeout )); then
			UU_QUIESCE_BUSY="$busy"
			return 1
		fi
		engine_log "migrate: apt is busy (${busy}) — waiting ${interval}s"
		sleep "$interval"
		waited=$((waited + interval))
	done
}

# uu_config_members — the config set to archive, relative to CONFIG_ROOT.
# DERIVED from dpkg rather than hardcoded, so a conffile a future
# unattended-upgrades adds is archived without anyone remembering this list; the
# generated files dpkg does not track are unioned in on top. Only files that
# exist are emitted, so tar is never handed a missing member.
uu_config_members() {
	local base="${CONFIG_ROOT%/}"
	local pkg p rel
	local -a raw=()
	for pkg in "${UU_PURGE_PKGS[@]}"; do
		while read -r p; do
			[[ -n "$p" ]] && raw+=("${p#/}")
		done < <(dpkg-query -W -f '${Conffiles}\n' "$pkg" 2>/dev/null \
			| tr ' ' '\n' | grep '^/' || true)
	done
	raw+=("${UU_EXTRA_CONFIGS[@]}")
	printf '%s\n' "${raw[@]}" | sort -u | while read -r rel; do
		[[ -n "$rel" && -e "${base}/${rel}" ]] && printf '%s\n' "$rel"
	done
	return 0
}

# uu_archive_configs — write the archive EXACTLY ONCE, and CONTINUE either way.
#
# "Never overwrite" reads like "abort if it exists", and that reading is wrong:
# it turns every interrupted migration into a permanently unrecoverable one,
# which is the opposite of the convergence SC-319 demands. An archive that is
# already there is the earlier pass's, it is the state we wanted preserved, and
# the correct response is to leave it and get on with the rest.
#
# Completeness is expressed by the RENAME, not by a marker file: tar writes to
# <archive>.partial and only a finished tar is renamed into place, so a file at
# $UU_ARCHIVE is by construction complete. A kill mid-tar leaves the .partial,
# which the next pass overwrites.
UU_ARCHIVE_STATE=""
uu_archive_configs() {
	install -d -m 0700 "$MIGRATION_DIR" || return 1
	if [[ -s "$UU_ARCHIVE" ]]; then
		UU_ARCHIVE_STATE="existing"
		return 0
	fi
	local -a members=()
	mapfile -t members < <(uu_config_members)
	if [[ ${#members[@]} -eq 0 ]]; then
		# Nothing of the incumbent's left to preserve — a re-run after a purge, or
		# a host that never had the configs. Not an error, and not a reason to stop.
		UU_ARCHIVE_STATE="empty"
		return 0
	fi
	local base="${CONFIG_ROOT%/}" tmp="${UU_ARCHIVE}.partial" rc=0
	rm -f "$tmp"
	install -m 0600 /dev/null "$tmp" || return 1
	# tar exits 1 for "file changed as we read it", routine against a live /etc;
	# only a fatal error (>1) or an empty result discards the attempt.
	tar czf "$tmp" -C "${base:-/}" "${members[@]}" 2>/dev/null || rc=$?
	if [[ $rc -gt 1 || ! -s "$tmp" ]]; then
		rm -f "$tmp"
		return 1
	fi
	mv -f "$tmp" "$UU_ARCHIVE"
	UU_ARCHIVE_STATE="created"
	return 0
}

uu_mask_units() {
	local unit
	for unit in "${UU_MASK_UNITS[@]}"; do
		# Best-effort by exit code, VERIFIED afterwards by uu_single_mechanism:
		# `stop` on a unit that does not exist on this host is a failure that means
		# nothing, and `mask` reporting success is not evidence the mask took.
		systemctl stop "$unit" >/dev/null 2>&1 || true
		systemctl disable "$unit" >/dev/null 2>&1 || true
		systemctl mask "$unit" >/dev/null 2>&1 || true
	done
	return 0
}

# uu_single_mechanism — is there exactly ONE automatic security mechanism on this
# host, and is it ours? Incumbent gone AND its units masked AND the replacement
# timer in place. Zero mechanisms is the catastrophic outcome this whole
# checkpoint exists to prevent, so "theirs is gone" alone is not the question.
UU_VERIFY_DETAIL=""
uu_single_mechanism() {
	UU_VERIFY_DETAIL=""
	local -a problems=()
	local pkg unit state
	for pkg in "${UU_PURGE_PKGS[@]}"; do
		pkg_is_installed "$pkg" && problems+=("${pkg} is still installed")
	done
	for unit in "${UU_MASK_UNITS[@]}"; do
		# `is-enabled` on a masked unit prints "masked" and EXITS 1 — the exit code
		# is not the answer here, the word is.
		state="$(systemctl is-enabled "$unit" 2>/dev/null || true)"
		[[ "$state" == masked* ]] || problems+=("${unit} is '${state:-unknown}', not masked")
	done
	state="$(systemctl is-enabled "$SHCP_UPDATE_SECURITY_TIMER" 2>/dev/null || true)"
	case "$state" in
		enabled|enabled-runtime|static) ;;
		*) problems+=("${SHCP_UPDATE_SECURITY_TIMER} is '${state:-unknown}' — the replacement is not armed") ;;
	esac
	if [[ ${#problems[@]} -gt 0 ]]; then
		UU_VERIFY_DETAIL="$(printf '%s; ' "${problems[@]}")"
		UU_VERIFY_DETAIL="${UU_VERIFY_DETAIL%; }"
		return 1
	fi
	return 0
}

# uu_write_record — the DURABLE record, written once.
#
# Under ${STATE_DIR}/migrations/, deliberately NOT under runs/: run_prune deletes
# every run dir past the newest 5, so a record kept in the run journal is gone
# within days and the only remaining answer to "why does this box have no
# unattended-upgrades" is archaeology.
uu_write_record() {   # uu_write_record <proof_run> <proof_ts> <purged_json_array> [command_uuid]
	local proof_run="$1" proof_ts="$2" purged="$3" command_uuid="${4:-}"
	install -d -m 0700 "$MIGRATION_DIR" || return 1
	if [[ -e "$UU_RECORD" || -L "$UU_RECORD" ]]; then
		uu_record_valid || return 1
		[[ -z "$command_uuid" || "$(uu_record_uuid || true)" == "$command_uuid" ]] || return 1
		return 0   # write-once: a valid earlier pass already told this story
	fi

	local sha="" files='[]'
	if [[ -s "$UU_ARCHIVE" ]]; then
		sha="$(sha256sum "$UU_ARCHIVE" 2>/dev/null | awk '{print $1}' || true)"
		# Read the member list back OUT of the archive rather than from the
		# variable that built it: on a converging re-run the archive was written by
		# a previous process and that variable is empty.
		files="$(tar tzf "$UU_ARCHIVE" 2>/dev/null | jq -R . | jq -sc . 2>/dev/null)" \
			|| files='[]'
		[[ -n "$files" ]] || files='[]'
	fi
	# Built in full BEFORE anything is written. Piping jq straight into
	# atomic_write would leave a truncated-but-present record when jq failed, and
	# a present record is what the "already migrated" short-circuit keys on — the
	# host would then report itself migrated on the strength of an empty file.
	local doc
	doc="$(jq -n --argjson schema "$UU_RECORD_SCHEMA" \
		--arg at "$(now_utc)" --arg host "$(uname -n 2>/dev/null || echo unknown)" \
		--arg proof_run "$proof_run" --arg proof_ts "$proof_ts" \
		--arg timer "$SHCP_UPDATE_SECURITY_TIMER" \
		--arg archive "$UU_ARCHIVE" --arg sha "$sha" --argjson files "$files" \
		--arg archive_state "$UU_ARCHIVE_STATE" \
		--arg command_uuid "$command_uuid" \
		--argjson purged "$purged" \
		--argjson units "$(printf '%s\n' "${UU_MASK_UNITS[@]}" | jq -R . | jq -sc .)" \
		'{schema: $schema,
		  migration: "unattended-upgrades",
		  migrated_at: $at,
		  host: $host,
		  proof: {run_id: $proof_run, last_security_success: $proof_ts},
		  replacement: {timer: $timer},
		  archive: {path: $archive, state: $archive_state,
		            sha256: (if $sha == "" then null else $sha end), files: $files},
		  purged: $purged,
		  masked_units: $units} |
		  if $command_uuid == "" then . else .command_uuid = $command_uuid end')" || return 1
	[[ -n "$doc" ]] || return 1
	printf '%s\n' "$doc" | atomic_write "$UU_RECORD" || return 1
	chmod 0600 "$UU_RECORD" 2>/dev/null || return 1
	# The claim is removed only after both the record inode and its directory
	# entry survive a crash. Otherwise ACCEPTED could lose its only correlation.
	sync -f "$UU_RECORD" 2>/dev/null || return 1
	sync -f "$MIGRATION_DIR" 2>/dev/null || return 1
	return 0
}

cmd_migrate_security_mechanism() {
	local yes=0
	while [[ $# -gt 0 ]]; do
		case "$1" in
			--yes)  yes=1; shift ;;
			--json) JSON_OUTPUT=1; shift ;;
			*) die "migrate-security-mechanism: unknown argument: $1" ;;
		esac
	done

	# (0) deb ONLY (AD-7). There is no RPM packager for this engine, so an EL host
	# has no replacement to hand the job to and keeps dnf-automatic. Purging the
	# incumbent there would end security patching outright, which is the exact
	# failure the checkpoint is about.
	osf_detect_family
	[[ "$OS_FAMILY" == "deb" ]] || die \
		"migrate-security-mechanism: deb hosts only — this host is ${OS_FAMILY}, where dnf-automatic stays (nothing was changed)"
	command -v dpkg-query >/dev/null 2>&1 \
		|| die "migrate-security-mechanism: dpkg-query not found (nothing was changed)"
	command -v systemctl >/dev/null 2>&1 \
		|| die "migrate-security-mechanism: systemctl not found — the masking cannot be verified (nothing was changed)"

	uu_migration_lock || die "cannot lock ${UU_MIGRATION_LOCK}"
	local claim_rc=0
	if uu_claim_load; then
		[[ "$UU_CLAIM_PHASE" == "authorized" ]] \
			|| die "migrate: queue claim is not authorized — nothing was changed"
		engine_log "migrate: carrying authorized command correlation ${UU_CLAIM_UUID}"
	else
		claim_rc=$?
		(( claim_rc == 1 )) && die "migrate: authorized queue claim is required — nothing was changed"
		die "migrate: malformed or unsafe queue claim; operator cleanup required"
	fi
	acquire_lock || die "another shcp-update run holds ${SHCP_UPDATE_LOCK}"

	# Already migrated? The RECORD ALONE is not the answer. A host where someone
	# re-installed unattended-upgrades since has two mechanisms again, and a
	# record-only check would report success over exactly that. Both, or neither.
	if [[ -e "$UU_RECORD" || -L "$UU_RECORD" ]]; then
		uu_record_valid || die "migrate: malformed or unsafe immutable record; operator cleanup required"
	fi
	if [[ -f "$UU_RECORD" ]] && uu_single_mechanism; then
		local record_uuid=""
		record_uuid="$(uu_record_uuid || true)"
		if [[ -n "$UU_CLAIM_UUID" ]]; then
			[[ "$record_uuid" == "$UU_CLAIM_UUID" ]] \
				|| die "migrate: immutable record does not match the queued command; operator cleanup required"
			rm -f -- "$UU_CLAIM" || die "migrate: could not remove the completed queue claim"
			sync -f "$MIGRATION_DIR" 2>/dev/null || die "migrate: could not durably remove the completed queue claim"
		fi
		log "unattended-upgrades is already retired on this host (${UU_RECORD})"
		if [[ $JSON_OUTPUT -eq 1 ]]; then
			jq -n --arg r "$UU_RECORD" '{outcome: "already-migrated", record: $r}'
		fi
		return 0
	fi

	# (1) PROOF, and NOTHING has been touched yet when it fails. This is the step
	# the natural implementation gets wrong by purging first and checking after —
	# an order that passes any test which only inspects the end state.
	local proof proof_run proof_ts
	proof="$(security_proof_find || true)"
	if [[ -z "$proof" ]]; then
		log "no successful engine security run is recorded on this host."
		log "  The replacement must be shown to have PATCHED before the incumbent is"
		log "  removed, and this verb will not perform that run itself: re-entering the"
		log "  stage machinery hands the migrating process to stage 0's self-update"
		log "  exec, which replaces it and finishes a different job with exit 0."
		log "  Disarm unattended-upgrades first — APT::Periodic::Unattended-Upgrade \"0\""
		log "  in /etc/apt/apt.conf.d/20auto-upgrades — so the engine's preflight stops"
		log "  refusing, let ${SHCP_UPDATE_SECURITY_TIMER} complete one run, then re-run this."
		die "refusing to migrate: no proof the replacement has patched this host — nothing was changed"
	fi
	proof_run="${proof%%$'\t'*}"
	proof_ts="${proof#*$'\t'}"

	if (( SHCP_UPDATE_SECURITY_PROOF_MAX_AGE > 0 )); then
		local proof_epoch now_epoch age
		proof_epoch="$(date -u -d "$proof_ts" +%s 2>/dev/null || true)"
		# An unparsable timestamp is not a pass. We cannot age-check what we cannot
		# read, and the safe reading of "cannot tell" is "do not purge".
		[[ "$proof_epoch" =~ ^[0-9]+$ ]] || die \
			"refusing to migrate: run ${proof_run} has an unreadable last_security_success ('${proof_ts}') — nothing was changed"
		now_epoch="$(date -u +%s)"
		age=$(( now_epoch - proof_epoch ))
		if (( age > SHCP_UPDATE_SECURITY_PROOF_MAX_AGE )); then
			die "refusing to migrate: the newest successful security run (${proof_run}, ${proof_ts}) is $(( age / 86400 )) day(s) old — the replacement patched this host once, which is not evidence it still does; nothing was changed"
		fi
	fi

	# Same reasoning one step further: proof that ours ran is worthless if ours is
	# no longer scheduled. Checked BEFORE anything is removed, so the failure mode
	# "purged theirs, ours was disabled, host now has zero mechanisms" cannot
	# happen at all.
	local timer_state
	timer_state="$(systemctl is-enabled "$SHCP_UPDATE_SECURITY_TIMER" 2>/dev/null || true)"
	case "$timer_state" in
		enabled|enabled-runtime|static) ;;
		*) die "refusing to migrate: ${SHCP_UPDATE_SECURITY_TIMER} is '${timer_state:-unknown}' — with the incumbent gone this host would have NO security mechanism at all; arm the timer first (systemctl enable --now ${SHCP_UPDATE_SECURITY_TIMER}). Nothing was changed" ;;
	esac

	engine_log "migrate: proof of a prior successful security run — ${proof_run} at ${proof_ts}"

	# SC-071: the most destructive verb in the engine and the only one nothing can
	# undo. Gating the prompt on a tty inverted the rule — no tty meant no prompt
	# AND no refusal, so `ssh host shcp-update migrate-security-mechanism` with no
	# -t, a cron entry, or any pipe purged silently. Non-interactive must REFUSE
	# absent an explicit opt-in; only an interactive caller gets to be asked.
	if [[ $yes -eq 0 ]]; then
		if [[ ! -t 0 ]]; then
			die "refusing to migrate without --yes: stdin is not a terminal, so there is nobody to confirm an irreversible purge (SC-071) — nothing was changed"
		fi
		printf 'Purge unattended-upgrades + apt-listchanges (with their conffiles) and mask %s? This cannot be rolled back. Type "yes" to continue: ' \
			"${UU_MASK_UNITS[*]}" >&2
		local answer=""
		read -r answer
		[[ "$answer" == "yes" ]] || die "migration aborted (confirmation not given) — nothing was changed"
	fi

	# (2) archive, write-once, continue either way.
	uu_archive_configs || die "migrate: could not archive the incumbent's configs to ${UU_ARCHIVE} — nothing was purged"
	engine_log "migrate: config archive ${UU_ARCHIVE_STATE} (${UU_ARCHIVE})"

	# (3) stop + mask, BEFORE the wait — so nothing new can start while we wait
	# for what is already running to finish.
	uu_mask_units
	engine_log "migrate: stopped and masked ${UU_MASK_UNITS[*]}"

	# (4) quiescence, bounded, and a refusal when it is not reached.
	if ! apt_wait_quiescent; then
		die "migrate: apt/dpkg still holds ${UU_QUIESCE_BUSY} after ${SHCP_UPDATE_QUIESCE_TIMEOUT}s — refusing to purge under a live transaction (the units are masked; re-run when apt is idle)"
	fi

	# (5) purge. The incumbent's conffiles go with it — that is what --purge is
	# for and why step 2 archived them first.
	local pkg
	local -a to_purge=() purged_json=()
	for pkg in "${UU_PURGE_PKGS[@]}"; do
		osf_valid_pkg "$pkg" || die "migrate: refusing to purge an invalid package name '${pkg}'"
		if pkg_is_installed "$pkg"; then
			to_purge+=("$pkg")
			purged_json+=("$(jq -nc --arg p "$pkg" --arg v "$(pkg_installed_version "$pkg")" \
				'{pkg: $p, version: $v}')")
		fi
	done
	if [[ ${#to_purge[@]} -gt 0 ]]; then
		engine_log "migrate: purging ${to_purge[*]}"
		if ! DEBIAN_FRONTEND=noninteractive apt-get -y "${APT_LOCK_OPT[@]}" \
				remove --purge "${to_purge[@]}"; then
			# The message deliberately does not spell the apt binary's name: the
			# apt-hygiene suite derives its list of lock-taking call sites from
			# this file, and a mention inside an error string reads as one.
			die "migrate: the purge failed for ${to_purge[*]} — the units are masked and the archive is kept; re-run to converge"
		fi
	else
		# A converging re-run: an earlier pass purged and died before recording.
		engine_log "migrate: incumbent packages already absent — converging"
	fi

	# (6) re-verify, by ASKING the host rather than by trusting the exit codes
	# above. This is what turns "the commands returned 0" into "there is exactly
	# one mechanism here, and it is ours".
	if ! uu_single_mechanism; then
		die "migrate: post-purge verification failed (${UU_VERIFY_DETAIL}) — no record written; fix and re-run to converge"
	fi

	# (7) the durable record.
	local purged_arr
	purged_arr="$(printf '%s\n' ${purged_json[@]+"${purged_json[@]}"} | jq -sc 'map(select(type == "object"))')"
	uu_write_record "$proof_run" "$proof_ts" "$purged_arr" "$UU_CLAIM_UUID" \
		|| die "migrate: the migration completed but the record could not be written to ${UU_RECORD}"
	if [[ -n "$UU_CLAIM_UUID" ]] && [[ "$(uu_record_uuid || true)" == "$UU_CLAIM_UUID" ]]; then
		rm -f -- "$UU_CLAIM" || die "migrate: could not remove the completed queue claim"
		sync -f "$MIGRATION_DIR" 2>/dev/null || die "migrate: could not durably remove the completed queue claim"
	fi

	if [[ $JSON_OUTPUT -eq 1 ]]; then
		jq -n --arg r "$UU_RECORD" --arg a "$UU_ARCHIVE" --argjson p "$purged_arr" \
			'{outcome: "migrated", record: $r, archive: $a, purged: $p}'
	else
		log "unattended-upgrades retired: archive ${UU_ARCHIVE}, record ${UU_RECORD}"
	fi
	return 0
}

# --- 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 — the same three reasons UPD-12 records
# above, plus one of its own:
#   - 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;
#   - 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}"
# Parse-time fallback (deb) / test override; reconcile_resolve_apache_paths
# re-derives this from OS_FAMILY once the family is probed (SC-046/SC-047/SC-436).
APACHE_SITES_DIR="${SHCP_UPDATE_APACHE_SITES:-/etc/apache2/sites-available}"
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. Line 5601's `tr` deliberately keeps \12, so nothing upstream saves us.
DECL_UNIT_RE='\A[A-Za-z0-9._-]+\.(timer|service)\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'

# 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.
RECONCILE_PACKAGE_UNITS_JSON='["shcp-update-apply.service","shcp-update-drift.service","shcp-update-drift.timer","shcp-update-migrate.service","shcp-update-reinstall@.service","shcp-update-rollback@.service","shcp-update-security.service","shcp-update-security.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
	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" '
		# 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));
		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 ((.unit) as $n | ($x | index($n)) == null)))),
		  env: ((.env // []) | map(select(
		            (type == "object")
		            and named(.id // ""; $i)
		            and named(.key // ""; $e)
		            and (((.why // "") | length) > 0)))
		          # 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 (((.why // "") | length) > 0)))
		          # 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)))
		}
		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. 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' ;;
		*)         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=.
env_unit_lookup() {
	command -v systemctl >/dev/null 2>&1 || return 1
	local out="" rc=0 tok
	out="$(systemctl show "$SHCPD_UNIT" --property=Environment 2>/dev/null)" || rc=$?
	(( rc == 0 )) || return 1
	out="${out#Environment=}"
	[[ -n "$out" ]] || return 1
	local -a toks=()
	read -r -a toks <<<"$out" || true
	for tok in ${toks[@]+"${toks[@]}"}; do
		[[ "$tok" == "${1}="* ]] || continue
		printf '%s' "${tok#"${1}="}"
		return 0
	done
	return 1
}

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
	done < <(jq -r --arg s "$sep" '(.units // [])[] | [
			(.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)

	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, and its
# provisioning must run fetch-free (SC-530). A box
# installed before the installer exported the keyring file (pre-0.0.43) still holds
# the pinned primary in ROOT's gpg keyring, so reconcile can re-materialise it
# locally; without it every panel update refuses manifest_unverifiable and the box
# can never self-update to recover.
#   present+readable  -> ok (a present keyring's CONTENT is cmd_check's gpgv job, not
#                        ours; we NEVER overwrite one)
#   absent + root has the pinned primary -> RECOVERABLE finding (apply re-materialises)
#   absent + root lacks it                -> advisory (unrecoverable here; manual remedy)
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
	if command -v gpg >/dev/null 2>&1 && gpg --batch --list-keys "$RELEASE_FPR" >/dev/null 2>&1; then
		reconcile_add "keyring:shcp-release" keyring "$RELEASE_KEYRING" absent finding \
			"the release keyring is missing but the pinned primary is in root's gpg keyring — reconcile --apply re-materialises it locally" \
			"without the trust anchor every panel update refuses and the box cannot self-update to recover"
	else
		reconcile_add "keyring:shcp-release" keyring "$RELEASE_KEYRING" absent advisory \
			"the release keyring is missing AND root's gpg keyring lacks the pinned primary — reconcile cannot re-materialise it; recover manually (gpg --export) or reinstall" \
			"the trust anchor is unrecoverable on this box because the source key is absent"
	fi
	return 0
}

# reconcile_apply_keyring — PROVISION the release keyring from root's LOCAL gpg
# keyring, fetch-free (SC-530). Export STRICTLY by the
# compiled-in pinned primary (a polluted root keyring can only ever emit that one
# key or nothing), re-derive and assert the exported file's fingerprint before
# trusting it, write 0644 root:root mode-before-content, STAT-assert, read-back.
# NEVER the network; NEVER overwrite a present keyring; fail closed to coverage_gap.
reconcile_apply_keyring() {
	[[ -r "$RELEASE_KEYRING" ]] && return 0   # present: never touch it
	if ! command -v gpg >/dev/null 2>&1; then
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap \
			"gpg is not available — cannot re-materialise the release keyring locally"
		return 0
	fi
	if ! gpg --batch --list-keys "$RELEASE_FPR" >/dev/null 2>&1; then
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap \
			"root's gpg keyring lacks the pinned primary ${RELEASE_FPR} — cannot re-materialise the keyring, and never fetch a trust anchor from the network"
		return 0
	fi
	local dir tmp got_fpr mode owner
	dir="$(dirname "$RELEASE_KEYRING")"
	install -d -m 0755 "$dir" 2>/dev/null || true   # EL has no /usr/share/keyrings
	tmp="${RELEASE_KEYRING}.reconcile.$$"
	# mode-before-content: 0644-from-birth so the anchor never sits at a wider mode.
	( umask 022; : > "$tmp" ) || { reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap "could not stage ${tmp}"; return 0; }
	chown root:root "$tmp" 2>/dev/null || true
	if ! gpg --batch --export "$RELEASE_FPR" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then
		rm -f "$tmp"
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap \
			"gpg --export produced no output for ${RELEASE_FPR} — not written"
		return 0
	fi
	# Re-derive the exported file's fingerprint and assert it IS the pinned primary
	# (mirrors the installer's actual_fpr cross-check) before trusting the anchor.
	got_fpr="$(gpg --batch --with-colons --import-options show-only --import "$tmp" 2>/dev/null | awk -F: '$1=="fpr"{print $10; exit}')"
	if [[ "$got_fpr" != "$RELEASE_FPR" ]]; then
		rm -f "$tmp"
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap \
			"exported keyring fingerprint ${got_fpr:-none} != pinned ${RELEASE_FPR} — refusing to install a mismatched trust anchor"
		return 0
	fi
	chmod 0644 "$tmp" 2>/dev/null || true
	if ! mv -f "$tmp" "$RELEASE_KEYRING" 2>/dev/null; then
		rm -f "$tmp"
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap "could not install the keyring atomically"
		return 0
	fi
	mode="$(stat -c '%a' "$RELEASE_KEYRING" 2>/dev/null || echo '')"
	if [[ "$mode" != "644" ]]; then
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap "keyring installed but its mode is ${mode}, not 644"
		return 0
	fi
	if [[ "$(id -u 2>/dev/null)" == "0" ]]; then
		owner="$(stat -c '%U:%G' "$RELEASE_KEYRING" 2>/dev/null || echo '')"
		if [[ "$owner" != "root:root" ]]; then
			reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap "keyring installed but owner is ${owner}, not root:root"
			return 0
		fi
	fi
	# Read-back: gpgv's own read path must now find the pinned primary in the file.
	if gpg --batch --no-default-keyring --keyring "$RELEASE_KEYRING" --list-keys "$RELEASE_FPR" >/dev/null 2>&1; then
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" provisioned \
			"re-materialised the release keyring from root's gpg keyring (pinned primary, fingerprint-verified, 0644 root:root)"
	else
		reconcile_action "keyring:shcp-release" keyring "$RELEASE_KEYRING" coverage_gap \
			"wrote the keyring but the pinned primary is not readable from it — refusing to claim it is provisioned"
	fi
	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: the vhost/HSTS drift oracle runs on BOTH families.
	# The Apache facts it reads are resolved from OS_FAMILY (deb sites-available vs EL
	# /etc/httpd/conf.d); every other predicate below — units via systemctl, env
	# layering, the release keyring — is already family-neutral. osf_detect_family
	# still defaults a genuinely unrecognised id to deb, which is the safe fallback:
	# a box that is neither reads as deb, never as an untested rpm path.
	reconcile_resolve_apache_paths

	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)' <<<"$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)' <<<"$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 updater owns the anchor at the
	# same /usr/share/keyrings path on both families (reconcile_apply_keyring creates
	# the dir on EL, which ships no /usr/share/keyrings), 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 exactly as
# it is for UPD-12's migration verb:
#   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), 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 ledger of enable-symlinks THIS verb created, OUTSIDE runs/ (run_prune would
# otherwise erase the only record of what we may later be asked to remove).
# updater#22 / SC-537: orphan cleanup removes ONLY
# links recorded here, so the reconcile-managed scope IS exactly what reconcile
# wrote — never an admin- or package-shipped enable-symlink. Mirrors last-apply.json.
RECONCILE_LEDGER_FILE="${SHCP_UPDATE_RECONCILE_LEDGER:-${RECONCILE_APPLY_DIR}/enabled-units.json}"
MAX_RECONCILE_LEDGER_BYTES=$((64 * 1024))

# 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="${SHCP_UPDATE_EDGE_AUTH_CONF:-/etc/apache2/shcp-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 | skip | coverage_gap.
# The first three 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
}

# reconcile_fetch_templates <dest-dir> — fetch the signed manifest, resolve the
# installer artifact, verify it (SC-064/207) and extract it into dest-dir. 0 on
# success (dest-dir populated), 1 on any failure — in which case NOTHING on the
# box has been read or changed and the caller aborts.
reconcile_fetch_templates() {
	local dest="$1" manifest tarball spec url sig sha
	mkdir -p "$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 ! tar xzf "$tarball" -C "$dest" --no-same-owner 2>/dev/null; then
		engine_log "reconcile: installer artifact did not extract"
		rm -f "$tarball" "${tarball}.sig" 2>/dev/null || true
		return 1
	fi
	rm -f "$tarball" "${tarball}.sig" 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, THEN write a
# COMPLETE marker. The marker is written LAST and is what distinguishes a
# finished archive from a half-written one: a re-run that finds it SKIPS
# archiving and continues (idempotent appliers converge), so an interrupted
# reconcile is recoverable instead of permanently wedged. Crucially the archive
# is finished BEFORE any applier writes, so a crash before COMPLETE means no
# applier ran and re-archiving captures true pre-state; a crash after COMPLETE
# preserves that true pre-state across the re-run.
RECONCILE_ARCHIVE_DIR=""
reconcile_archive_run() {   # reconcile_archive_run <archive-dir> <path>...
	local adir="$1"; shift
	local files_dir="${adir}/files" marker="${adir}/COMPLETE"
	if [[ -f "$marker" ]]; then
		return 0   # a previous pass already captured pristine state — keep it
	fi
	# Incomplete (or absent): (re)build it. Safe because COMPLETE gates the
	# appliers, so nothing has changed on the box yet.
	rm -rf "$files_dir" "${adir}/index.json" 2>/dev/null || true
	install -d -m 0700 "$adir" "$files_dir" || return 1
	local p sz sha existed idx='[]' n=0
	for p in "$@"; do
		[[ -n "$p" ]] || continue
		n=$((n + 1))
		local 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
	done
	printf '%s\n' "$idx" > "${adir}/index.json" || return 1
	# COMPLETE is the LAST thing written, and only after the index is on disk.
	printf '%s\n' "$(now_utc)" > "$marker" || return 1
	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
	# Remember the enable-symlinks this created so a later run can recognise them as
	# reconcile's own if the defining package is rolled back (updater#22,
	# SC-537). Best-effort: a ledger we cannot write is
	# logged, never fatal — the enable itself is correct regardless.
	reconcile_ledger_record "$id" "$unit"
	# 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_wants_links_for <unit> — emit "path<TAB>target", one per line, for
# every enable-symlink named <unit> in a <target>.wants/ or <target>.requires/
# directory under RECONCILE_SYSTEMD_ETC. That is exactly the set `systemctl
# enable` writes for a unit's `WantedBy=`/`RequiredBy=` install directives.
# Sorted for a reproducible ledger. Emits nothing (rc 0) when there are none —
# consumed in a command substitution, so it MUST never abort the caller.
reconcile_wants_links_for() {
	local unit="$1" root="$RECONCILE_SYSTEMD_ETC" d link tgt
	[[ -n "$unit" && -d "$root" ]] || return 0
	local -a out=()
	# A non-matching glob expands to the literal pattern; the -d test drops it, so
	# nullglob is not needed and is not toggled globally.
	for d in "$root"/*.wants "$root"/*.requires; do
		[[ -d "$d" ]] || continue
		link="${d}/${unit}"
		[[ -L "$link" ]] || continue
		tgt="$(readlink "$link" 2>/dev/null || true)"
		out+=("$(printf '%s\t%s' "$link" "$tgt")")
	done
	[[ ${#out[@]} -gt 0 ]] || return 0
	printf '%s\n' "${out[@]}" | LC_ALL=C sort
	return 0
}

# reconcile_ledger_record <id> <unit> — upsert (by unit) the enable-symlinks
# reconcile just created for <unit> into the ledger. Best-effort: every failure
# path logs and returns 0 — a ledger we cannot write must not undo a correct
# enable, it only forgoes a future orphan-cleanup opportunity.
reconcile_ledger_record() {
	local id="$1" unit="$2" links_json cur new
	links_json="$(reconcile_wants_links_for "$unit" \
		| jq -R -s -c 'split("\n") | map(select(length>0) | split("\t") | {path: .[0], target: .[1]})' 2>/dev/null || printf '[]')"
	[[ -n "$links_json" ]] || links_json='[]'
	# An enable that created no persistent link (a unit with no [Install], or a
	# systemd that placed none) leaves nothing for cleanup to ever act on — do not
	# record an empty entry.
	[[ "$links_json" != '[]' ]] || return 0
	install -d -m 0700 "$RECONCILE_APPLY_DIR" 2>/dev/null \
		|| { engine_log "reconcile: cannot create state dir for the enable-symlink ledger"; return 0; }
	cur='[]'
	if [[ -f "$RECONCILE_LEDGER_FILE" ]]; then
		cur="$(head -c "$MAX_RECONCILE_LEDGER_BYTES" -- "$RECONCILE_LEDGER_FILE" 2>/dev/null | jq -c '.' 2>/dev/null || printf '[]')"
		[[ -n "$cur" ]] || cur='[]'
	fi
	new="$(jq -c --arg id "$id" --arg unit "$unit" --argjson links "$links_json" --arg at "$(now_utc)" \
		'(map(select(.unit != $unit))) + [{unit: $unit, id: $id, links: $links, enabled_at: $at}]' \
		<<<"$cur" 2>/dev/null || true)"
	[[ -n "$new" ]] || { engine_log "reconcile: enable-symlink ledger update produced no document; left unchanged"; return 0; }
	printf '%s\n' "$new" | atomic_write "$RECONCILE_LEDGER_FILE" 0600 \
		|| engine_log "reconcile: could not write the enable-symlink ledger"
	return 0
}

# reconcile_orphan_wants_cleanup <decl-engine> <decl-release> — remove enable-
# symlinks THIS verb created whose defining package was later downgraded/removed,
# leaving the link dangling. updater#22, residual of SC-480; SC-432 is the anchor
# and SC-537 the new destructive-action checkpoint.
#
# reconcile applies config OUTSIDE SC-432 auto-rollback, which reverts only the
# apt package diff — so a reconcile-enabled unit whose .deb is rolled back leaves
# a .wants/ symlink pointing at a unit file dpkg has removed. The drift detector
# never reports it (F4: the CURRENT declaration no longer names the unit, so the
# detector's walk never reaches it), so unless this runs nothing ever cleans it up.
#
# SCOPE — destructive on the host, so a link is removed ONLY when ALL hold:
#   1. it is in THIS verb's ledger (reconcile wrote it). An admin- or package-
#      shipped enable-symlink is never in the ledger, so it is never a candidate;
#   2. the recorded link is STILL a dangling symlink on disk pointing at the SAME
#      target we recorded (-L && ! -e && readlink == recorded target). A link an
#      operator re-pointed or replaced, or whose target has reappeared, is left;
#   3. systemd reports the unit `absent` (LoadState=not-found). An unanswerable or
#      unknown probe (no dbus, a container, an SC-356 MAC denial) never removes —
#      never act blind on evidence systemd could not give;
#   4. the unit is NOT in the running-release declared set and NOT exempt. A still-
#      declared unit whose fragment is momentarily missing is the applier's
#      coverage-gap case (the installer must deliver it), never a removal.
# Records a `removed_orphan_wants` action per link, drops fully-cleaned units from
# the ledger, and daemon-reloads once if anything was removed. Never fatal.
reconcile_orphan_wants_cleanup() {
	local decl_e="$1" decl_r="$2"
	[[ -f "$RECONCILE_LEDGER_FILE" ]] || return 0
	local ledger
	ledger="$(head -c "$MAX_RECONCILE_LEDGER_BYTES" -- "$RECONCILE_LEDGER_FILE" 2>/dev/null | jq -c '.' 2>/dev/null || true)"
	[[ -n "$ledger" && "$ledger" != '[]' ]] || return 0

	# The running-release declared unit set (both halves). A unit named here is
	# expected and can never be a removal candidate (guard 4).
	local declared
	declared="$(printf '%s\n%s\n' "$decl_e" "$decl_r" \
		| jq -r '(.units // [])[]?.unit // empty' 2>/dev/null | LC_ALL=C sort -u || true)"

	local removed_any=0 kept='[]'
	local rec unit id state path target kind
	while IFS= read -r rec; do
		[[ -n "$rec" ]] || continue
		unit="$(jq -r '.unit // empty' <<<"$rec" 2>/dev/null || true)"
		id="$(jq -r '.id // empty' <<<"$rec" 2>/dev/null || true)"
		[[ -n "$unit" ]] || continue
		kind="$(reconcile_unit_kind "$unit")" || continue

		# Guard 4a: still declared -> reconcile would re-enable it; keep the record,
		# remove nothing. Guard 4b: exempt -> operator said "leave that". Guard 3:
		# systemd must positively report absent, else the unit is back or unprobeable.
		if printf '%s\n' "$declared" | grep -qxF -- "$unit" \
			|| { [[ -n "$id" ]] && reconcile_is_exempt "$id"; } \
			|| [[ "$(reconcile_unit_state "$unit")" != absent ]]; then
			kept="$(jq -c --argjson r "$rec" '. + [$r]' <<<"$kept" 2>/dev/null || printf '%s' "$kept")"
			continue
		fi

		# Guard 2, per link: remove ONLY a still-dangling symlink pointing where we
		# recorded. Anything else survives and stays tracked.
		local -a survivors=()
		while IFS=$'\t' read -r path target; do
			[[ -n "$path" ]] || continue
			if [[ -L "$path" && ! -e "$path" && "$(readlink "$path" 2>/dev/null || true)" == "$target" ]]; then
				if rm -f -- "$path" 2>/dev/null; then
					removed_any=1
					reconcile_action "$id" "$kind" "$unit" removed_orphan_wants \
						"removed orphan enable-symlink ${path} -> ${target} (fragment gone, unit undeclared after a package rollback — SC-432)"
				else
					survivors+=("$(printf '%s\t%s' "$path" "$target")")
					reconcile_action "$id" "$kind" "$unit" coverage_gap \
						"orphan enable-symlink ${path} could not be removed"
				fi
			else
				survivors+=("$(printf '%s\t%s' "$path" "$target")")
			fi
		done < <(jq -r '.links[]? | "\(.path)\t\(.target)"' <<<"$rec" 2>/dev/null || true)

		# Keep the record only if a link survived; a fully-cleaned unit leaves the
		# ledger entirely.
		if [[ ${#survivors[@]} -gt 0 ]]; then
			local survivors_json
			survivors_json="$(printf '%s\n' "${survivors[@]}" \
				| jq -R -s -c 'split("\n") | map(select(length>0) | split("\t") | {path: .[0], target: .[1]})' 2>/dev/null || printf '[]')"
			kept="$(jq -c --argjson r "$rec" --argjson l "$survivors_json" '. + [($r + {links: $l})]' <<<"$kept" 2>/dev/null || printf '%s' "$kept")"
		fi
	done < <(jq -c '.[]?' <<<"$ledger" 2>/dev/null || true)

	if (( removed_any == 1 )); then
		command -v systemctl >/dev/null 2>&1 && systemctl daemon-reload >/dev/null 2>&1 || true
		if [[ "$kept" == '[]' ]]; then
			rm -f -- "$RECONCILE_LEDGER_FILE" 2>/dev/null || true
		else
			printf '%s\n' "$kept" | atomic_write "$RECONCILE_LEDGER_FILE" 0600 \
				|| engine_log "reconcile: could not rewrite the enable-symlink ledger after cleanup"
		fi
	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 pre-UPD-3 box (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
	# migrated box to a permanent coverage_gap the detector never agrees with. `-L`
	# keeps us on the symlink layout (a pre-UPD-3 real file is still not 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 pre-UPD-3 unmigrated layout. 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 (unmigrated .env.local layout) — 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
	# 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.
	chmod 0640 "$target" 2>/dev/null || true
	chown "$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 a plain append is the additive write.
	if ! printf '%s=%s\n' "$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, including a stray :80 one from a pre-#24 bug — 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
	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 (pre-UPD-3, 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
}

# 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
	done < <(jq -r --arg s "$sep" '(.units // [])[] | [
			(.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.
	if [[ "${RECONCILE_ENV_BLIND:-0}" != "1" ]]; then
		while IFS="$sep" read -r id name gen why; do
			[[ -n "$id" ]] || continue
			reconcile_is_exempt "$id" && continue
			if ! env_resolved "$name"; then
				reconcile_apply_env "$id" "$name" "$gen" "$why"
			fi
		done < <(jq -r --arg s "$sep" '(.env // [])[] | [
				(.id), (.key), (.generate.method // ""),
				((.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)
	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") | .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=="provisioned")] | length' <<<"$actions" 2>/dev/null || echo 0)"
	gaps="$(jq -r '[.[] | select(.action=="coverage_gap")] | length' <<<"$actions" 2>/dev/null || echo 0)"
	local orphans
	orphans="$(jq -r '[.[] | select(.action=="removed_orphan_wants")] | 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" \
		--argjson orph "$orphans" --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, orphans_removed: $orph,
		  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, 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. A non-deb box or 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

	# Orphan enable-symlink cleanup (updater#22, SC-537).
	# Runs on BOTH the clean (rc==OK) and findings paths, because the drift detector
	# never flags these (F4: a vanished declaration is never reached), so a clean box
	# is exactly where a post-rollback orphan hides. Fetch-free and bounded to the
	# reconcile-written ledger (reconcile_orphan_wants_cleanup). Reached only past the
	# CANNOT_CHECK gate above, so never on a non-deb / unreadable-declaration box.
	local decl_e_orph="" decl_r_orph="" orc
	orc=0; decl_e_orph="$(decl_read "$DECL_ENGINE_FILE")" || orc=$?; (( orc == 0 )) || decl_e_orph=""
	orc=0; decl_r_orph="$(decl_read "$DECL_RELEASE_FILE")" || orc=$?; (( orc == 0 )) || decl_r_orph=""
	reconcile_orphan_wants_cleanup "$decl_e_orph" "$decl_r_orph"

	if (( rc == RECONCILE_EXIT_OK )); then
		# A box whose ONLY change was orphan cleanup still deserves a durable record
		# (last-apply.json); a truly clean box recorded nothing, so the list is empty
		# and none is written — Section C's no-op invariant holds.
		local okdoc; okdoc="$(reconcile_apply_doc "" true)"
		if (( ${#RECONCILE_APPLY_ACTIONS[@]} > 0 )); then
			reconcile_record_write "$okdoc" || engine_log "reconcile: could not write the durable apply record"
		fi
		printf '%s\n' "$okdoc"
		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=""

	# Step 0.5: re-materialise the release keyring FIRST, fetch-free, if it is the
	# recoverable finding (SC-530). This MUST precede the
	# step-1 fetch: reconcile_fetch_templates -> manifest_fetch requires the keyring
	# to verify, so on a pre-0.0.43 box (the whole target of this heal) the fetch
	# would abort before any applier ran. A no-op when the keyring is present or
	# unrecoverable. Its action survives because Step 3 no longer clears the list.
	reconcile_apply_keyring

	# If re-materialising the keyring converged the box (it was the only finding),
	# skip fetch/archive/apply: the step-1 fetch would need a template no remaining
	# finding consumes, and the point on this pre-0.0.43 box was to unblock manifest
	# verification, not to touch a vhost. Re-run the SAME detector — the keyring
	# action already recorded survives (RECONCILE_APPLY_ACTIONS is not cleared by
	# reconcile_evaluate; only RECONCILE_ITEMS is).
	rc=0
	reconcile_evaluate >/dev/null 2>&1 || rc=$?
	if (( rc == RECONCILE_EXIT_OK )); then
		printf '%s\n' "$(reconcile_apply_doc "" true)"
		return "$RECONCILE_EXIT_OK"
	fi
	if (( rc == RECONCILE_EXIT_CANNOT_CHECK )); then
		printf '%s\n' "$(reconcile_apply_doc "cannot-check" false)"
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi

	# Step 1: fetch + verify the installer artifact BEFORE reading a byte of it
	# (SC-064/207). An unverified artifact ABORTS having changed nothing.
	local work="${RECONCILE_APPLY_DIR}/work.$$"
	rm -rf "$work" 2>/dev/null || true
	if ! reconcile_fetch_templates "$work"; then
		rm -rf "$work" 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 three candidate classes that exist
	# (panel.env, the edge conf, every declared vhost conf). A complete archive
	# from an earlier interrupted run is kept, so the re-run continues.
	RECONCILE_ARCHIVE_DIR="${RECONCILE_APPLY_DIR}/archive"
	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)
	if ! reconcile_archive_run "$RECONCILE_ARCHIVE_DIR" "${candidates[@]}"; then
		rm -rf "$work" 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 and Step 0.5 (keyring) may already have
	# recorded into it; clearing would drop that action from the record.
	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 "$work" 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
		return "$RECONCILE_EXIT_CANNOT_CHECK"
	fi
	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"
}

usage() {
	# Terminated by the state-machine paragraph, not by a fixed line count. The
	# old '3,20p' already cut migrate-security-mechanism's rationale in half, and
	# any wider fixed number eventually swallows the paragraph below the verbs.
	sed -n '3,${/^# State machine/q;p}' "$0" | sed 's/^# \{0,1\}//'
	cat >&2 <<'EOF'
Usage:
  shcp-update check [--blockers]
  shcp-update apply [--from-request | --scope security | --reinstall [--command-uuid <uuid>]]
  shcp-update resume
  shcp-update rollback <run-id> [--yes] [--force-superseded]
  shcp-update status [--follow]
  shcp-update self-test
  shcp-update maintenance [--set|--clear|--status]
  shcp-update queue-security-migration --command-uuid <lowercase-uuid>
  shcp-update authorize-security-migration-claim --command-uuid <lowercase-uuid>
  shcp-update release-security-migration-claim --command-uuid <lowercase-uuid>
  shcp-update migrate-security-mechanism [--yes]
  shcp-update reconcile-config --check      (always JSON; --json is accepted and redundant)
Global: --json
EOF
}

main() {
	[[ $# -ge 1 ]] || { usage; exit 2; }
	local verb="$1"
	shift
	case "$verb" in
		check)       cmd_check "$@" ;;
		apply)       cmd_apply "$@" ;;
		resume)      cmd_resume "$@" ;;
		rollback)    cmd_rollback "$@" ;;
		status)      cmd_status "$@" ;;
		self-test)   cmd_self_test "$@" ;;
		maintenance) cmd_maintenance "$@" ;;
		authorize-security-migration-claim) cmd_authorize_security_migration_claim "$@" ;;
		release-security-migration-claim) cmd_release_security_migration_claim "$@" ;;
		queue-security-migration) cmd_queue_security_migration "$@" ;;
		# UPD-12. A verb, never a stage — see the section above
		# cmd_migrate_security_mechanism.
		migrate-security-mechanism) cmd_migrate_security_mechanism "$@" ;;
		# UPD-14 detection. Read-only, no lock, no network — and, like the verb
		# above it, never reachable from run_stages (SC-432).
		reconcile-config) cmd_reconcile_config "$@" ;;
		-h|--help|help) usage ;;
		*) 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
