#!/usr/bin/env bash
# scripts/preflight.sh [repo-root]
#
# THE single pre-deploy gate (DOCFIX-073). Before this existed the gate surface
# was six artifacts the operator had to remember independently (pre-flight-checks
# + its three delegates + provider-bundle-check + standup dry-run) -- the same
# fragmentation that let review-bundle.py rot into noise. This runs every
# stage-1 (pre-add-model) gate in dependency order with sectioned output and
# worst-exit aggregation, then prints the stage-2 gates it CANNOT run yet.
#
# Mutates NOTHING. Stages:
#   P1 repo lint            scripts/repo-lint.sh          (static hygiene)
#   P2 bundle invariants    scripts/provider-bundle-check.py
#   P3 channel assert       scripts/channel_assert.py     (needs charmhub; WARN if offline)
#   P4 live pre-flight      scripts/pre-flight-checks.sh  (needs MAAS; overlay/VIP/planes/nodes)
#   P5 credential matrix    scripts/creds-matrix.py --tier2  (D-137 ruling 1;
#                           tier 1 STATIC + tier 2 LOCAL. Hermetic: no ssh, no sudo.)
#   P7 octavia PKI          scripts/octavia-pki.sh verify <dc>  (amphora trust domain;
#                             HEADEND-ONLY -- NOT EVALUATED elsewhere, never a silent pass)
#   P6 reminders            stage-2 gates: juju-spaces-check.sh (AFTER add-model),
#                           osd-blank-check.sh (sudo), 'juju deploy --dry-run' (phase-01 1.2)
#
# DC-AWARE END TO END (F5, 2026-07-29). `DC` selects the datacenter this run gates;
# it defaults to vr1-dc0, is validated against the D-119 region-qualified token set,
# is EXPORTED so every sub-gate measures the same DC, and is printed in both the
# opening header and the verdict line. P2 and P4 are the DC-sensitive gates.
#   DC=vr1-dc1 bash scripts/preflight.sh
#
# Exit: 0 all pass | 1 any FAIL (do NOT deploy) | 2 warnings only (review, then decide).
# ASCII + LF.
set -uo pipefail
REPO="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
cd "$REPO" || { echo "FAIL: cannot cd to repo root $REPO"; exit 1; }
WORST=0

# ---- F5: the DC selection is ONE decision, made ONCE, EXPORTED, and PRINTED -----
# MEASURED DEFECT (2026-07-29, docs/audit/stage5-findings-20260729-successor.md F5):
# this used to be set at the top of P2 as a plain shell variable and never exported, so
# whether the sub-gates saw it depended on the CALLER'S INVOCATION FORM --
# `DC=vr1-dc1 bash scripts/preflight.sh` put it in the environment (children saw it),
# a bare invocation did not (children saw nothing and silently used their own default).
# Two call shapes, two behaviours, neither documented. `export` collapses them to one.
#
# REFUSE an unrecognised DC rather than defaulting to success: an unknown token used to
# flow straight into `--dc $DC` and the overlay paths, where it produced "overlay not
# found" noise instead of "you named a DC that does not exist". The token set is the
# D-119 region-qualified one enforced by lib-net.sh/lib-hosts.sh; bare `dcN` is RETIRED
# there and is rejected here with the same reason so the message does not depend on
# which gate happens to see it first.
DC="${DC:-vr1-dc0}"
case "$DC" in
  vr0-dc0|vr1-dc0|vr1-dc1) ;;
  dc0|dc1|dc2)
    echo "FAIL: DC='$DC' -- bare 'dcN' is RETIRED (D-119): it meant VR0's live DC0 in"
    echo "      lib-net.sh but VR1's FIRST DC in the NetBox importer. Use a"
    echo "      region-qualified selector: vr0-dc0 | vr1-dc0 | vr1-dc1"
    exit 1 ;;
  *)
    echo "FAIL: DC='$DC' is not a recognised datacenter selector (expected vr0-dc0,"
    echo "      vr1-dc0 or vr1-dc1). REFUSING rather than gating an unknown target --"
    echo "      a gate that cannot say WHAT it measured is not a gate."
    exit 1 ;;
