#!/usr/bin/env bash
# vesmaro-node — one-command installer for a vesmaro memory node.
#
# Bundle = server venv (vesmaro, from a RELEASE TAG) + mesh binary
# (mnemos-mesh, from the paired release ref) + configs (/etc/vesmaro,
# secrets in 0600 env files) + systemd units + cert-gen hook.
# Idempotent: re-running install at the SAME pair is a re-provision;
# a CHANGED pair routes through the upgrade path. Restarts happen only
# when artifacts actually change. Upgrades are ATOMIC over the (core,
# mesh) PAIR with pre-flight sqlite backup, health-verify and
# auto-rollback (health-verify failures AND mid-swap aborts).
#
# Subcommands:
#   install           deploy the bundle (adopt existing install when present)
#   container-install flatpak-style: pull READY images from ghcr, deploy
#                     rootless quadlets (systemd --user), data stays on
#                     the HOST via bind-mounts (local-first)
#   upgrade           atomic pair upgrade (--check = dry-run)
#   status            versions, units, face health, mesh healthz, peer skew,
#                     available tags, pair compatibility (+containers)
#   uninstall         remove the bundle (--keep-data default | --purge);
#                     also removes a container install when one is recorded
#
# Layout styles:
#   system (default)  /opt/vesmaro/venv, /usr/local/bin/mnemos-mesh,
#                     /etc/vesmaro, /etc/systemd/system, /var/lib/vesmaro
#   --user NAME       ~NAME/.local/share/vesmaro/venv, ~NAME/.local/bin,
#                     ~NAME/.config/vesmaro — same unit NAMES (system
#                     units with User=NAME), mirroring the legacy laptop
#                     prod topology.
#
# Release policy: artifacts come ONLY from git tags listed (as pairs) in
# compatibility.tsv. Development revisions require an explicit --ref /
# --mesh-ref and are loudly marked dev. Unknown pairs refuse (or --force).
#
# Sandbox: --dest DIR prefixes every target path and neutralizes
# systemctl — full layout drill without touching the running system.
set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true
umask 077   # everything not explicitly chmod'd (manifests, staging) is private

SCRIPT_NAME="vesmaro-node"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TSV="$SCRIPT_DIR/compatibility.tsv"

# ── pretty helpers ───────────────────────────────────────────────────
# NOTE: all progress logging goes to STDERR — functions whose stdout is
# captured via $(...) (build_venv, build_mesh, install_units, ...) must
# stay data-only on stdout.
if [[ -t 2 ]]; then
  C_G='\033[0;32m'; C_Y='\033[1;33m'; C_R='\033[0;31m'; C_C='\033[0;36m'; C_N='\033[0m'
else
  C_G=''; C_Y=''; C_R=''; C_C=''; C_N=''
fi
info() { printf "${C_C}==${C_N} %s\n" "$*" >&2; }
ok()   { printf "${C_G}OK${C_N}  %s\n" "$*" >&2; }
warn() { printf "${C_Y}!!${C_N}  %s\n" "$*" >&2; }
die()  { printf "${C_R}XX${C_N}  %s\n" "$*" >&2; exit 1; }

usage() {
  awk 'NR>1 && /^# /{sub(/^# ?/,""); print; next} NR>1 && !/^#/{exit}' "${BASH_SOURCE[0]}"
  exit 0
}

# ── global option state ──────────────────────────────────────────────
CMD=""
SCRIPT_CMD=""  # first positional — used in host-context hints
PROFILE=""
STYLE="system"
USER_NAME=""
RUN_USER=""
WITHOUT_MESH=false
RELEASE=""
MESH_RELEASE=""
DEV_REF=""
DEV_MESH_REF=""
VESMARO_REPO_FLAG=""
MESH_REPO_FLAG=""
MESH_BIN_FLAG=""
NODE_ID=""
DEST=""
NO_ENABLE=false
REGEN_UNITS=false
RECONF=false
FORCE=false
ADOPT=false
MIGRATE_ADOPTED=false
GEN_CERTS=false
CHECK=false
PURGE=false
KEEP_DATA=false
ASSUME_YES=false
INCLUDE_ADOPTED=false
WORKDIR="${XDG_CACHE_HOME:-$HOME/.cache}/vesmaro-node"
PYBIN_FLAG=""

# container-install state
CT_NAME=""
CT_TAG=""
CT_MESH_TAG=""
CT_PORT=""
CT_DATA_DIR=""
CT_NETWORK="host"
GHCR_CORE="ghcr.io/vesmaro/vesma"
GHCR_MESH="ghcr.io/vesmaro/vesma-mesh"

SANDBOX=false
SYS_DRY=false
SYS_CMD=""

# resolved paths (filled by compute_paths / adopt)
VENV="" MESHBIN="" CONFDIR="" ENVDIR="" PKIDIR="" DATADIR="" RUNDIR=""
STATEDIR="" UNITDIR="" MANIFEST="" BUNDLE_DIR=""
LEG_HOME=""

dst() { printf '%s%s' "$DEST" "$1"; }   # sandbox-aware target path

# ── systemd wrapper ──────────────────────────────────────────────────
detect_sys() {
  if [[ -n "$DEST" ]]; then
    SANDBOX=true; SYS_DRY=true; return
  fi
  if [[ -d /run/systemd/system ]] && command -v systemctl >/dev/null 2>&1; then
    SYS_CMD="systemctl"
  elif command -v distrobox-host-exec >/dev/null 2>&1; then
    SYS_CMD="distrobox-host-exec systemctl"
  else
    warn "no systemd reachable here — systemctl calls become dry-run"
    SYS_DRY=true
  fi
}

sys() {  # sys <verb...> — never fails the script; caller checks meaning
  if $SYS_DRY; then
    printf "  [dry] systemctl %s\n" "$*" >&2
    return 0
  fi
  $SYS_CMD "$@" || true
}
sys_real() {  # strict variant for decisions
  if $SYS_DRY; then
    printf "  [dry] systemctl %s\n" "$*" >&2
    return 0
  fi
  $SYS_CMD "$@"
}

# ── manifest (JSON via python3) ──────────────────────────────────────
mf_get() {  # mf_get dotted.key -> value or "" (empty)
  python3 - "$MANIFEST" "$1" <<'PY'
import json, sys
try:
    d = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
key = sys.argv[2]
v = d.get(key)      # flat dotted key — this is how mf_write stores them
if v is None:       # nested-dict fallback (hand-edited manifests)
    v = d
    for k in key.split("."):
        if not isinstance(v, dict) or k not in v:
            sys.exit(0)
        v = v[k]
print(v if isinstance(v, str) else json.dumps(v))
PY
}

mf_write() {  # mf_write key=value ... (merges into manifest)
  python3 - "$MANIFEST" "$@" <<'PY'
import json, sys, os, datetime
path, kv = sys.argv[1], sys.argv[2:]
try:
    d = json.load(open(path))
except Exception:
    d = {}
d.update(dict(a.split("=", 1) for a in kv))
now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
d.setdefault("installed_at", now)
d["updated_at"] = now
d["schema"] = 1
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
    json.dump(d, f, indent=2, sort_keys=True)
    f.write("\n")
PY
}

manifest_exists() { [[ -n "$MANIFEST" && -f "$MANIFEST" ]]; }

# ── small utilities ──────────────────────────────────────────────────
sha256_of() { sha256sum "$1" | awk '{print $1}'; }

distrobox_enter_cmd() {  # echoes "" or a usable distrobox-enter command
  if command -v distrobox-enter >/dev/null 2>&1; then echo "distrobox-enter"; return; fi
  local c="$HOME/.local/bin/distrobox-enter"
  if [[ -x "$c" ]]; then echo "$c"; fi
}

version_core() {  # $1 = mnemos bin -> "4.3.0" or ""
  [[ -x "$1" ]] || { echo ""; return; }
  local v=""
  v="$("$1" --version 2>/dev/null | awk '{print $NF}' || true)"
  [[ -n "$v" ]] && { echo "$v"; return; }
  local dbx; dbx="$(distrobox_enter_cmd)"
  if [[ -n "$dbx" ]]; then
    # legacy laptop: the prod venv's python lives inside a distrobox (the
    # host has no python3.12) — probe through the box; box name overridable
    # via VESMARO_LEGACY_BOX (default: ubuntu)
    v="$("$dbx" -n "${VESMARO_LEGACY_BOX:-ubuntu}" -- "$1" --version 2>/dev/null | awk '{print $NF}' || true)"
  fi
  echo "${v:-}"
}

version_mesh() {  # $1 = mnemos-mesh bin -> "v1.3.2" or ""
  [[ -x "$1" ]] || { echo ""; return; }
  "$1" version 2>/dev/null | awk 'NR==1{print $NF}' || echo ""
}

user_home() { getent passwd "$1" | cut -d: -f6; }

require_root_unless_sandbox() {
  [[ "$STYLE" == "user" ]] && return 0  # --user writes only under $HOME
  if [[ -z "$DEST" && "$(id -u)" != 0 ]]; then
    die "system style writes /opt,/etc,/usr/local — run with sudo (or use --user / --dest)"
  fi
}

pick_python() {
  if [[ -n "$PYBIN_FLAG" ]]; then command -v "$PYBIN_FLAG" >/dev/null || die "--python: $PYBIN_FLAG not found"; PYBIN="$PYBIN_FLAG"; return; fi
  for c in python3.12 python3.11 python3; do
    if command -v "$c" >/dev/null 2>&1; then
      if "$c" -c 'import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)' 2>/dev/null; then PYBIN="$c"; return; fi
    fi
  done
  die "python >= 3.11 required (or pass --python BIN)"
}

# ── repo resolution ──────────────────────────────────────────────────
default_vesmaro_repo() {
  if [[ -n "$VESMARO_REPO_FLAG" ]]; then echo "$VESMARO_REPO_FLAG"; return; fi
  if [[ -n "${VESMARO_REPO:-}" ]]; then echo "$VESMARO_REPO"; return; fi
  local p="$HOME/LABs/Projects/Project-Vesma/vesma"
  if [[ -d "$p/.git" ]]; then echo "$p"; return; fi
  echo ""
}
default_mesh_repo() {
  if [[ -n "$MESH_REPO_FLAG" ]]; then echo "$MESH_REPO_FLAG"; return; fi
  if [[ -n "${MESH_REPO:-}" ]]; then echo "$MESH_REPO"; return; fi
  local p="$HOME/LABs/Projects/Project-Vesma/vesma-mesh"
  if [[ -d "$p/.git" ]]; then echo "$p"; return; fi
  echo ""
}

ensure_repo() {  # ensure_repo <what> <default-path-or-empty> <clone-url> -> echoes repo path
  local what="$1" path="$2" url="$3"
  if [[ -n "$path" ]]; then
    [[ -d "$path/.git" ]] || die "$what repo path is not a git repo: $path"
    echo "$path"; return
  fi
  local clone="$WORKDIR/src/${what}"
  if [[ ! -d "$clone/.git" ]]; then
    info "cloning $what from $url (or pass --${what}-repo PATH)"
    mkdir -p "$WORKDIR/src"
    git clone -q "$url" "$clone"
  fi
  echo "$clone"
}