esac
export DC
echo "PREFLIGHT TARGET: DC=$DC   (override: DC=vr1-dc1 bash scripts/preflight.sh)"
echo "  Every gate below is run against THIS DC; the verdict line repeats it."
# ---- R15(3): preflight must not FAIL OPEN (ruled 2026-07-27, OPS) --------------
# MEASURED DEFECT: note() tested ONLY rc 1 and 2, so a sub-gate exiting 127 (command
# not found), 126 (not executable), 130 (interrupted) or any rc>=3 left WORST
# untouched and this printed "PREFLIGHT: PASS -- clear to add-model / deploy".
# Reproduced for all four. This is the gate that AUTHORISES the deploy.
#
# rc=2 IS PER-GATE, and R15's blanket framing ("rc=2 is how these checkers signal
# 'I could not evaluate anything'") is only PARTLY right -- measured per checker:
#   P1 repo-lint.sh          rc2 = "warnings only"      (documented header)  -> WARN
#   P2 provider-bundle-check rc2 = could-not-evaluate    (PyYAML missing at :32,
#                                  cannot parse :108, cannot merge overlay :113) -> FAIL
#   P3 channel_assert.py     rc2 = BOTH: sys.exit(2) on missing PyYAML at :19, but
#                                  `return 2 if warns` at :55 is a LEGITIMATE warn.
#                                  Ambiguous IN THE CHECKER, so preflight cannot
#                                  resolve it -- kept WARN because the offline/
#                                  charmhub-unreachable warn is the common path, and
#                                  the missing-PyYAML root cause is caught anyway by
#                                  P2, which shares it and is strict. Flagged, not
#                                  guessed: making rc2 unambiguous belongs in
#                                  channel_assert.py, not here.
#   P4 pre-flight-checks.sh  rc2 = "warning"            (documented header)  -> WARN
#   P5 creds-matrix.py       rc2 = could-not-evaluate    (bad --dc at :1037,
#                                  unreadable matrix at :116)                -> FAIL
# Blanket-remapping 2 to FAIL would have turned the standing L1 legacy-ASCII WARN
# into a deploy blocker. Measured before implementing.
#
# note <rc> <gate-label> <what-rc2-means: warn|fail>
note() {
  local rc="$1" gate="$2" two="${3:-warn}"
  case "$rc" in
    0) ;;
    1) WORST=1 ;;
    2)
      if [ "$two" = "fail" ]; then
        echo "  [FAIL] $gate exited 2 = COULD NOT EVALUATE (not 'no findings'). A gate"
        echo "         that could not look must never read as a gate that found nothing."
        WORST=1
      else
        [ "$WORST" -ne 1 ] && WORST=2
      fi
      ;;
    *)
      echo "  [FAIL] $gate exited $rc -- UNEXPECTED exit code (127=command not found,"
      echo "         126=not executable, 130=interrupted, >=3=undefined). Treated as"
      echo "         FAIL: this used to leave 'PREFLIGHT: PASS -- clear to deploy'."
      WORST=1
      ;;
  esac
}

echo "================ P1: repo lint ================"
bash scripts/repo-lint.sh "$REPO"; note $? "P1 repo lint" warn

echo "================ P2: bundle invariants ================"
# Ruling 3 (2026-07-25) made bundle.yaml VIP-FREE, so the BARE base is no longer a
# deployable input -- validating it alone would report 11 phantom "hacluster
# relation but no vip" failures for VIPs that simply moved. P2 therefore validates
# the MERGED dc0 deploy input, which is what actually gets deployed. The overlay is
# passed only when present so this stays runnable on a tree without it.
#
# DC-AWARE since 2026-07-29 (chain audit finding 16). P2 used to hardcode
# `overlays/vr1-dc0-vips.yaml --dc vr1-dc0` with NO dc1 branch, while
# `runbooks/dc-dc-phase4-juju-bundle-per-dc.md` invokes this script bare as the *dc1*
# gate and tells the operator to expect PASS. A dc1 operator therefore got a green gate
# that had validated dc0's numbers against dc0's bands and never parsed a dc1 artifact.
# Set DC= to gate the other site; the default keeps every existing caller unchanged.
#
# It now assembles the SAME overlay set the deploy uses, in the SAME order, rather than a
# partial one -- the audit's point was that a gate validating a different input than the
# deploy is not validating the deploy. Each overlay is added only if present, so a tree
# missing the gitignored octavia-pki secret still runs (CHECK 0 in P4 is what fails on
# that, deliberately, and it is a different gate).
# $DC is selected, validated and EXPORTED at the top of this file (F5).
# The octavia-pki overlay is PER-DC since F1 (2026-07-29) -- one shared
# overlays/octavia-pki.yaml would have carried whichever DC's CA was generated last,
# which is the cross-DC amphora root-of-trust the D-109 R7 amendment refused.
P2_ARGS="bundle.yaml"
for _ovl in "overlays/${DC}-vips.yaml" "overlays/${DC}-machines.yaml" "overlays/${DC}-octavia-pki.yaml"; do
  [ -f "$_ovl" ] && P2_ARGS="$P2_ARGS --overlay $_ovl"
done
P2_ARGS="$P2_ARGS --dc $DC"
echo "  validating the MERGED $DC deploy input: $P2_ARGS"
python3 scripts/provider-bundle-check.py $P2_ARGS; note $? "P2 bundle invariants" fail

echo "================ P3: channel assert (charmhub) ================"
python3 scripts/channel_assert.py bundle.yaml; note $? "P3 channel assert" warn

echo "================ P4: live pre-flight (MAAS/overlay/nodes) ================"
if [ -x scripts/pre-flight-checks.sh ] || [ -f scripts/pre-flight-checks.sh ]; then
  bash scripts/pre-flight-checks.sh; note $? "P4 live pre-flight" warn
else
  echo "  [FAIL] scripts/pre-flight-checks.sh missing"; note 1 "P4 live pre-flight" fail
fi

echo "================ P5: credential matrix (D-137 tier 1 + tier 2 local) ================"
# D-137 ruling 1: enforcement is BLOCKING IN PREFLIGHT. Advisory was rejected as the
# posture that demonstrably failed -- `creds-audit` read CLEAN on 2026-07-15 while four
# region-VM secrets sat undeclared, and `admin.pass` surfaced 12 days later only because
# someone looked. Tier 1 is wired here because it is OFFLINE and its findings ARE the three
# ruled failure classes (expected-but-absent / undeclared / per-DC-asymmetric).
#
# LOCAL HALF ONLY, deliberately. `--tier2` without `--remote` is HERMETIC -- no ssh, no
# sudo, no reachability coupling -- and still checks every jumphost creds folder for
# undeclared files, wrong modes and missing credentials. The REMOTE half
# (`--tier2 --remote --privileged`) is an operator-run check at DC-standup close and stage
# close, NOT a deploy gate: it would let an unrelated host outage, a NOPASSWD change, or a
# MAAS snap refresh turn the deploy gate red for reasons that are not about credentials,
# and a gate that fails environmentally is a gate people learn to bypass. Same reasoning
# `run-tests-all.sh:7-8` gives for staying out of preflight: different cadence.
#
# EXPECTED TO FAIL TODAY, BY DESIGN. The register is red on real defects (SEC-021's
# declaration gaps, the SEC-020 ruling-5 identity conflation, the per-DC power-key
# asymmetry). That is this gate working, not misconfigured: a deploy should not proceed
# over an unreconciled credential estate. Going green is remediation, gated per row --
# do NOT silence it by deleting matrix rows or by demoting this to a warning.
# FAIL CLOSED if the checker is absent, mirroring P4. Without this guard a missing file
# makes python3 exit 2, which `note` maps to WARN -- so DELETING the gate would downgrade
# it to a warning instead of stopping the deploy. A gate that vanishes must never read as
# a pass (harness T9 encodes this).
if [ -f scripts/creds-matrix.py ]; then
  python3 scripts/creds-matrix.py --tier2; note $? "P5 credential matrix" fail
else
  echo "  [FAIL] scripts/creds-matrix.py missing"; note 1 "P5 credential matrix" fail
fi