fetch_ref() {  # fetch_ref <repo> <ref> — make sure ref exists locally
  local repo="$1" ref="$2"
  git -C "$repo" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null && return 0
  info "fetching $ref into $repo"
  git -C "$repo" fetch origin --quiet --tags 2>/dev/null \
    || git -C "$repo" fetch origin --quiet 2>/dev/null \
    || warn "offline fetch failed for $repo"
  git -C "$repo" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null \
    || die "ref $ref not found in $repo"
}

# ── compatibility ledger ─────────────────────────────────────────────
# columns: core_tag, mesh_ref, mesh_version, verified, date
tsv_rows() {  # -> lines of "core_tag|mesh_ref|mesh_version"
  [[ -f "$TSV" ]] || die "compatibility ledger not found: $TSV"
  grep -Ev '^\s*(#|$)' "$TSV" | awk -F'\t' 'NF>=3{printf "%s|%s|%s\n",$1,$2,$3}'
}

pair_known() {  # pair_known core mesh -> 0 if listed
  local c="$1" m="$2" row
  while IFS='|' read -r ct mr mv; do
    [[ "$ct" == "$c" && "$mr" == "$m" ]] && return 0
  done < <(tsv_rows)
  return 1
}

tsv_last() { tsv_rows | tail -1; }

list_known_pairs() {
  info "known-good pairs in $TSV:"
  while IFS='|' read -r ct mr mv; do printf "    core %-12s mesh %-10s (%s)\n" "$ct" "$mr" "$mv"; done < <(tsv_rows)
}

# resolve_release: sets CORE_TAG CORE_REF MESH_REF EXPECT_MESHV PAIR_MODE
CORE_TAG=""; CORE_REF=""; MESH_REF=""; EXPECT_MESHV=""; PAIR_MODE=""
resolve_release() {
  if [[ -n "$DEV_REF" || -n "$DEV_MESH_REF" ]]; then
    CORE_REF="${DEV_REF:-$(tsv_last | cut -d'|' -f1)}"
    MESH_REF="${DEV_MESH_REF:-$(tsv_last | cut -d'|' -f2)}"
    EXPECT_MESHV="$(tsv_last | cut -d'|' -f3)"
    CORE_TAG="$CORE_REF"; PAIR_MODE="dev"
    warn "DEV mode: building from '$CORE_REF' / '$MESH_REF' — not a release pair"
    return
  fi
  local row
  if [[ -z "$RELEASE" ]]; then
    row="$(tsv_last)"
    [[ -n "$row" ]] || die "ledger empty: add a pair to compatibility.tsv"
  else
    row="$(tsv_rows | grep -F "${RELEASE}|" | head -1 || true)"
    if [[ -z "$row" ]]; then
      warn "core tag '$RELEASE' is not in the compatibility ledger"
      list_known_pairs
      die "pick a listed --release (or extend compatibility.tsv)"
    fi
  fi
  CORE_TAG="$(cut -d'|' -f1 <<<"$row")"
  MESH_REF="$(cut -d'|' -f2 <<<"$row")"
  EXPECT_MESHV="$(cut -d'|' -f3 <<<"$row")"
  if [[ -n "$MESH_RELEASE" && "$MESH_RELEASE" != "$MESH_REF" ]]; then
    if ! pair_known "$CORE_TAG" "$MESH_RELEASE"; then
      if ! $FORCE; then
        warn "pair ($CORE_TAG, $MESH_RELEASE) is not in the ledger"
        list_known_pairs
        die "refusing unverified pair — add it to compatibility.tsv or pass --force"
      fi
      PAIR_MODE="forced"
    else
      PAIR_MODE="known"
    fi
    MESH_REF="$MESH_RELEASE"
  else
    PAIR_MODE="known"
  fi
  CORE_REF="$CORE_TAG"
}

# ── path model ───────────────────────────────────────────────────────
compute_paths() {
  if [[ "$STYLE" == "user" ]]; then
    local home
    home="$(user_home "$USER_NAME")"
    [[ -n "$home" && -d "$home" ]] || die "user $USER_NAME has no home"
    LEG_HOME="$home"
    VENV="$home/.local/share/vesmaro/venv"
    MESHBIN="$home/.local/bin/mnemos-mesh"
    CONFDIR="$home/.config/vesmaro"
    DATADIR="$home/.local/share/vesmaro/data"
    RUNDIR="$home/.run/vesmaro"
    STATEDIR="$home/.local/state/vesmaro"
    BUNDLE_DIR="$home/.local/share/vesmaro/node-install"
  else
    VENV="/opt/vesmaro/venv"
    MESHBIN="/usr/local/bin/mnemos-mesh"
    CONFDIR="/etc/vesmaro"
    DATADIR="/var/lib/vesmaro"
    RUNDIR="/run/vesmaro"
    STATEDIR="/var/lib/vesmaro"
    BUNDLE_DIR="/opt/vesmaro/node-install"
  fi
  ENVDIR="$CONFDIR/env"
  PKIDIR="$CONFDIR/pki"
  UNITDIR="/etc/systemd/system"
  # MANIFEST is DEST-prefixed so a sandbox install records its own
  # manifest under $DEST instead of touching the real /var/lib.
  MANIFEST="${DEST}${STATEDIR}/manifest.json"
  RUN_USER="${RUN_USER:-$USER_NAME}"
  [[ -n "$RUN_USER" ]] || RUN_USER="root"
}

# ── artifact builds ──────────────────────────────────────────────────
build_venv() {  # build_venv <repo> <ref> -> echoes cache path
  local repo="$1" ref="$2" sha pyver cache src
  fetch_ref "$repo" "$ref"
  sha="$(git -C "$repo" rev-parse --short=12 "${ref}^{commit}")"
  pyver="$("$PYBIN" -c 'import sys;print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
  cache="$WORKDIR/cache/venv-${sha}-py${pyver}"
  if [[ -x "$cache/bin/mnemos" ]]; then
    info "server venv: cache hit ($ref @ $sha)"
    echo "$cache"; return
  fi
  src="$WORKDIR/src/vesmaro-${sha}"
  rm -rf "$src"; mkdir -p "$src"
  info "server venv: git archive $ref @ $sha -> uv sync (py$pyver)"
  git -C "$repo" archive --format=tar "${ref}^{commit}" | tar -x -C "$src"
  ( cd "$src" && UV_PROJECT_ENVIRONMENT="$cache" uv sync --frozen --no-editable --no-dev --python "$PYBIN" ) \
    || die "uv sync failed for $ref"
  [[ -x "$cache/bin/mnemos" ]] || die "venv build produced no mnemos binary"
  echo "$cache"
}

go_arch() { case "$(uname -m)" in x86_64) echo amd64;; aarch64) echo arm64;; armv7l|armv6l) echo arm;; *) echo "";; esac; }