echo "================ P7: Octavia amphora PKI ================"
# WHY THIS GATE EXISTS (added 2026-07-30). `scripts/octavia-pki.sh` was invoked by NOTHING
# executable: P4's CHECK 0 asserts only that overlays/<dc>-octavia-pki.yaml EXISTS, and the
# harness runs against throwaway fixtures. So "gauntlet ALL GREEN" said nothing whatever about
# the live PKI, and the only real check ran when a human remembered to type it. That is how F9
# survived for weeks -- both DCs' controller certs carried another region's names, and no gate
# read them. Octavia cannot establish amphora mTLS without this material being correct, so it
# belongs in the deploy gate.
#
# HOST-AWARE, AND THAT IS THE WHOLE DESIGN PROBLEM. The PKI lives on the HEADEND (D-109 ruling
# note (b)); `octavia-pki.sh verify` REFUSES with exit 3 anywhere else rather than describing
# the wrong machine's filesystem. Two consequences:
#   (a) rc 3 must NOT reach note()'s `*)` arm, which would report a legitimate wrong-host
#       refusal as "UNEXPECTED exit code" and hard-FAIL preflight on the jumphost;
#   (b) on a non-headend host this gate must say NOT EVALUATED -- never silently pass. P5's own
#       F6 defect (2026-07-30) was probing the WRONG HOST'S filesystem and reporting it as
#       fact, producing 34 findings against a true 7. The binding is read from the SAME
#       register file octavia-pki.sh reads, so the two cannot drift.
#
# THE PASS VERDICT ALONE IS NOT SUFFICIENT, WHICH IS WHY THE POSITIVE LINE IS ALSO REQUIRED.
# While `os-public-hostname` is unset (bundle.yaml posture B5, IP-only), A12/A13 RECORD a
# wrong-zone certificate rather than failing it -- deliberately, since nothing resolves those
# names yet. So `verify` exits 0 on exactly the defect this gate is meant to catch. Asserting
# the literal positive line closes that: it is emitted ONLY when every DNS SAN is in this DC's
# derived zone. Satisfiable by construction -- 1.0-GEN.c now derives the zone, and both live
# DCs were reissued into theirs on 2026-07-30.
if [ "$DC" = "vr0-dc0" ]; then
  echo "  [skip] P7 not applicable to $DC -- the Octavia amphora PKI is a VR1 per-DC artifact"
elif [ ! -f scripts/octavia-pki.sh ]; then
  # FAIL CLOSED, mirroring P4/P5: a gate that VANISHES must never read as a pass.
  echo "  [FAIL] scripts/octavia-pki.sh missing -- the Octavia PKI gate cannot run"
  note 1 "P7 octavia PKI" fail
else
  P7_HOST="$(hostname)"
  P7_EXPECT="$(awk '!/^[[:space:]]*#/ && NF==2 && $1=="headend" {print $2; exit}' \
               creds-manifests/host-identity 2>/dev/null)"
  if [ -z "$P7_EXPECT" ]; then
    echo "  [FAIL] no 'headend' binding in creds-manifests/host-identity -- cannot tell which"
    echo "         host should hold the Octavia PKI, and will not assume this one does"
    note 1 "P7 octavia PKI" fail
  elif [ "$P7_HOST" != "$P7_EXPECT" ]; then
    echo "  [warn] P7 NOT EVALUATED on '$P7_HOST' -- the Octavia PKI lives on '$P7_EXPECT'"
    echo "         (D-109 ruling note (b)), and measuring this host's filesystem would"
    echo "         describe the wrong machine. This is NOT a pass. Run preflight ON the"
    echo "         headend for a real pre-deploy gate: D-128 puts the Stage-5+ deploy there."
    note 2 "P7 octavia PKI" warn
  else
    P7_OUT="$(bash scripts/octavia-pki.sh verify "$DC" 2>&1)"; P7_RC=$?
    printf '%s\n' "$P7_OUT"
    case "$P7_RC" in
      0)
        # The verdict passed. Now the acceptance criterion the verdict cannot express.
        if printf '%s' "$P7_OUT" | grep -q "DNS SANs are all in this DC's expected zone"; then
          echo "  [ok]   P7 octavia PKI verified for $DC, and its DNS SANs are in this DC's own zone"
        else
          echo "  [FAIL] P7 octavia-pki verify PASSED for $DC but did NOT report that the DNS SANs"
          echo "         are in this DC's expected zone. While os-public-hostname is unset those"
          echo "         names are INERT, so A12/A13 record rather than fail -- which means a PASS"
          echo "         verdict here is compatible with a certificate carrying another DC's or"
          echo "         another region's names (F9, measured on BOTH DCs 2026-07-30). Reissue it:"
          echo "         runbooks/phase-01-bundle-deploy.md Step 1.0-REISSUE"
          note 1 "P7 octavia PKI" fail
        fi ;;
      1) note 1 "P7 octavia PKI" fail ;;
      3)
        echo "  [FAIL] P7 octavia-pki verify REFUSED (exit 3) ON THE HEADEND ITSELF -- it could"
        echo "         not evaluate the PKI (missing openssl/python3, unreadable VIP overlay, or"
        echo "         an absent host binding). 'Could not look' is never 'nothing there', and a"
        echo "         deploy must not proceed over an unevaluated amphora trust domain."
        note 1 "P7 octavia PKI" fail ;;
      *)
        echo "  [FAIL] P7 octavia-pki verify exited $P7_RC -- unexpected for this checker"
        note 1 "P7 octavia PKI" fail ;;
    esac
  fi
fi

echo "================ P8: substrate drift (outer tofu root) ================"
# Nothing detected substrate drift before this gate: measured 2026-08-01, preflight
# contained ZERO `tofu plan` checks, and a real drift (office1-opnsense's runtime id)
# had been sitting unseen. The outer root runs ON vcloud (D-128 Plane 1), so this
# REFUSES rather than fails when run anywhere else -- P4/P5's fail-closed shape is
# wrong here, because "not the substrate host" is a legitimate state, not a missing gate.
#
# `-detailed-exitcode` returns 2 for BOTH a pending change and a harmless refresh-only
# observation, so the exit code alone cannot decide. Asserted on CONTENT: a pending
# ACTION ("will be created/destroyed/updated/replaced") FAILS; an observed-only drift
# ("has changed outside of OpenTofu") WARNS. A gate that is permanently red gets ignored.
if ! command -v tofu >/dev/null 2>&1; then
  echo "  [warn] tofu not on PATH -- substrate drift NOT evaluated here (run on the vcloud"
  echo "         substrate host). This is 'could not look', never 'nothing there'."
  note 2 "P8 substrate drift" warn
elif [ ! -f opentofu/terraform.tfstate ] || [ ! -d opentofu/.terraform ]; then
  # `||`, NOT `&&` (fixed 2026-08-01 after a live false FAIL on voffice1). The two markers
  # are NOT equivalent evidence and must BOTH be present to evaluate: terraform.tfstate is
  # the OWNERSHIP marker (only the substrate host holds it, and it is gitignored), while
  # .terraform is merely a provider cache that `tofu init` leaves on ANY host that ever
  # initialised ANY root -- which by D-128 includes voffice1, since it runs the INNER roots.
  # Under the old `&&`, voffice1 satisfied neither warn branch, fell through to `tofu plan`,
  # and died on a gitignored tfvar -> exit 1 -> hard FAIL on the very host the Stage-5
  # runbook designates for preflight. That contradicts this gate's own rule above: "not the
  # substrate host" is a legitimate state, not a missing gate.
  echo "  [warn] no outer-root state here -- substrate drift NOT evaluated (D-128: the"
  echo "         outer root lives on vcloud). Not a failure; not a pass either."
  note 2 "P8 substrate drift" warn
else
  P8_OUT="$(tofu -chdir=opentofu plan -detailed-exitcode -no-color -input=false 2>&1)"; P8_RC=$?
  P8_ACT="$(printf '%s\n' "$P8_OUT" | grep -cE 'will be (created|destroyed|updated|replaced)')"
  P8_OBS="$(printf '%s\n' "$P8_OUT" | grep -cE 'has changed outside of OpenTofu|Objects have changed')"
  case "$P8_RC" in
    0) echo "  [ok]   substrate matches the outer root -- zero diff" ;;
    2)
      if [ "$P8_ACT" -gt 0 ]; then
        echo "  [FAIL] $P8_ACT pending substrate action(s) -- config and reality have DIVERGED."
        printf '%s\n' "$P8_OUT" | grep -E 'will be (created|destroyed|updated|replaced)' | sed 's/^/         /'
        echo "         Deploying over an unapplied substrate change is how an in-place apply"
        echo "         mid-deploy bounces a guest (2026-07-20: regenerated 9 node MACs)."
        note 1 "P8 substrate drift" fail
      elif [ "$P8_OBS" -gt 0 ]; then
        echo "  [warn] refresh-only drift observed (no pending action). Benign attributes such"
        echo "         as a libvirt runtime id change on every guest restart; reported so it is"
        echo "         SEEN, not so it blocks."
        note 2 "P8 substrate drift" warn
      else
        echo "  [FAIL] tofu reported changes (exit 2) but no action or drift line parsed --"
        echo "         refusing to interpret an unrecognised plan shape as a pass."
        note 1 "P8 substrate drift" fail
      fi ;;
    *)
      echo "  [FAIL] tofu plan exited $P8_RC -- the substrate could not be evaluated."
      printf '%s\n' "$P8_OUT" | tail -5 | sed 's/^/         /'
      note 1 "P8 substrate drift" fail ;;
  esac