build_mesh() {  # build_mesh <repo> <ref> <expect_ver> -> echoes staging binary
  local repo="$1" ref="$2" expect="$3" out="$WORKDIR/staging/mnemos-mesh"
  mkdir -p "$WORKDIR/staging"
  if [[ -n "$MESH_BIN_FLAG" ]]; then
    [[ -x "$MESH_BIN_FLAG" ]] || die "--mesh-bin: $MESH_BIN_FLAG not executable"
    cp -f "$MESH_BIN_FLAG" "$out"
    local v; v="$(version_mesh "$out")"
    [[ -n "$expect" && "$v" != "$expect" ]] \
      && warn "--mesh-bin reports $v, ledger expects $expect — operator responsibility"
    info "mesh: prebuilt binary $MESH_BIN_FLAG ($v)"
    echo "$out"; return
  fi
  fetch_ref "$repo" "$ref"
  local src
  src="$WORKDIR/src/mnemos-mesh-$(git -C "$repo" rev-parse --short=12 "${ref}^{commit}")"
  rm -rf "$src"; mkdir -p "$src"
  git -C "$repo" archive --format=tar "${ref}^{commit}" | tar -x -C "$src"
  # gen/go (gRPC stubs) is GITIGNORED in mnemos-mesh (regenerated via
  # `make proto`, needs buf) — a pristine git archive lacks it. Reuse the
  # generated stubs from the source checkout when present.
  if [[ ! -d "$src/gen/go" && -d "$repo/gen/go" ]]; then
    mkdir -p "$src/gen"
    cp -a "$repo/gen/go" "$src/gen/go"
    info "mesh: gen/go stubs are gitignored — copied from the checkout at $repo"
  fi
  [[ -d "$src/gen/go" ]] || warn "mesh: $src/gen/go missing — build fails unless stubs are generated (cd $repo && make proto)"
  # toolchain: local go, else HOST go via distrobox-host-exec (laptop dev
  # box has no go; the host does). `go build -C dir` (go >= 1.20) keeps the
  # cwd identical for both; `env` makes CGO_ENABLED=0 survive host-exec.
  local -a go_cmd=(env CGO_ENABLED=0 go)
  if ! command -v go >/dev/null 2>&1; then
    if command -v distrobox-host-exec >/dev/null 2>&1 \
       && distrobox-host-exec go version >/dev/null 2>&1; then
      go_cmd=(distrobox-host-exec env CGO_ENABLED=0 go)
      info "mesh: no go in this container — building with the HOST toolchain (distrobox-host-exec)"
    else
      go_cmd=()
    fi
  fi
  if [[ ${#go_cmd[@]} -gt 0 ]]; then
    local ver="$expect"
    [[ -n "$ver" ]] || ver="git-$(git -C "$repo" rev-parse --short=8 "${ref}^{commit}")"
    info "mesh: go build $ref (stamp $ver)"
    "${go_cmd[@]}" build -C "$src" -trimpath \
        -ldflags "-s -w -X main.buildVersion=$ver" -o "$out" ./cmd/mnemos-mesh \
      || die "mesh go build failed"
  else
    local arch pre
    arch="$(go_arch)"
    pre="$repo/bin/mnemos-mesh-linux-${arch}"
    if [[ -n "$arch" && -x "$pre" ]]; then
      warn "mesh: no go toolchain — using repo prebuilt $pre (verify provenance)"
      cp -f "$pre" "$out"
    else
      die "mesh: no go toolchain and no prebuilt binary — install go or pass --mesh-bin"
    fi
  fi
  chmod 0755 "$out"
  echo "$out"
}

# ── template rendering ───────────────────────────────────────────────
tmpl_dir() {
  if [[ -n "$BUNDLE_DIR" && -d "${DEST}${BUNDLE_DIR}/templates" ]]; then
    echo "${DEST}${BUNDLE_DIR}/templates"
  else
    echo "$SCRIPT_DIR/templates"
  fi
}

render() {  # render <src> <dst> @@VAR@@=val ...
  local src="$1" dstf="$2"; shift 2
  python3 - "$src" "$dstf" "$@" <<'PY'
import sys, os
src, dst = sys.argv[1], sys.argv[2]
kv = dict(a.split("=", 1) for a in sys.argv[3:])
t = open(src).read()
for k, v in kv.items():
    t = t.replace(k, v)
os.makedirs(os.path.dirname(dst), exist_ok=True)
with open(dst, "w") as f:
    f.write(t)
PY
}

# ── units ────────────────────────────────────────────────────────────
profile_units() {  # echoes unit names for the profile
  if [[ "$PROFILE" == "laptop" ]]; then
    printf '%s\n%s\n' mnemos-prod-laptop.service mnemos-board-laptop.service
  else
    echo "mnemos-node.service"
  fi
  $WITHOUT_MESH || echo "mnemos-mesh.service"
}

unit_user_line() {
  if [[ "$STYLE" == "user" ]]; then echo "User=$USER_NAME";
  elif [[ "$RUN_USER" != "root" ]]; then echo "User=$RUN_USER";
  else echo "# (package default: no User= — unit runs as root)"; fi
}

emit_server_unit() {  # $1 name, $2 desc, $3 port, $4 conf, $5 envfile
  local td uline
  td="$(tmpl_dir)"
  uline="$(unit_user_line)"
  local mkdir_line="" harden=""
  if [[ "$STYLE" == "user" ]]; then
    mkdir_line="ExecStartPre=/usr/bin/mkdir -p @@RUNDIR@@"
  else
    harden=$'ProtectSystem=strict\nReadWritePaths=@@DATADIR@@\nRuntimeDirectory=vesmaro'
  fi
  render "$td/units/mnemos-server.service.tmpl" "${DEST}${UNITDIR}/$1.gen" \
    "@@UNIT_NAME@@=$1" "@@DESCRIPTION@@=$2" "@@USER_LINE@@=$uline" \
    "@@ENVFILE@@=$5" "@@MKDIR_LINE@@=$mkdir_line" "@@HARDENING@@=$harden" \
    "@@VENV@@=$VENV" "@@CONF@@=${CONFDIR}/$4" "@@PORT@@=$3" "@@RUNDIR@@=$RUNDIR" \
    "@@DATADIR@@=$DATADIR"
}

emit_mesh_unit() {
  local td uline
  td="$(tmpl_dir)"
  uline="$(unit_user_line)"
  local mkdir_line="" harden=""
  if [[ "$STYLE" == "user" ]]; then
    mkdir_line="ExecStartPre=/usr/bin/mkdir -p @@RUNDIR@@"
  else
    harden=$'ProtectSystem=strict\nRuntimeDirectory=vesmaro'
  fi
  render "$td/units/mnemos-mesh.service.tmpl" "${DEST}${UNITDIR}/mnemos-mesh.service.gen" \
    "@@USER_LINE@@=$uline" "@@MKDIR_LINE@@=$mkdir_line" "@@HARDENING@@=$harden" \
    "@@MESHBIN@@=$MESHBIN" "@@MESHCONF@@=${CONFDIR}/mesh.yaml" "@@RUNDIR@@=$RUNDIR"
}

install_units() {  # returns (echo) list of units whose files changed
  local changed="" u gen
  for u in $(profile_units); do
    if [[ "$u" == mnemos-mesh.service ]]; then
      $WITHOUT_MESH && continue
      emit_mesh_unit
    elif [[ "$PROFILE" == "laptop" && "$u" == mnemos-prod-laptop.service ]]; then
      emit_server_unit "$u" "vesmaro node core (laptop) — HTTP 127.0.0.1:8787 + mesh tcp 8790" \
        8787 node.yaml "${ENVDIR}/node.env"
    elif [[ "$PROFILE" == "laptop" && "$u" == mnemos-board-laptop.service ]]; then
      emit_server_unit "$u" "vesmaro node board face (laptop) — HTTP 0.0.0.0:8788, bearer auth" \
        8788 board.yaml "${ENVDIR}/board.env"
    else
      emit_server_unit "$u" "vesmaro node — memory server (HTTP 127.0.0.1:8787)" \
        8787 node.yaml "${ENVDIR}/node.env"
    fi
    gen="${DEST}${UNITDIR}/$u.gen"
    local final="${DEST}${UNITDIR}/$u"
    if [[ ! -f "$final" ]]; then
      mv "$gen" "$final"; chmod 0644 "$final"; changed+="$u "; info "unit installed: $u"
    elif cmp -s "$gen" "$final"; then
      rm -f "$gen"
    else
      local ours="no"
      manifest_exists && [[ "$(mf_get "units.${u%.service}")" != "" ]] && ours="yes"
      if [[ "$ours" == "yes" || $REGEN_UNITS ]]; then
        cp -f "$final" "${final}.bak-$(date +%Y%m%d%H%M%S)"
        mv "$gen" "$final"; chmod 0644 "$final"; changed+="$u "; info "unit updated: $u (backup kept)"
      else
        rm -f "$gen"
        warn "unit $u exists and differs from the bundle template — KEPT"
        warn "  (foreign unit; pass --regen-units to take it over)"
      fi
    fi
  done
  echo "$changed"
}

# ── configs / env / certs ────────────────────────────────────────────
install_configs() {
  local td; td="$(tmpl_dir)"
  local mesh_enabled="true"; $WITHOUT_MESH && mesh_enabled="false"
  deploy_yaml node.yaml node.yaml.tmpl \
    "@@PROFILE@@=$PROFILE" "@@STYLE@@=$STYLE" "@@RUN_USER@@=$RUN_USER" \
    "@@ENVDIR@@=$ENVDIR" "@@DATADIR@@=$DATADIR" "@@RUNDIR@@=$RUNDIR" \
    "@@PKIDIR@@=$PKIDIR" "@@MESH_ENABLED@@=$mesh_enabled"
  if [[ "$PROFILE" == "laptop" ]]; then
    deploy_yaml board.yaml board.yaml.tmpl \
      "@@ENVDIR@@=$ENVDIR" "@@DATADIR@@=$DATADIR" "@@RUNDIR@@=$RUNDIR"
  fi
  $WITHOUT_MESH || deploy_yaml mesh.yaml mesh.yaml.tmpl "@@NODE_ID@@=$NODE_ID" \
      "@@RUNDIR@@=$RUNDIR" "@@PKIDIR@@=$PKIDIR"
}

deploy_yaml() {  # $1 name, $2 template, rest @@VAR@@=..
  local name="$1" tmpl="$2"; shift 2
  local final="${DEST}${CONFDIR}/$name" td; td="$(tmpl_dir)"
  if [[ -f "$final" ]] && ! $RECONF; then
    info "config $name exists — kept (pass --reconf to regenerate, backup made)"
    return
  fi
  if [[ -f "$final" ]]; then cp -f "$final" "${final}.bak-$(date +%Y%m%d%H%M%S)"; fi
  render "$td/$tmpl" "$final" "$@"
  chmod 0644 "$final"
  ok "config: $final"
}

install_env_files() {
  local td; td="$(tmpl_dir)"
  mkdir -p "${DEST}${ENVDIR}"; chmod 0750 "${DEST}${ENVDIR}"
  if [[ ! -f "${DEST}${ENVDIR}/node.env" ]]; then
    python3 - "$td/env/node.env.example" "${DEST}${ENVDIR}/node.env" <<'PY'
import sys, os
src, dst = sys.argv[1], sys.argv[2]
with open(src) as f:
    t = f.read()
# O_EXCL: create-or-fail atomically at 0600 (umask-proof, no race)
fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as f:
    f.write(t)
PY
    ok "env: $(dst "$ENVDIR")/node.env (0600, created O_EXCL)"
  fi
  if [[ "$PROFILE" == "laptop" && ! -f "${DEST}${ENVDIR}/board.env" ]]; then
    python3 - "$td/env/board.env.example" "${DEST}${ENVDIR}/board.env" <<'PY'
import secrets, sys, os
src, dst = sys.argv[1], sys.argv[2]
t = open(src).read().replace("__GENERATED__", secrets.token_urlsafe(32))
fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as f: f.write(t)
PY
    ok "env: $(dst "$ENVDIR")/board.env (0600, TOTP master key AUTO-GENERATED)"
  fi
}

cert_hook() {
  $WITHOUT_MESH && return 0
  local pki; pki="$(dst "$PKIDIR")"
  if [[ -f "$pki/ca.pem" && -f "$pki/node.pem" && -f "$pki/core.pem" ]]; then
    ok "certs present in $pki — cert-gen skipped"
    return 0
  fi
  local repo; repo="$(default_mesh_repo)"
  local gcs="$repo/deploy/cluster/gen-certs.sh"
  if $GEN_CERTS && [[ -n "$repo" && -f "$gcs" ]]; then
    local out
    out="$pki.staging-$(date +%Y%m%d%H%M%S)"
    info "cert-gen: running gen-certs.sh -> $out (staging; nothing wired yet)"
    bash "$gcs" --out "$out" || warn "gen-certs.sh returned non-zero — inspect $out"
    cat <<EOF
  Next steps (manual, deliberate):
    1. cp $out/ca.pem $pki/ca.pem
    2. cp $out/<this-node>.pem $pki/node.pem   (+ .key, 0600)
    3. mint/reuse a CORE cert for the TCP leg -> $pki/core.pem|.key
    4. paste fingerprints into $(dst "$CONFDIR")/mesh.yaml (core_fingerprint,
       peer_fingerprints, peers[]) — then: systemctl restart mnemos-mesh
EOF
    return 0
  fi
  warn "mTLS certs not found in $pki — mesh will start single-node"
  warn "  cert-gen: rerun with --gen-certs (uses mnemos-mesh deploy/cluster/gen-certs.sh)"
  warn "  or provision $pki/{ca,node,core}.pem manually; see contrib/node-install/README.md"
}

# ── sqlite backup (pre-flight / upgrade) ─────────────────────────────
backup_sqlite() {  # $1 data_dir, $2 dest_dir
  python3 - "$1" "$2" <<'PY'
import glob, os, sqlite3, sys
src, dst = sys.argv[1], sys.argv[2]
os.makedirs(dst, exist_ok=True)
n = 0
for db in sorted(glob.glob(os.path.join(src, "*.db"))):
    name = os.path.basename(db)
    s = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
    d = sqlite3.connect(os.path.join(dst, name))
    s.backup(d)
    d.close(); s.close()
    print(f"    {name} -> {dst}/{name}", file=sys.stderr)
    n += 1
print(n)
PY
}

# ── health ───────────────────────────────────────────────────────────
wait_health() {  # $1 url, $2 timeout_s -> 0 ok
  local url="$1" t="${2:-60}" i=0
  while [[ $i -lt $((t / 2)) ]]; do
    curl -sf -m 2 "$url" >/dev/null 2>&1 && return 0
    i=$((i + 1)); sleep 2
  done
  return 1
}

mesh_metrics_port() {  # $1 mesh.yaml -> port (default 9091)
  local conf="$1"
  [[ -f "$conf" ]] || { echo 9091; return; }
  awk '/^metrics:/{f=1;next} f&&/listen:/{split($2,a,":"); gsub(/"/,"",a[2]); print a[2]; exit}' "$conf"
}

faces_for_profile() {
  if [[ "$PROFILE" == "laptop" ]]; then echo "8787 8788"; else echo "8787"; fi
}

# ── self + bundle copy ───────────────────────────────────────────────
install_bundle_copy() {
  if [[ "${DEST}${BUNDLE_DIR}" == "$SCRIPT_DIR" ]]; then return; fi
  mkdir -p "${DEST}${BUNDLE_DIR}"
  cp -f "$SCRIPT_DIR/vesmaro-node" "${DEST}${BUNDLE_DIR}/vesmaro-node"
  cp -f "$TSV" "${DEST}${BUNDLE_DIR}/compatibility.tsv"
  if [[ -d "$SCRIPT_DIR/templates" ]]; then
    rm -rf "${DEST}${BUNDLE_DIR}/templates"
    cp -a "$SCRIPT_DIR/templates" "${DEST}${BUNDLE_DIR}/templates"
  fi
  if [[ -f "$SCRIPT_DIR/README.md" ]]; then cp -f "$SCRIPT_DIR/README.md" "${DEST}${BUNDLE_DIR}/"; fi
  chmod 0755 "${DEST}${BUNDLE_DIR}/vesmaro-node"
  local bindir="${DEST}/usr/local/sbin"
  [[ "$STYLE" == "user" ]] && bindir="$(dst "$(user_home "${USER_NAME:-$RUN_USER}")/.local/bin")"
  mkdir -p "$bindir"
  ln -sf "${BUNDLE_DIR}/vesmaro-node" "$bindir/vesmaro-node"
  ok "bundle copy: $BUNDLE_DIR (CLI: $bindir/vesmaro-node)"
}

# ── adopt (legacy laptop prod) ───────────────────────────────────────
adopt_legacy() {
  local home leg_venv leg_bin leg_conf u enabled=0
  home="$LEG_HOME"
  leg_venv="$home/.local/share/mnemos-prod/venv"
  leg_bin="$home/.local/bin/mnemos-mesh"
  leg_conf="$home/.config/mnemos-mesh"
  [[ -x "$leg_venv/bin/mnemos" ]] || die "adopt: no legacy venv at $leg_venv"
  [[ -x "$leg_bin" ]] || die "adopt: no legacy mesh binary at $leg_bin"
  for u in mnemos-prod-laptop mnemos-board-laptop mnemos-mesh; do
    sys_real is-enabled "$u.service" >/dev/null 2>&1 && enabled=$((enabled + 1))
  done
  [[ $enabled -ge 2 ]] || warn "adopt: only $enabled/3 legacy units enabled — adopting anyway"
  local cv mv row_ct row_mr
  cv="$(version_core "$leg_venv/bin/mnemos")"
  mv="$(version_mesh "$leg_bin")"
  # pair identity: look the live mesh version up in the ledger (tag-based
  # core identity; the binary --version string is informational only)
  row_ct="$(tsv_rows | awk -F'|' -v v="$mv" '$3==v{print $1; exit}')"
  row_mr="$(tsv_rows | awk -F'|' -v v="$mv" '$3==v{print $2; exit}')"
  if [[ -z "$row_ct" ]]; then
    row_ct="unknown"; row_mr="${mv:-unknown}"
    warn "adopt: live mesh version '${mv:-?}' is not in the ledger — pair recorded as unknown"
  fi
  mf_write "style=user" "user=$USER_NAME" "run_user=$USER_NAME" "profile=laptop" \
    "adopted=yes" \
    "adopted_venv=$leg_venv" "adopted_meshbin=$leg_bin" \
    "adopted_core_conf=$leg_conf/mnemos-laptop-prod.yaml" \
    "adopted_board_conf=$leg_conf/mnemos-laptop-board.yaml" \
    "adopted_mesh_conf=$leg_conf/mesh-laptop.yaml" \
    "core_version=$cv" "mesh_version=$mv" "mesh_sha256=$(sha256_of "$leg_bin")" \
    "core_tag=$row_ct" "mesh_ref=$row_mr" "dest=" \
    "units.core=mnemos-prod-laptop.service" \
    "units.board=mnemos-board-laptop.service" "units.mesh=mnemos-mesh.service"
  ok "adopted legacy laptop node into $MANIFEST (NOTHING was modified on disk)"
  info "vesmaro $cv | mesh $mv | units: $enabled/3 enabled"
  info "manage the adopted node with: $SCRIPT_NAME status | upgrade --check"
  warn "adopted nodes refuse bundle upgrades — migration to managed paths is a separate op"
}

# ── upgrade rollback (B2) ────────────────────────────────────────────
# Restores the pre-upgrade pair from the *.prev-<ts> copies. Called both
# from the health-verify failure path and from the ERR trap covering the
# swap..verify area (disk full / cp abort / anything) so the node is
# never left without a venv or on a mixed pair. Guards: single-shot
# (RB_ACTIVE), tolerates prev copies missing (an abort may happen before
# the prev was created), re-verifies faces + mesh healthz afterwards.
RB_ACTIVE=false
RB_DSTVENV=""; RB_DSTBIN=""; RB_VPREV=""; RB_MPREV=""
RB_CORE_UNIT=""; RB_BOARD_UNIT=""; RB_MESH_UNIT=""; RB_MPORT=""
RB_CUR_CV=""; RB_CUR_MV=""

rollback_pair() {
  $RB_ACTIVE || return 0
  RB_ACTIVE=false
  warn "rollback: restoring the pre-upgrade pair (${RB_CUR_CV:-?}, ${RB_CUR_MV:-no-mesh})"
  if [[ -n "$RB_VPREV" && -d "$RB_VPREV" ]]; then
    rm -rf "$RB_DSTVENV"
    mv "$RB_VPREV" "$RB_DSTVENV"
    ok "rollback: venv restored from $(basename "$RB_VPREV")"
  elif [[ -d "$RB_DSTVENV" ]]; then
    warn "rollback: no venv prev copy — keeping current $RB_DSTVENV (unverified)"
  else
    warn "rollback: venv MISSING ($RB_DSTVENV) and no prev copy — node left without a venv, investigate manually"
  fi
  if [[ -n "$RB_DSTBIN" ]]; then
    if [[ -n "$RB_MPREV" && -f "$RB_MPREV" ]]; then
      if [[ -f "$RB_DSTBIN" ]]; then mv "$RB_DSTBIN" "${RB_DSTBIN}.rollback-broken"; fi
      mv "$RB_MPREV" "$RB_DSTBIN"
      ok "rollback: mesh binary restored from $(basename "$RB_MPREV")"
    elif [[ -f "$RB_DSTBIN" ]]; then
      warn "rollback: no mesh prev copy — keeping current $RB_DSTBIN (unverified)"
    else
      warn "rollback: mesh binary MISSING ($RB_DSTBIN) and no prev copy"
    fi
  fi
  local u
  for u in "$RB_CORE_UNIT" "$RB_BOARD_UNIT" "$RB_MESH_UNIT"; do
    if [[ -n "$u" ]]; then sys restart "$u"; fi
  done
  sleep 3
  if [[ -z "${VESMARO_TEST_FAIL_VERIFY:-}" ]] && ! $SANDBOX; then
    local p
    for p in $(faces_for_profile); do
      wait_health "http://127.0.0.1:$p/health" 60 \
        || warn "ROLLBACK face :$p still unhealthy — investigate manually"
    done
    if [[ -n "$RB_MESH_UNIT" ]]; then
      curl -sf -m 2 "http://127.0.0.1:$RB_MPORT/healthz" >/dev/null 2>&1 \
        || warn "ROLLBACK mesh healthz still down on :$RB_MPORT — investigate manually"
    fi
  else
    warn "rollback re-verify skipped (sandbox/test mode) — check files and versions manually"
  fi
}

upgrade_swap_err() {  # ERR trap for the upgrade swap..verify area
  local rc=$?
  trap - ERR
  warn "upgrade aborted mid-swap (rc=$rc) — invoking rollback"
  rollback_pair
  die "upgrade FAILED mid-swap, rollback applied — inspect the node before retrying"
}

verify_health() {  # $1 url, $2 timeout -> 0 ok (sandbox/test aware)
  if [[ -n "${VESMARO_TEST_FAIL_VERIFY:-}" ]]; then
    warn "TEST: VESMARO_TEST_FAIL_VERIFY is set — verify forced to FAIL ($1)"
    return 1
  fi
  if $SANDBOX; then
    printf "  [dry] health verify %s — skipped (sandbox, nothing runs under --dest)\n" "$1" >&2
    return 0
  fi
  wait_health "$1" "$2"
}

# ── container install (flatpak-style, rootless) ──────────────────────
# Pulls READY images from ghcr (ghcr tag == git release tag) and drives
# them with quadlets under systemd --user. The container carries CODE
# ONLY — configs, pki and data stay on the HOST (bind-mounts,
# local-first). v1 runs on the HOST network namespace (documented
# compromise: the mesh container dials the core on 127.0.0.1:8790 and
# peers reach :8443; bridge + published ports is the follow-up).

in_container_ctx() {
  [[ -n "${DISTROBOX_NAME:-}" || -e /run/.containerenv ]]
}

# getent must resolve the HOST home (inside a distrobox it answers the
# box's $HOME — the known adopted-gate caveat; here we WANT the box
# context, so route through distrobox-host-exec).
host_getent_home() {  # $1 user -> host home dir
  if in_container_ctx && command -v distrobox-host-exec >/dev/null 2>&1; then
    distrobox-host-exec getent passwd "$1" 2>/dev/null | cut -d: -f6
  else
    getent passwd "$1" | cut -d: -f6
  fi
}

podman_bin() {  # echoes the podman command (HOST storage, rootless)
  if in_container_ctx; then
    command -v distrobox-host-exec >/dev/null 2>&1 && { echo "distrobox-host-exec podman"; return; }
    die "inside a container but no distrobox-host-exec — run on the host or from the dev box"
  fi
  command -v podman >/dev/null 2>&1 && { echo "podman"; return; }
  echo ""
}

userctl_bin() {  # echoes a working `systemctl --user` command (or "")
  if in_container_ctx; then
    command -v distrobox-host-exec >/dev/null 2>&1 \
      && distrobox-host-exec systemctl --user is-system-running >/dev/null 2>&1 \
      && { echo "distrobox-host-exec systemctl --user"; return; }
    echo ""
  fi
  command -v systemctl >/dev/null 2>&1 \
    && systemctl --user is-system-running >/dev/null 2>&1 \
    && { echo "systemctl --user"; return; }
  echo ""
}

# container paths (rootless: everything under the REAL host $HOME)
CT_HOME=""; CT_CONFDIR=""; CT_DATADIR=""; CT_STATEDIR=""; CT_QUADDIR=""
CT_RUNDIR=""; CT_MANIFEST=""
compute_container_paths() {
  local who="${USER_NAME:-$(id -un)}"
  [[ "$(id -u)" == 0 ]] && die "container-install is ROOTLESS-only (systemd --user + rootless podman) — run as the owning user"
  CT_HOME="$(host_getent_home "$who")"
  [[ -n "$CT_HOME" && "$CT_HOME" != "/" ]] || die "no home resolved for $who (host getent)"
  CT_CONFDIR="$CT_HOME/.config/vesmaro"
  CT_DATADIR="${CT_DATA_DIR:-$CT_HOME/.local/share/vesmaro/data}"
  CT_STATEDIR="$CT_HOME/.local/state/vesmaro"
  CT_QUADDIR="$CT_HOME/.config/containers/systemd"
  CT_RUNDIR="$CT_STATEDIR/container-run"
  CT_MANIFEST="${DEST}${CT_STATEDIR}/container-manifest.json"
}

certs_present() {
  local p="${DEST}${CT_CONFDIR}/pki"
  [[ -f "$p/ca.pem" && -f "$p/core.pem" && -f "$p/core.key" \
     && -f "$p/node.pem" && -f "$p/node.key" ]]
}

ct_units() {  # echoes unit names for the current effective pair
  echo "${CT_NAME}-core.service"
  $CT_MESH_ON || return 0
  echo "${CT_NAME}-mesh.service"
}

render_quadlet() {  # $1 tmpl  $2 out  rest @@VAR@@=..
  render "$1" "$2" "${@:3}"
}

cmd_container_install() {
  detect_sys   # keeps sys* harmless if anything calls it
  compute_container_paths
  CT_NAME="${CT_NAME:-vesmaro}"
  CONFDIR="$CT_CONFDIR"   # deploy_yaml renders into $CONFDIR — point it at the container confdir

  # ── resolve the pair from the ledger (image tag == git tag) ─────
  if [[ -z "$CT_TAG" && -z "$CT_MESH_TAG" ]]; then
    local row; row="$(tsv_last)"
    [[ -n "$row" ]] || die "ledger empty — add a pair to compatibility.tsv (or pass --tag/--mesh-tag with --force)"
    CT_TAG="$(cut -d'|' -f1 <<<"$row")"
    CT_MESH_TAG="$(cut -d'|' -f2 <<<"$row")"
  else
    CT_TAG="${CT_TAG:-$(tsv_last | cut -d'|' -f1)}"
    CT_MESH_TAG="${CT_MESH_TAG:-$(tsv_last | cut -d'|' -f2)}"
    if ! $FORCE && ! pair_known "$CT_TAG" "$CT_MESH_TAG"; then
      warn "pair ($CT_TAG, $CT_MESH_TAG) is not in the ledger"
      list_known_pairs
      die "refusing unverified pair — add it to compatibility.tsv or pass --force"
    fi
  fi
  CT_PORT="${CT_PORT:-8787}"
  [[ "$CT_NETWORK" == "host" ]] || die "v1 supports --network host only (bridge + published ports is the follow-up; see README)"

  # mesh leg: explicit --without-mesh, or auto-off when no mTLS material
  CT_MESH_ON=true
  $WITHOUT_MESH && CT_MESH_ON=false
  if $CT_MESH_ON && ! certs_present; then
    CT_MESH_ON=false
    warn "mTLS certs not found in ${CT_CONFDIR}/pki — installing CORE-ONLY"
    warn "  (the core fails fast on a TCP leg without readable certs; provision certs + rerun container-install to add the mesh container — idempotent)"
  fi

  local POD=""
  [[ -z "$DEST" ]] && { POD="$(podman_bin)"; [[ -n "$POD" ]] || die "no podman reachable (rootless, host storage)"; }
  local UCTL=""
  [[ -z "$DEST" ]] && { UCTL="$(userctl_bin)"; [[ -n "$UCTL" ]] || die "no systemd --user reachable (needed for quadlets)"; }

  # ── (a) pull READY images (never build here) ────────────────────
  if [[ -n "$POD" ]]; then
    $POD login ghcr.io --get-login >/dev/null 2>&1 \
      || warn "not logged in to ghcr.io (private registry) — pull may fail; run: podman login ghcr.io"
    info "pull: $GHCR_CORE:$CT_TAG"
    $POD pull "$GHCR_CORE:$CT_TAG" || die "pull failed: $GHCR_CORE:$CT_TAG"
    if $CT_MESH_ON; then
      info "pull: $GHCR_MESH:$CT_MESH_TAG"
      $POD pull "$GHCR_MESH:$CT_MESH_TAG" || die "pull failed: $GHCR_MESH:$CT_MESH_TAG"
    fi
  else
    printf "  [dry] podman pull %s:%s\n" "$GHCR_CORE" "$CT_TAG" >&2
    $CT_MESH_ON && printf "  [dry] podman pull %s:%s\n" "$GHCR_MESH" "$CT_MESH_TAG" >&2
  fi

  # ── host dirs (data/pki/run stay local-first) ───────────────────
  mkdir -p "${DEST}${CT_DATADIR}/data" "${DEST}${CT_DATADIR}/vault" \
           "${DEST}${CT_CONFDIR}/pki" "${DEST}${CT_RUNDIR}" "${DEST}${CT_QUADDIR}" "${DEST}${CT_STATEDIR}"

  # ── configs (kept on re-runs unless --reconf) ───────────────────
  local td; td="$(tmpl_dir)"
  local mesh_enabled="false"; $CT_MESH_ON && mesh_enabled="true"
  deploy_yaml node.container.yaml containers/node.container.yaml.tmpl \
    "@@PORT@@=$CT_PORT" "@@MESH_ENABLED@@=$mesh_enabled"
  if $CT_MESH_ON; then
    deploy_yaml mesh.container.yaml containers/mesh.container.yaml.tmpl \
      "@@NODE_ID@@=${NODE_ID:-$(hostname -s)}"
  fi

  # ── quadlets (ours by construction: namespaced <name>-{core,mesh}) ─
  local mesh_vols=""
  if $CT_MESH_ON; then
    mesh_vols="Volume=${CT_CONFDIR}/pki:/etc/vesmaro/pki:ro
Volume=${CT_STATEDIR}/container-run:/run/vesmaro"
  fi
  local changed_units="" q final gen
  render_quadlet "$td/containers/vesmaro-core.container.tmpl" "${DEST}${CT_QUADDIR}/${CT_NAME}-core.container.gen" \
    "@@CORE_TAG@@=$CT_TAG" "@@NAME@@=$CT_NAME" "@@PORT@@=$CT_PORT" \
    "@@CONFDIR@@=$CT_CONFDIR" "@@DATADIR@@=$CT_DATADIR" "@@MESH_VOLUMES@@=$mesh_vols"
  local -a tmpls=( "core" )
  $CT_MESH_ON && tmpls+=( "mesh" )
  if $CT_MESH_ON; then
    render_quadlet "$td/containers/vesmaro-mesh.container.tmpl" "${DEST}${CT_QUADDIR}/${CT_NAME}-mesh.container.gen" \
      "@@MESH_TAG@@=$CT_MESH_TAG" "@@NAME@@=$CT_NAME" "@@NODE_ID@@=${NODE_ID:-$(hostname -s)}" \
      "@@CONFDIR@@=$CT_CONFDIR" "@@STATEDIR@@=$CT_STATEDIR"
  fi
  for q in "${tmpls[@]}"; do
    gen="${DEST}${CT_QUADDIR}/${CT_NAME}-${q}.container.gen"
    final="${DEST}${CT_QUADDIR}/${CT_NAME}-${q}.container"
    if [[ ! -f "$final" ]]; then
      mv "$gen" "$final"; changed_units+="${CT_NAME}-${q}.service "
      info "quadlet installed: $(basename "$final")"
    elif cmp -s "$gen" "$final"; then
      rm -f "$gen"
    else
      cp -f "$final" "${final}.bak-$(date +%Y%m%d%H%M%S)"
      mv "$gen" "$final"; changed_units+="${CT_NAME}-${q}.service "
      info "quadlet updated: $(basename "$final") (backup kept — rollback = restore the .bak + daemon-reload)"
    fi
  done

  # ── manifest (pair identity for idempotency/uninstall) ──────────
  MANIFEST="$CT_MANIFEST"
  local first=true
  manifest_exists && first=false
  mf_write "mode=container" "name=$CT_NAME" "user=${USER_NAME:-$(id -un)}" \
    "core_tag=$CT_TAG" "mesh_tag=$CT_MESH_TAG" "mesh_on=$CT_MESH_ON" \
    "port=$CT_PORT" "data_dir=$CT_DATADIR" "confdir=$CT_CONFDIR" \
    "quaddir=$CT_QUADDIR" "network=$CT_NETWORK" "dest=$DEST" \
    "images=$GHCR_CORE:$CT_TAG,$GHCR_MESH:$CT_MESH_TAG" \
    "units.core=${CT_NAME}-core.service" "adopted=no"
  $CT_MESH_ON && mf_write "units.mesh=${CT_NAME}-mesh.service"

  # ── start (quadlet -> generated systemd --user units) ──────────
  # NOTE: quadlet-generated units cannot be `systemctl enable`d (they
  # are "generated" — enable refuses); the [Install] section of the
  # .container file makes the generator enable them persistently, so
  # daemon-reload + start is the whole story.
  if [[ -n "$UCTL" ]]; then
    $UCTL daemon-reload
    local u
    for u in $(ct_units); do
      if [[ " $changed_units " == *" $u "* ]] || ! $UCTL is-active "$u" >/dev/null 2>&1; then
        $UCTL start "$u" || die "unit $u failed to start — check: $UCTL status $u / journalctl --user -u $u"
      else
        info "unit $u already active and unchanged"
      fi
    done
    # health verify (core face)
    if ! wait_health "http://127.0.0.1:$CT_PORT/health" 90; then
      warn "core face :$CT_PORT did not become healthy in 90s"
      warn "  inspect: $POD logs ${CT_NAME}-core ; rollback: restore the quadlet .bak + daemon-reload"
      die "container install FAILED health verify"
    fi
    ok "health: http://127.0.0.1:$CT_PORT/health -> 200"
  else
    local u
    for u in $(ct_units); do printf "  [dry] systemctl --user start %s (enabled by the quadlet generator)\n" "$u" >&2; done
  fi

  echo
  ok "container install complete — $GHCR_CORE:$CT_TAG$( $CT_MESH_ON && echo " + $GHCR_MESH:$CT_MESH_TAG" ), name $CT_NAME, port $CT_PORT"
  info "data (host, bind-mounted): $CT_DATADIR/{data,vault}"
  [[ -n "$DEST" ]] && info "SANDBOX: layout under $DEST — nothing pulled/enabled"
  $first || info "re-provision (manifest updated; unchanged quadlets were not restarted)"
}

cmd_container_uninstall() {
  compute_container_paths
  [[ -f "$CT_MANIFEST" ]] || die "no container install recorded at $CT_MANIFEST"
  MANIFEST="$CT_MANIFEST"
  CT_NAME="$(mf_get name)"; CT_NAME="${CT_NAME:-vesmaro}"
  CT_MESH_ON=false; [[ "$(mf_get mesh_on)" == "true" ]] && CT_MESH_ON=true
  local POD="" UCTL=""
  if [[ -z "$DEST" ]]; then POD="$(podman_bin)"; UCTL="$(userctl_bin)"; fi

  local u q
  for u in $(ct_units); do
    [[ -n "$UCTL" ]] && { $UCTL disable --now "$u" 2>/dev/null || true; }
  done
  if [[ -n "$POD" ]]; then
    $POD rm -f "${CT_NAME}-core" >/dev/null 2>&1 && info "removed container ${CT_NAME}-core"
    $POD rm -f "${CT_NAME}-mesh" >/dev/null 2>&1 && info "removed container ${CT_NAME}-mesh"
  fi
  for q in core mesh; do
    rm -f "${DEST}${CT_QUADDIR}/${CT_NAME}-${q}.container" \
          "${DEST}${CT_QUADDIR}/${CT_NAME}-${q}.container.gen" 2>/dev/null || true
    rm -f "${DEST}${CT_QUADDIR}"/"${CT_NAME}-${q}".container.bak-* 2>/dev/null || true
  done
  [[ -n "$UCTL" ]] && $UCTL daemon-reload

  if $PURGE; then
    local img
    if [[ -n "$POD" ]]; then
      for img in $(mf_get images | tr ',' ' '); do
        $POD rmi "$img" >/dev/null 2>&1 && info "removed image $img" || warn "image not removed: $img (in use?)"
      done
    fi
    rm -f "${DEST}${CT_CONFDIR}/node.container.yaml" "${DEST}${CT_CONFDIR}/mesh.container.yaml"
    rm -rf "${DEST}${CT_RUNDIR}"
    if $KEEP_DATA; then
      ok "uninstalled (--purge + --keep-data): containers+units+images+configs removed; DATA KEPT at $(mf_get data_dir)"
    else
      rm -rf "$(mf_get data_dir)" && info "removed data dir $(mf_get data_dir)"
      ok "uninstalled (--purge): containers+units+images+configs+data removed"
    fi
  else
    ok "uninstalled: containers + units removed; images, configs and DATA kept (data: $(mf_get data_dir))"
    info "full cleanup: rerun with --purge (add --keep-data to spare the data dir)"
  fi
  rm -f "$CT_MANIFEST"
}

container_status() {  # appended to `status` when a container manifest exists
  compute_container_paths
  [[ -f "$CT_MANIFEST" ]] || return 1
  local saved_manifest="$MANIFEST"; MANIFEST="$CT_MANIFEST"
  CT_NAME="$(mf_get name)"
  local POD; POD="$(podman_bin)"
  echo "  containers: name=$(mf_get name) core=$(mf_get core_tag) mesh=$(mf_get mesh_tag)$( [[ "$(mf_get mesh_on)" == "false" ]] && echo " (core-only)")"
  if [[ -n "$POD" ]]; then
    while IFS= read -r line; do
      [[ -n "$line" ]] && printf '    %s\n' "$line"
    done < <($POD ps -a --filter "name=$(mf_get name)-" --format '{{.Names}} {{.Status}}' 2>/dev/null)
    local h
    h="$(curl -sf -m 2 "http://127.0.0.1:$(mf_get port)/health" 2>/dev/null || true)"
    printf "    127.0.0.1:%-5s /health %s\n" "$(mf_get port)" "$([[ -n $h ]] && echo OK || echo DOWN)"
  fi
  MANIFEST="$saved_manifest"
  return 0
}

# ── INSTALL / UPGRADE ────────────────────────────────────────────────

# Mutating commands must resolve paths the SAME way the manifest was
# written. Inside a distrobox container getent resolves a different
# $HOME (~/.distrobox/<box>/home), so an adopted node's manifest is
# invisible → the adopted-gate would be bypassed and a fresh install
# attempted against live legacy paths. Refuse with the way out instead.
require_host_context() {
  if [[ -n "${DEST}" ]]; then return 0; fi   # sandbox layouts are self-contained
  if [[ -e /run/.containerenv || -n "${DISTROBOX_NAME:-}" ]]; then
    die "refusing to run a mutating command from inside a container \
(distrobox \$HOME differs from the host's — the manifest and the \
adopted-gate would be bypassed). Run from the host: \
distrobox-host-exec bash .../vesmaro-node $SCRIPT_CMD --user abyss \
(see the Context section of contrib/node-install/README.md)."
  fi
}

cmd_install() {
  detect_sys
  compute_paths
  require_root_unless_sandbox
  require_host_context

  NODE_ID="${NODE_ID:-$(hostname -s)}"
  # inherit profile from an existing manifest unless given explicitly
  if [[ -z "$PROFILE" ]]; then
    if manifest_exists && [[ -n "$(mf_get profile)" ]]; then PROFILE="$(mf_get profile)"; else PROFILE="node"; fi
  fi

  if $ADOPT; then
    [[ "$STYLE" == "user" ]] || die "--adopt applies to --user style (legacy laptop runs from \$HOME)"
    [[ -n "$USER_NAME" ]] || die "--adopt needs --user NAME"
    mkdir -p "${DEST}${STATEDIR}"
    adopt_legacy
    return
  fi

  # B1: an adopted (legacy live) node must never be silently overwritten
  # by a bundle install — the only way in is an explicit migration, which
  # is a separate operation (and deliberately not implemented yet).
  if manifest_exists && [[ "$(mf_get adopted)" == "yes" ]]; then
    if $MIGRATE_ADOPTED; then
      die "--migrate-adopted: migrating a legacy adopted node onto managed bundle paths is a SEPARATE operation, not implemented in this slice — see the Adopt section of contrib/node-install/README.md"
    fi
    die "node is ADOPTED (legacy live install, manifest $MANIFEST) — bundle install would replace legacy units/binaries; pass --migrate-adopted once that op exists. Nothing was modified."
  fi

  pick_python
  resolve_release

  # M1: for an EXISTING managed install the pair identity (core_tag +
  # mesh_ref), not the --version string, decides what happens:
  #   same pair    -> idempotent re-provision (manifest NOT touched —
  #                   rewriting core_tag here used to fake "no updates")
  #   changed pair -> route through the UPGRADE path so the swap gets
  #                   backup + verify + rollback semantics.
  local first_provision=true
  if manifest_exists; then
    first_provision=false
    local cur_t cur_m pair_same=false
    cur_t="$(mf_get core_tag)"; cur_m="$(mf_get mesh_ref)"
    if $WITHOUT_MESH; then
      if [[ "$cur_t" == "$CORE_TAG" ]]; then pair_same=true; fi
    else
      if [[ "$cur_t" == "$CORE_TAG" && "$cur_m" == "$MESH_REF" ]]; then pair_same=true; fi
    fi
    if $pair_same; then
      info "existing install at the same pair (tag $cur_t) — idempotent re-provision, manifest untouched"
    else
      info "pair change ($cur_t, ${cur_m:-no-mesh}) -> ($CORE_TAG, $MESH_REF) on an existing install — routing through the upgrade path"
      cmd_upgrade
      return $?
    fi
  fi

  local vrepo mrepo vcache mbin new_cv new_mv changed_units=""
  vrepo="$(ensure_repo vesmaro "$(default_vesmaro_repo)" git@github.com:vesmaro/vesmaro.git)"
  $WITHOUT_MESH || mrepo="$(ensure_repo mnemos-mesh "$(default_mesh_repo)" git@github.com:vesmaro/vesma-mesh.git)"

  info "target pair: core $CORE_TAG + mesh $MESH_REF (${PAIR_MODE})"

  vcache="$(build_venv "$vrepo" "$CORE_REF")"
  new_cv="$(version_core "$vcache/bin/mnemos")"
  [[ -n "$new_cv" ]] || die "built venv has no working mnemos CLI"
  if ! $WITHOUT_MESH; then
    mbin="$(build_mesh "$mrepo" "$MESH_REF" "$EXPECT_MESHV")"
    new_mv="$(version_mesh "$mbin")"
  fi

  # ── venv deploy (idempotent: version compare + swap with prev) ──
  local venv_changed=false
  local dst_venv; dst_venv="$(dst "$VENV")"
  local old_cv=""
  [[ -x "$dst_venv/bin/mnemos" ]] && old_cv="$(version_core "$dst_venv/bin/mnemos")"
  if [[ "$old_cv" == "$new_cv" && -x "$dst_venv/bin/mnemos" ]]; then
    ok "server venv already at $new_cv — untouched"
  else
    if [[ -d "$dst_venv" ]]; then
      # timestamped prev names sort chronologically (version-suffixed
      # names sorted 4.10.0 before 4.9.0 and broke pruning)
      mv "$dst_venv" "${dst_venv}.prev-$(date +%Y%m%d%H%M%S)"
    fi
    mkdir -p "$(dirname "$dst_venv")"
    cp -a "$vcache" "$dst_venv"
    venv_changed=true
    ok "server venv deployed: $new_cv ($(du -sh "$dst_venv" | cut -f1))"
  fi

  # ── mesh binary deploy ──────────────────────────────────────────
  local mesh_changed=false old_mv=""
  local dst_bin; dst_bin="$(dst "$MESHBIN")"
  if ! $WITHOUT_MESH; then
    if [[ -x "$dst_bin" ]] && [[ "$(sha256_of "$dst_bin")" == "$(sha256_of "$mbin")" ]]; then
      ok "mesh binary unchanged ($(sha256_of "$dst_bin" | cut -c1-12)) — untouched"
    else
      if [[ -x "$dst_bin" ]]; then
        old_mv="$(version_mesh "$dst_bin")"
        mv "$dst_bin" "${dst_bin}.prev-$(date +%Y%m%d%H%M%S)"
      fi
      mkdir -p "$(dirname "$dst_bin")"
      cp -f "$mbin" "$dst_bin"; chmod 0755 "$dst_bin"
      mesh_changed=true
      if [[ -n "$old_mv" ]]; then
        ok "mesh binary deployed: $new_mv (prev kept: ${MESHBIN}.prev-<ts>)"
      else
        ok "mesh binary deployed: $new_mv"
      fi
    fi
  fi

  # ── configs / env / units ───────────────────────────────────────
  install_configs
  install_env_files
  mkdir -p "$(dst "$DATADIR")" "$(dst "$PKIDIR")" "$(dst "$RUNDIR")" 2>/dev/null || true
  if [[ "$STYLE" == "user" && -z "$DEST" ]]; then
    chown -R "$USER_NAME:" "$(dst "$DATADIR")" "$(dst "$STATEDIR")" "$(dst "$ENVDIR")" 2>/dev/null || true
  fi
  changed_units="$(install_units)"
  local u restart_set=""
  if [[ -n "$changed_units" ]] && ! $SYS_DRY; then
    sys daemon-reload
    # a unit whose FILE changed needs a restart too, not just a reload
    for u in $changed_units; do
      if sys_real is-active "$u" >/dev/null 2>&1; then
        sys restart "$u"
        restart_set="$restart_set $u"
      fi
    done
  fi

  cert_hook

  # ── manifest BEFORE enable (so units become "ours" for next runs);
  # M1: written on FIRST provisioning only — a same-pair re-provision
  # must not rewrite core_tag/version fields it did not deploy.
  if $first_provision; then
    mf_write "style=$STYLE" "user=${USER_NAME:-}" "run_user=$RUN_USER" "profile=$PROFILE" \
      "dest=$DEST" "core_version=$new_cv" "core_tag=$CORE_TAG" "core_ref=$(git -C "$vrepo" rev-parse --short=12 "${CORE_REF}^{commit}" 2>/dev/null || echo "$CORE_REF")" \
      "venv=$VENV" "meshbin=$MESHBIN" "confdir=$CONFDIR" "envdir=$ENVDIR" "pki_dir=$PKIDIR" \
      "data_dir=$DATADIR" "rundir=$RUNDIR" "bundle_dir=$BUNDLE_DIR" "pair_mode=$PAIR_MODE" "adopted=no"
    if ! $WITHOUT_MESH; then
      mf_write "mesh_version=$new_mv" "mesh_sha256=$(sha256_of "$dst_bin")" "mesh_ref=$MESH_REF"
    fi
    if [[ "$PROFILE" == "laptop" ]]; then
      mf_write "units.core=mnemos-prod-laptop.service" "units.board=mnemos-board-laptop.service"
    else
      mf_write "units.core=mnemos-node.service"
    fi
    $WITHOUT_MESH || mf_write "units.mesh=mnemos-mesh.service"
  fi

  install_bundle_copy

  # ── enable / restart ────────────────────────────────────────────
  if $venv_changed; then
    for u in $(profile_units | grep -v mesh); do restart_set="$restart_set $u"; done
  fi
  $mesh_changed && restart_set="$restart_set mnemos-mesh.service"
  if ! $NO_ENABLE && ! $SYS_DRY; then
    for u in $(profile_units); do
      if ! sys_real is-enabled "$u" >/dev/null 2>&1; then
        sys enable "$u"
      fi
      if ! sys_real is-active "$u" >/dev/null 2>&1; then
        sys start "$u"
      elif [[ " $restart_set " == *" $u "* ]]; then
        sys restart "$u"
      fi
    done
  elif $SYS_DRY; then
    for u in $(profile_units); do printf "  [dry] systemctl enable --now %s\n" "$u" >&2; done
  fi

  echo
  ok "install complete — pair (vesmaro $new_cv, mesh ${new_mv:-n/a}), profile $PROFILE, style $STYLE"
  $SANDBOX && info "SANDBOX: layout under $DEST — nothing enabled, no systemd calls"
}

# ── UPGRADE (atomic pair) ────────────────────────────────────────────
cmd_upgrade() {
  detect_sys
  compute_paths
  require_root_unless_sandbox
  require_host_context
  manifest_exists || die "no install recorded at $MANIFEST — run 'install' first"
  [[ -n "$PROFILE" ]] || PROFILE="$(mf_get profile)"
  local adopted; adopted="$(mf_get adopted)"
  local cur_cv cur_mv cur_tag cur_meshref
  cur_cv="$(mf_get core_version)"; cur_mv="$(mf_get mesh_version)"
  cur_tag="$(mf_get core_tag)"; cur_meshref="$(mf_get mesh_ref)"

  pick_python
  resolve_release
  info "target pair: core $CORE_TAG + mesh $MESH_REF (${PAIR_MODE})"

  # mesh leg present? (a --without-mesh install records no meshbin; an
  # explicit --without-mesh also narrows THIS upgrade to the core; an
  # ADOPTED manifest records the mesh under adopted_meshbin instead)
  local have_mesh=false
  if ! $WITHOUT_MESH; then
    if [[ -n "$(mf_get meshbin)" || -n "$(mf_get adopted_meshbin)" ]]; then have_mesh=true; fi
  fi

  # ── dry-run ─────────────────────────────────────────────────────
  if $CHECK; then
    echo
    info "UPGRADE --check (dry run, nothing is modified)"
    printf "  core : %s (tag %s) -> %s\n" "${cur_cv:-?}" "${cur_tag:-?}" "$CORE_TAG"
    if $have_mesh; then
      printf "  mesh : %s (ref %s) -> %s\n" "${cur_mv:-?}" "${cur_meshref:-?}" "$MESH_REF"
    else
      echo "  mesh : n/a (this node runs --without-mesh)"
    fi
    local any=false
    [[ "$cur_tag" != "$CORE_TAG" ]] && any=true \
      && echo "  files: venv swap ($VENV) + restart $(mf_get units.core)$( [[ $PROFILE == laptop ]] && echo " + $(mf_get units.board)")"
    if $have_mesh; then
      [[ "$cur_meshref" != "$MESH_REF" ]] && any=true \
        && echo "  files: mesh binary swap ($MESHBIN) + restart $(mf_get units.mesh)"
    fi
    if ! $any; then
      ok "already at target pair (core tag $CORE_TAG, mesh ref $MESH_REF) — no updates"
      if [[ "$adopted" == "yes" ]]; then
        info "node is ADOPTED (legacy paths) — a future pair change would need migration first"
      fi
      return 0
    fi
    if [[ "$adopted" == "yes" ]]; then
      warn "node is ADOPTED (legacy paths) — bundle upgrade would REFUSE; migration to managed paths is a separate op"
      return 0
    fi
    echo "  pre-flight: sqlite .backup of $(mf_get data_dir)/data -> $(dst "$STATEDIR")/backups/pre-upgrade-<ts>/"
    echo "  verify    : /health per face + mesh healthz (version must equal $EXPECT_MESHV), timeout 120s"
    echo "  rollback  : auto — health-verify failures AND mid-swap aborts (ERR trap) restore the *.prev-<ts> copies"
    return 0
  fi

  [[ "$adopted" == "yes" ]] && die "adopted node — bundle upgrade refused (see upgrade --check)"
  local pair_now=true
  [[ "$cur_tag" != "$CORE_TAG" ]] && pair_now=false
  if $have_mesh && [[ "$cur_meshref" != "$MESH_REF" ]]; then pair_now=false; fi
  if $pair_now; then
    ok "already at pair (core tag $cur_tag, mesh ref ${cur_meshref:-n/a}) — nothing to do"; return 0
  fi

  # ── pre-flight: health + fresh sqlite backup ─────────────────────
  if [[ -n "${VESMARO_TEST_FAIL_VERIFY:-}" ]]; then
    warn "TEST MODE (VESMARO_TEST_FAIL_VERIFY): pre-flight health SKIPPED, verify WILL FAIL — rollback drill only"
  elif $SANDBOX; then
    warn "sandbox: pre-flight health checks skipped (nothing runs under --dest)"
  else
    local p
    for p in $(faces_for_profile); do
      curl -sf -m 2 "http://127.0.0.1:$p/health" >/dev/null 2>&1 \
        || die "pre-flight: face :$p unhealthy — fix before upgrading"
    done
  fi
  local mport; mport="$(mesh_metrics_port "${DEST}${CONFDIR}/mesh.yaml")"
  if $have_mesh && [[ -z "${VESMARO_TEST_FAIL_VERIFY:-}" ]] && ! $SANDBOX; then
    curl -sf -m 2 "http://127.0.0.1:$mport/healthz" >/dev/null 2>&1 \
      || warn "pre-flight: mesh healthz not answering on :$mport (continuing)"
  fi
  if $SANDBOX; then
    info "sandbox: sqlite backup skipped (no live data under --dest)"
  else
    local bdir; bdir="$(dst "$STATEDIR")/backups/pre-upgrade-$(date +%Y%m%d%H%M%S)"
    info "pre-flight: sqlite backup -> $bdir"
    local ddir; ddir="$(dst "$(mf_get data_dir)")/data"
    [[ -d "$ddir" ]] || ddir="$(dst "$(mf_get data_dir)")"
    local nb; nb="$(backup_sqlite "$ddir" "$bdir" | tail -1)"
    [[ "$nb" -gt 0 ]] || die "pre-flight: no sqlite DBs backed up under $ddir — refusing"
  fi

  # ── build both artifacts BEFORE touching the node ───────────────
  local vrepo vcache new_cv
  vrepo="$(ensure_repo vesmaro "$(default_vesmaro_repo)" git@github.com:vesmaro/vesmaro.git)"
  vcache="$(build_venv "$vrepo" "$CORE_REF")"
  new_cv="$(version_core "$vcache/bin/mnemos")"
  local mrepo="" mbin="" new_mv=""
  if $have_mesh; then
    mrepo="$(ensure_repo mnemos-mesh "$(default_mesh_repo)" git@github.com:vesmaro/vesma-mesh.git)"
    mbin="$(build_mesh "$mrepo" "$MESH_REF" "$EXPECT_MESHV")"
    new_mv="$(version_mesh "$mbin")"
  fi

  # ── swap area (B2: ERR-trapped — any abort rolls the pair back) ──
  local ts; ts="$(date +%Y%m%d%H%M%S)"
  local dst_venv dst_bin
  dst_venv="$(dst "$VENV")"
  dst_bin=""
  if $have_mesh; then dst_bin="$(dst "$MESHBIN")"; fi
  RB_ACTIVE=true
  RB_DSTVENV="$dst_venv"; RB_DSTBIN="$dst_bin"
  RB_VPREV="${dst_venv}.prev-$ts"; RB_MPREV=""
  RB_CORE_UNIT="$(mf_get units.core)"; RB_BOARD_UNIT="$(mf_get units.board)"; RB_MESH_UNIT=""
  if $have_mesh; then RB_MESH_UNIT="$(mf_get units.mesh)"; fi
  RB_MPORT="$mport"; RB_CUR_CV="$cur_cv"; RB_CUR_MV="$cur_mv"
  trap 'upgrade_swap_err' ERR

  info "swap: venv $cur_cv -> $new_cv"
  mv "$dst_venv" "$RB_VPREV"
  cp -a "$vcache" "$dst_venv"
  if $have_mesh; then
    RB_MPREV="${dst_bin}.prev-$ts"
    info "swap: mesh $cur_mv -> $new_mv"
    mv "$dst_bin" "$RB_MPREV"
    cp -f "$mbin" "$dst_bin"; chmod 0755 "$dst_bin"
  fi

  # ── restart + verify (core faces first, then mesh) ──────────────
  local core_unit board_unit u bad=false
  core_unit="$RB_CORE_UNIT"; board_unit="$RB_BOARD_UNIT"
  for u in "$core_unit" "$board_unit"; do
    [[ -n "$u" ]] || continue
    sys restart "$u"
    verify_health "http://127.0.0.1:$([[ $u == *board* ]] && echo 8788 || echo 8787)/health" 120 || bad=true
  done
  if [[ -n "$RB_MESH_UNIT" ]]; then
    sys restart "$RB_MESH_UNIT"
    verify_health "http://127.0.0.1:$mport/healthz" 120 || bad=true
    if [[ -z "${VESMARO_TEST_FAIL_VERIFY:-}" ]] && ! $SANDBOX; then
      local got
      got="$(curl -sf -m 2 "http://127.0.0.1:$mport/healthz" 2>/dev/null | python3 -c 'import json,sys;print(json.load(sys.stdin).get("version",""))' 2>/dev/null || true)"
      # "v"-prefix tolerant comparison (ledger may say 1.3.2, healthz v1.3.2)
      if [[ -n "$EXPECT_MESHV" && "${got#v}" != "${EXPECT_MESHV#v}" ]]; then
        warn "healthz reports ${got:-nothing}, expected $EXPECT_MESHV"; bad=true
      fi
    fi
  fi

  trap - ERR   # leaving the protected swap..verify area

  if $bad; then
    warn "health-verify FAILED — rolling back"
    rollback_pair
    die "upgrade FAILED, rollback applied — node should be back on ($cur_cv, ${cur_mv:-no-mesh})"
  fi

  # prune older prev copies, keep the newest. Prev names are TIMESTAMPED
  # (sort chronologically); the old version-suffix scheme sorted 4.10.0
  # before 4.9.0 and pruned the wrong copy.
  find "$(dirname "$dst_venv")" -maxdepth 1 -name "$(basename "$dst_venv").prev-*" | sort | head -n -1 | xargs -r rm -rf
  if [[ -n "$dst_bin" ]]; then
    find "$(dirname "$dst_bin")" -maxdepth 1 -name "$(basename "$dst_bin").prev-*" | sort | head -n -1 | xargs -r rm -f
  fi

  mf_write "core_version=$new_cv" "core_tag=$CORE_TAG" "core_ref=$(git -C "$vrepo" rev-parse --short=12 "${CORE_REF}^{commit}" 2>/dev/null || echo "$CORE_REF")" \
    "pair_mode=$PAIR_MODE"
  if $have_mesh; then
    mf_write "mesh_version=$new_mv" "mesh_ref=$MESH_REF" "mesh_sha256=$(sha256_of "$dst_bin")"
  fi
  ok "upgrade complete: (vesmaro $new_cv, mesh ${new_mv:-n/a}), rollback copies kept (*.prev-<ts>)"
}

# ── STATUS ───────────────────────────────────────────────────────────
cmd_status() {
  detect_sys
  compute_paths
  compute_container_paths
  if ! manifest_exists; then
    # container-only node? print its block and stop
    if [[ -f "$CT_MANIFEST" ]]; then
      echo "vesmaro-node status (container install) — $(date '+%Y-%m-%d %H:%M:%S')"
      container_status
      return 0
    fi
    die "no install recorded at $MANIFEST — run 'install' first (or --user/--dest to match)"
  fi
  local adopted cv mv cvbin mvbin
  adopted="$(mf_get adopted)"
  if [[ "$adopted" == "yes" ]]; then
    cvbin="$(mf_get adopted_venv)/bin/mnemos"; mvbin="$(mf_get adopted_meshbin)"
  else
    cvbin="$(dst "$(mf_get venv)")/bin/mnemos"; mvbin="$(dst "$(mf_get meshbin)")"
  fi
  cv="$(version_core "$cvbin")"; mv="$(version_mesh "$mvbin")"
  PROFILE="$(mf_get profile)"

  echo "vesmaro-node status — $(date '+%Y-%m-%d %H:%M:%S')"
  echo "  node      : profile=$(mf_get profile) style=$(mf_get style) user=$(mf_get user) adopted=$adopted"
  echo "  installed : $(mf_get installed_at) (updated $(mf_get updated_at))"
  echo "  versions  : core ${cv:-?} [tag $(mf_get core_tag)]  mesh ${mv:-?} [$(mf_get mesh_ref)]"
  [[ -x "$mvbin" ]] && echo "  mesh sha  : $(sha256_of "$mvbin" | cut -c1-16)…"

  echo "  units:"
  local u key
  for key in core board mesh; do
    u="$(mf_get "units.$key")"; [[ -n "$u" ]] || continue
    local act ena
    act="$(sys_real is-active "$u" 2>/dev/null | head -1)"
    ena="$(sys_real is-enabled "$u" 2>/dev/null | head -1)"
    printf "    %-28s active=%-12s enabled=%s\n" "$u" "${act:-?}" "${ena:-?}"
  done

  echo "  faces:"
  local p
  for p in $(faces_for_profile); do
    local h; h="$(curl -sf -m 2 "http://127.0.0.1:$p/health" 2>/dev/null || true)"
    printf "    127.0.0.1:%-5s /health %s\n" "$p" "$([[ -n $h ]] && echo OK || echo DOWN)"
  done

  # mesh healthz + peer skew
  local mconf mport
  if [[ "$adopted" == "yes" ]]; then mconf="$(mf_get adopted_mesh_conf)"; else mconf="${DEST}${CONFDIR}/mesh.yaml"; fi
  mport="$(mesh_metrics_port "$mconf")"
  if command -v curl >/dev/null 2>&1; then
    local hz
    hz="$(curl -sf -m 2 "http://127.0.0.1:$mport/healthz" 2>/dev/null || true)"
    if [[ -n "$hz" ]]; then
      python3 - "$hz" "$mv" <<'PY'
import json, sys
d = json.loads(sys.argv[1]); local_v = sys.argv[2].lstrip("v")
cc = d.get("unix_socket", {}).get("core_connected")
cc = {True: "true", False: "false", None: "?"}[cc]
print(f"  mesh      : healthz ok, node {d.get('node_id')}, v{d.get('version','?').lstrip('v')}, "
      f"core_connected={cc}")
peers = d.get("peers") or []
if not peers:
    print("  peers     : none configured")
for p in peers:
    pv = p.get("version")
    reach = "true" if p.get("reachable") else "false"
    if pv is None:
        print(f"  peers     : {p.get('id')} reachable={reach} "
              f"version=n/a (mesh does not report peer versions yet)")
    elif pv.lstrip("v") != local_v:
        print(f"  peers     : {p.get('id')} reachable={reach} version={pv}"
              f"  << WARNING: peer skew (local v{local_v})")
    else:
        print(f"  peers     : {p.get('id')} reachable={reach} version={pv} (in sync)")
PY
    else
      echo "  mesh      : healthz DOWN on 127.0.0.1:$mport"
    fi
  fi

  # available tags + pair compatibility
  local vrepo mrepo
  vrepo="$(default_vesmaro_repo)"; mrepo="$(default_mesh_repo)"
  if [[ -n "$vrepo" ]]; then
    local vt; vt="$(git -C "$vrepo" ls-remote --tags origin 2>/dev/null | awk -F/ '{print $NF}' | grep -v '\^{}' | sort -V | tail -3 | tr '\n' ' ')"
    [[ -n "$vt" ]] && echo "  tags/core : (remote) $vt"
  fi
  if [[ -n "$mrepo" ]]; then
    local mt; mt="$(git -C "$mrepo" ls-remote --tags origin 2>/dev/null | awk -F/ '{print $NF}' | grep -v '\^{}' | sort -V | tail -3 | tr '\n' ' ')"
    [[ -n "$mt" ]] && echo "  tags/mesh : (remote) $mt"
  fi
  if [[ -f "$TSV" ]]; then
    if pair_known "$(mf_get core_tag)" "$(mf_get mesh_ref)"; then
      echo "  pair      : ($(mf_get core_tag), $(mf_get mesh_ref)) — known-good (ledger)"
    else
      echo "  pair      : ($(mf_get core_tag), $(mf_get mesh_ref)) — NOT in ledger (unknown compatibility)"
    fi
  fi

  container_status   # appends the container block when one is recorded
}

# ── UNINSTALL ────────────────────────────────────────────────────────
cmd_uninstall() {
  detect_sys
  compute_paths
  compute_container_paths
  if ! manifest_exists; then
    # container-only node
    if [[ -f "$CT_MANIFEST" ]]; then
      $ASSUME_YES || die "uninstall is destructive — pass --yes to proceed"
      cmd_container_uninstall
      return 0
    fi
    die "no install recorded at $MANIFEST"
  fi
  local adopted; adopted="$(mf_get adopted)"

  if $SANDBOX; then
    [[ -n "$DEST" && "$DEST" != "/" ]] || die "sandbox uninstall needs a sane --dest"
    info "sandbox uninstall: removing $DEST"
    rm -rf "$DEST"
    ok "sandbox root removed"
    return
  fi

  $ASSUME_YES || die "uninstall is destructive — pass --yes to proceed"
  if [[ "$adopted" == "yes" ]]; then
    $INCLUDE_ADOPTED || die "node is ADOPTED (legacy live prod) — pass --include-adopted to uninstall it too"
    warn "uninstalling ADOPTED legacy node (units + binaries from \$HOME)"
  fi

  local u
  for key in core board mesh; do
    u="$(mf_get "units.$key")"; [[ -n "$u" ]] || continue
    sys disable "$u"; sys stop "$u"
    if [[ "$adopted" != "yes" || $INCLUDE_ADOPTED ]]; then
      [[ -f "${DEST}${UNITDIR}/$u" ]] && rm -f "${DEST}${UNITDIR}/$u"
    fi
  done
  $SYS_DRY || sys daemon-reload

  local venv_rm bin_rm
  if [[ "$adopted" == "yes" ]]; then venv_rm="$(mf_get adopted_venv)"; bin_rm="$(mf_get adopted_meshbin)"; else venv_rm="$(dst "$VENV")"; bin_rm="$(dst "$MESHBIN")"; fi
  [[ -d "$venv_rm" ]] && rm -rf "$venv_rm" && info "removed $venv_rm"
  [[ -f "$bin_rm" ]] && rm -f "$bin_rm" && info "removed $bin_rm"
  rm -f "$bin_rm".prev-* 2>/dev/null || true

  if $PURGE; then
    rm -rf "$(dst "$CONFDIR")" "$(dst "$DATADIR")" "$(dst "$STATEDIR")" && info "purged configs+data+state"
    ok "uninstalled (--purge): everything removed"
  else
    ok "uninstalled (--keep-data): $CONFDIR, $PKIDIR, $DATADIR and $MANIFEST kept"
    info "full cleanup: rerun with --purge"
  fi
  [[ "$adopted" != "yes" ]] && rm -f "$MANIFEST"

  # a container install on the same node goes too (same --yes/--purge)
  if [[ -f "$CT_MANIFEST" ]]; then
    info "container install also recorded — removing it too"
    cmd_container_uninstall
  fi
}

# ── arg parsing ──────────────────────────────────────────────────────
[[ $# -ge 1 ]] || usage
CMD="$1"; SCRIPT_CMD="$CMD"; shift
case "$CMD" in
  install|upgrade|status|uninstall|container-install) : ;;
  --help|-h|help) usage ;;
  *) die "unknown subcommand: $CMD (install|container-install|upgrade|status|uninstall)" ;;
esac

while [[ $# -gt 0 ]]; do
  case "$1" in
    --profile) PROFILE="$2"; shift 2 ;;
    --user) STYLE="user"; USER_NAME="$2"; shift 2 ;;
    --run-user) RUN_USER="$2"; shift 2 ;;
    --without-mesh) WITHOUT_MESH=true; shift ;;
    --release) RELEASE="$2"; shift 2 ;;
    --mesh-release) MESH_RELEASE="$2"; shift 2 ;;
    --ref) DEV_REF="$2"; shift 2 ;;
    --mesh-ref) DEV_MESH_REF="$2"; shift 2 ;;
    --vesmaro-repo) VESMARO_REPO_FLAG="$2"; shift 2 ;;
    --mesh-repo) MESH_REPO_FLAG="$2"; shift 2 ;;
    --mesh-bin) MESH_BIN_FLAG="$2"; shift 2 ;;
    --node-id) NODE_ID="$2"; shift 2 ;;
    --dest) DEST="${2%/}"; shift 2 ;;
    --no-enable) NO_ENABLE=true; shift ;;
    --regen-units) REGEN_UNITS=true; shift ;;
    --reconf) RECONF=true; shift ;;
    --force) FORCE=true; shift ;;
    --adopt) ADOPT=true; shift ;;
    --migrate-adopted) MIGRATE_ADOPTED=true; shift ;;
    --gen-certs) GEN_CERTS=true; shift ;;
    --check) CHECK=true; shift ;;
    --purge) PURGE=true; shift ;;
    --keep-data) KEEP_DATA=true; shift ;;  # protects the data dir under --purge (container mode)
    --yes) ASSUME_YES=true; shift ;;
    --include-adopted) INCLUDE_ADOPTED=true; shift ;;
    --workdir) WORKDIR="$2"; shift 2 ;;
    --python) PYBIN_FLAG="$2"; shift 2 ;;
    --name) CT_NAME="$2"; shift 2 ;;
    --tag) CT_TAG="$2"; shift 2 ;;
    --mesh-tag) CT_MESH_TAG="$2"; shift 2 ;;
    --port) CT_PORT="$2"; shift 2 ;;
    --data-dir) CT_DATA_DIR="$2"; shift 2 ;;
    --network) CT_NETWORK="$2"; shift 2 ;;
    --help|-h) usage ;;
    *) die "unknown flag: $1" ;;
  esac
done

# status/uninstall may inherit the user from the calling context
if [[ "$CMD" == "status" || "$CMD" == "uninstall" ]]; then
  if [[ -z "$USER_NAME" && "$STYLE" == "user" && -n "${SUDO_USER:-}" ]]; then USER_NAME="$SUDO_USER"; fi
fi

case "$CMD" in
  install) cmd_install ;;
  container-install) cmd_container_install ;;
  upgrade) cmd_upgrade ;;
  status) cmd_status ;;
  uninstall) cmd_uninstall ;;
esac