fi

echo "================ P9: DC egress (the path off-site) ================"
# ADDED 2026-08-02 after a 19-hour dc0 egress outage that NO gate could see. The dc0
# OPNsense edge came up broken after a reboot, so the DC had no path off-site --
# and `dc-rack-net.sh check dc0` PASSED throughout (it asserts bridge legs and unit
# states; a leg is not a path), the mirror answered 200 from its own nginx, and
# preflight had no egress gate at all. It surfaced only when a nightly debmirror
# timer failed, three layers from the cause.
#
# The check RUNS ON THE RACK -- every value it uses describes the rack, and run
# anywhere else it measures a different machine's path (the recorded dc-mirror.sh
# trap). preflight normally runs on voffice1, which is NOT a rack, so this WARNS
# with the exact command rather than failing: "not the rack" is a legitimate state,
# the same reasoning as P8's substrate guard. It FAILS only when preflight is itself
# run on the rack and egress is genuinely broken.
EGRESS_SH="$REPO/scripts/dc-egress-check.sh"
EGRESS_SITE="${DC#vr1-}"
if [ ! -x "$EGRESS_SH" ]; then
  echo "  [warn] scripts/dc-egress-check.sh missing or not executable -- DC egress NOT evaluated"
  note 2 "P9 DC egress" warn
elif bash "$EGRESS_SH" check "$EGRESS_SITE" >/tmp/p9.$$ 2>&1; then
  echo "  [ok]   DC egress verified from this host for $DC"
  note 0 "P9 DC egress" warn
else
  P9_RC=$?
  if [ "$P9_RC" -eq 2 ]; then
    echo "  [warn] not the $DC rack, so DC egress was NOT evaluated here. This is"
    echo "         'could not look', never 'nothing there'. Run it where it belongs:"
    echo "           ssh <$DC rack> 'bash -s -- check $EGRESS_SITE' < scripts/dc-egress-check.sh"
    echo "         REQUIRED before Step 4 and after any DC reboot."
    note 2 "P9 DC egress" warn
  else
    echo "  [FAIL] DC egress is BROKEN for $DC -- the deploy needs the juju agent stream"
    echo "         and the snap upstream, neither of which is local:"
    sed 's/^/         /' /tmp/p9.$$ | grep -E 'FAIL|REFUSE' | head -6
    note 1 "P9 DC egress" fail
  fi
fi
rm -f /tmp/p9.$$

echo "================ P6: stage-2 reminders (NOT run here) ================"
echo "  - after 'juju add-model': bash scripts/juju-spaces-check.sh"
echo "  - with sudo:              bash scripts/osd-blank-check.sh"
# DOCFIX-207: the plan figures were 50/97, stale since 2026-07-10 -- phase-01's own
# gate was corrected then and this reminder was missed. 56 apps / 108 relations is
# MEASURED, twice: docs/CURRENT-STATE.md:955 (`juju deploy --dry-run` resolved all 56
# charms, planned 108 relations, exit 0) and again 2026-07-31 against the fixed input.
# Measurement wins over a document (GA-R1 C2). This is a REMINDER, not an assertion --
# the real gate is the dry-run itself at phase-01 Step 1.2.
echo "  - phase-01 Step 1.2:      juju deploy --dry-run (plan: 56 apps / 108 relations)"

# The `PREFLIGHT: <verdict>` token stays CONTIGUOUS and leading -- the harness and the
# runbooks grep for it verbatim. $DC is appended so a verdict can never be read against
# the wrong datacenter (F5: two DCs, one combined verdict line, no way to tell).
case "$WORST" in
  0) echo; echo "PREFLIGHT: PASS (DC=$DC) -- clear to add-model / deploy" ;;
  2) echo; echo "PREFLIGHT: WARN (DC=$DC) -- review warnings, then decide" ;;
  *) echo; echo "PREFLIGHT: FAIL (DC=$DC) -- do NOT deploy" ;;
esac
exit "$WORST"
