#!/usr/bin/env bash
# Apply and verify the Overdeck K3s Phase 2 zero-mutation enrollment planner
# in an isolated worktree. Git publication is allowed only after an
# independently validated deterministic dry-run receipt.
set -euo pipefail
umask 077
export PYTHONDONTWRITEBYTECODE=1

SCRIPT_VERSION=1
DEFAULT_PACKAGE_NAME=overdeck-k3s-phase2-enrollment.zip
DEFAULT_CANDIDATE=debian4
DEFAULT_SERVER=debian3

usage() {
  cat <<'USAGE'
Usage:
  ./apply-overdeck-k3s-phase2.sh REPO_ROOT [options]

Default deterministic fixture run:
  ./apply-overdeck-k3s-phase2.sh /home/user/Projects/overdeck

Read-only qualification of a real OS + Tailscale-ready candidate:
  ./apply-overdeck-k3s-phase2.sh /home/user/Projects/overdeck \
    --candidate debian4 --live-candidate

Options:
  --package PATH              Package ZIP (default: beside this launcher)
  --candidate NAME            Candidate Tailscale name (default: debian4)
  --fixture PATH              Custom sanitized fixture; no live calls
  --live-candidate            Inspect a real candidate read-only; never enrolls it
  --candidate-user USER       Tailscale SSH user (default: user)
  --server NAME               K3s server registry name (default: debian3)
  --server-ssh-door NAME      tailscale_ip, tailscale_ssh, or lan (default: tailscale_ip)
  --kubeconfig PATH           Workstation cluster kubeconfig
  --result-root PATH          Parent directory for result directory/archive
  --no-push                   Create local commit but do not push
  --no-pr                     Push branch but do not create a draft PR
  --gate-timeout SECONDS      Per repository gate timeout (default: 900)
  --enrollment-timeout SEC    Phase 2 planning timeout (default: 900)
  -h, --help                  Show this help

Safety:
  - Run as the normal repository owner, never root.
  - The shared checkout is never reset, cleaned, stashed, committed, or merged.
  - A fresh .worktrees/<slug> checkout is created from current origin/main.
  - Phase 2 has no live mutation command: no K3s join, token creation, SSH
    convergence, Kubernetes write, or tracked registry publication occurs.
  - Registry outputs are previews only and the candidate has execution=none.
  - No repository path is committed before independent receipt validation.
  - The launcher never pushes directly to main and never merges a pull request.
USAGE
}

die() { printf 'phase2-apply: %s\n' "$*" >&2; exit 2; }
log() { printf '[phase2-apply] %s\n' "$*"; }
need() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"; }

(($# >= 1)) || { usage >&2; exit 2; }
case "${1:-}" in -h|--help) usage; exit 0;; esac
REPO_INPUT=$1
shift

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
PACKAGE="$SCRIPT_DIR/$DEFAULT_PACKAGE_NAME"
CANDIDATE=$DEFAULT_CANDIDATE
SERVER=$DEFAULT_SERVER
SERVER_SSH_DOOR=tailscale_ip
CANDIDATE_USER=user
KUBECONFIG_PATH=${HOME}/.kube/config-buildboxes
FIXTURE_PATH=""
LIVE_CANDIDATE=0
RESULT_ROOT=""
PUSH=1
CREATE_PR=1
GATE_TIMEOUT_SEC=${OD_PHASE_GATE_TIMEOUT_SEC:-900}
ENROLLMENT_TIMEOUT_SEC=${OD_PHASE_LIVE_TIMEOUT_SEC:-900}

while (($#)); do
  case "$1" in
    --package) (($# >= 2)) || die "--package requires a path"; PACKAGE=$2; shift 2 ;;
    --candidate) (($# >= 2)) || die "--candidate requires a name"; CANDIDATE=$2; shift 2 ;;
    --fixture) (($# >= 2)) || die "--fixture requires a path"; FIXTURE_PATH=$2; shift 2 ;;
    --live-candidate) LIVE_CANDIDATE=1; shift ;;
    --candidate-user) (($# >= 2)) || die "--candidate-user requires a value"; CANDIDATE_USER=$2; shift 2 ;;
    --server) (($# >= 2)) || die "--server requires a name"; SERVER=$2; shift 2 ;;
    --server-ssh-door) (($# >= 2)) || die "--server-ssh-door requires a value"; SERVER_SSH_DOOR=$2; shift 2 ;;
    --kubeconfig) (($# >= 2)) || die "--kubeconfig requires a path"; KUBECONFIG_PATH=$2; shift 2 ;;
    --result-root) (($# >= 2)) || die "--result-root requires a path"; RESULT_ROOT=$2; shift 2 ;;
    --no-push) PUSH=0; CREATE_PR=0; shift ;;
    --no-pr) CREATE_PR=0; shift ;;
    --gate-timeout) (($# >= 2)) || die "--gate-timeout requires seconds"; GATE_TIMEOUT_SEC=$2; shift 2 ;;
    --enrollment-timeout) (($# >= 2)) || die "--enrollment-timeout requires seconds"; ENROLLMENT_TIMEOUT_SEC=$2; shift 2 ;;
    -h|--help) usage; exit 0 ;;
    *) die "unknown option: $1" ;;
  esac
done

[[ "$CANDIDATE" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]] || die "invalid candidate name: $CANDIDATE"
[[ "$SERVER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid server name: $SERVER"
[[ "$CANDIDATE_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid candidate user: $CANDIDATE_USER"
[[ "$GATE_TIMEOUT_SEC" =~ ^[1-9][0-9]*$ ]] || die "--gate-timeout must be a positive integer"
[[ "$ENROLLMENT_TIMEOUT_SEC" =~ ^[1-9][0-9]*$ ]] || die "--enrollment-timeout must be a positive integer"
case "$SERVER_SSH_DOOR" in tailscale_ip|tailscale_ssh|lan) ;; *) die "invalid server SSH door: $SERVER_SSH_DOOR";; esac
((LIVE_CANDIDATE == 0 || ${#FIXTURE_PATH} == 0)) || die "--fixture and --live-candidate are mutually exclusive"
[[ ${EUID:-$(id -u)} -ne 0 ]] || die "run as the normal repository owner, not root"
for command in bash python3 node sha256sum tar timeout; do need "$command"; done
[[ -x /usr/bin/git ]] || die "/usr/bin/git is required"
SELF_PATH=$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "${BASH_SOURCE[0]}")
[[ -r "$PACKAGE" ]] || die "package not found: $PACKAGE"
PACKAGE=$(CDPATH= cd -- "$(dirname -- "$PACKAGE")" && printf '%s/%s\n' "$PWD" "$(basename -- "$PACKAGE")")
REPO=$(CDPATH= cd -- "$REPO_INPUT" 2>/dev/null && pwd) || die "repository path does not exist: $REPO_INPUT"
[[ -d "$REPO/.git" || -f "$REPO/.git" ]] || die "not a Git checkout/worktree: $REPO"
TOP=$(/usr/bin/git -C "$REPO" rev-parse --path-format=absolute --show-toplevel 2>/dev/null) || die "cannot resolve repository root"
[[ "$TOP" == "$REPO" ]] || die "pass the checkout root, not a subdirectory: $TOP"
[[ -x "$REPO/modules/workstation/claude/bin/od-worktree" ]] || die "Overdeck worktree helper is missing"
[[ -n "$(/usr/bin/git -C "$REPO" remote get-url origin 2>/dev/null || true)" ]] || die "origin remote is missing"
KUBECONFIG_PATH=$(python3 -c 'import os,sys; print(os.path.abspath(os.path.expandvars(os.path.expanduser(sys.argv[1]))))' "$KUBECONFIG_PATH")
if [[ -n "$FIXTURE_PATH" ]]; then
  FIXTURE_PATH=$(python3 -c 'import os,sys; print(os.path.abspath(os.path.expandvars(os.path.expanduser(sys.argv[1]))))' "$FIXTURE_PATH")
  [[ -f "$FIXTURE_PATH" && ! -L "$FIXTURE_PATH" ]] || die "custom fixture must be a regular non-symlink file: $FIXTURE_PATH"
fi
if ((LIVE_CANDIDATE)); then
  for command in ssh tailscale kubectl; do need "$command"; done
  [[ -f "$KUBECONFIG_PATH" && ! -L "$KUBECONFIG_PATH" ]] || die "live mode requires a regular kubeconfig: $KUBECONFIG_PATH"
fi

STAMP=$(date -u +%Y%m%dT%H%M%SZ)
SLUG="k3s-phase2-enrollment-${STAMP,,}"
BRANCH="wt/$SLUG"
WORKTREE="$REPO/.worktrees/$SLUG"
if [[ -z "$RESULT_ROOT" ]]; then RESULT_ROOT=$(dirname -- "$REPO"); fi
mkdir -p "$RESULT_ROOT"
RESULT_ROOT=$(CDPATH= cd -- "$RESULT_ROOT" && pwd)
RESULT_DIR="$RESULT_ROOT/overdeck-k3s-phase2-result-$STAMP"
[[ ! -e "$RESULT_DIR" ]] || die "result path already exists: $RESULT_DIR"
mkdir -p "$RESULT_DIR/logs" "$RESULT_DIR/package"
chmod 700 "$RESULT_DIR" "$RESULT_DIR/logs" "$RESULT_DIR/package"

PAYLOAD_DIR=$(mktemp -d)
GATE_TMP_DIR="$RESULT_DIR/gate-tmp"
mkdir -p "$GATE_TMP_DIR"
GATES_TSV="$RESULT_DIR/gates.tsv"
: >"$GATES_TSV"
FINISHED=0
STATUS=running
PUSH_STATUS=not-attempted
PR_STATUS=not-attempted
PR_URL=""
PLAN_STATUS=not-attempted
RECEIPT_VALIDATION_STATUS=not-attempted
COMMIT=""
BASE_COMMIT=""
ORIGIN_MAIN=""
PACKAGE_SHA=$(sha256sum "$PACKAGE" | awk '{print $1}')
PHASE2_RECEIPT="$RESULT_DIR/phase2-receipt"

export OD2_SCRIPT_VERSION=$SCRIPT_VERSION OD2_REPO="$REPO" OD2_WORKTREE="$WORKTREE" OD2_BRANCH="$BRANCH" \
  OD2_PACKAGE="$PACKAGE" OD2_PACKAGE_SHA="$PACKAGE_SHA" OD2_GATES="$GATES_TSV" \
  OD2_RESULT_JSON="$RESULT_DIR/phase2-outer-result.json" OD2_CANDIDATE="$CANDIDATE" \
  OD2_SERVER="$SERVER" OD2_LIVE_CANDIDATE="$LIVE_CANDIDATE" OD2_FIXTURE="$FIXTURE_PATH" \
  OD2_KUBECONFIG="$KUBECONFIG_PATH" OD2_GATE_TIMEOUT="$GATE_TIMEOUT_SEC" \
  OD2_ENROLLMENT_TIMEOUT="$ENROLLMENT_TIMEOUT_SEC"

write_result() {
  local state=$1 rc=$2
  STATUS=$state
  export OD2_STATUS="$state" OD2_EXIT_CODE="$rc" OD2_GENERATED_UTC="$(date -u +%FT%TZ)" \
    OD2_COMMIT="$COMMIT" OD2_BASE="$BASE_COMMIT" OD2_ORIGIN_MAIN="$ORIGIN_MAIN" \
    OD2_PUSH_STATUS="$PUSH_STATUS" OD2_PR_STATUS="$PR_STATUS" OD2_PR_URL="$PR_URL" \
    OD2_PLAN_STATUS="$PLAN_STATUS" OD2_VALIDATION_STATUS="$RECEIPT_VALIDATION_STATUS" \
    OD2_RECEIPT="$PHASE2_RECEIPT"
  python3 - <<'PYRESULT'
import json, os
from pathlib import Path

def optional(value):
    return value or None

result = {
    "schema_version": 1,
    "phase": 2,
    "script_version": int(os.environ["OD2_SCRIPT_VERSION"]),
    "status": os.environ["OD2_STATUS"],
    "exit_code": int(os.environ["OD2_EXIT_CODE"]),
    "generated_utc": os.environ["OD2_GENERATED_UTC"],
    "repository": os.environ["OD2_REPO"],
    "worktree": os.environ["OD2_WORKTREE"],
    "branch": os.environ["OD2_BRANCH"],
    "commit": optional(os.environ.get("OD2_COMMIT", "")),
    "origin_main_at_start": optional(os.environ.get("OD2_ORIGIN_MAIN", "")),
    "package": os.environ["OD2_PACKAGE"],
    "package_sha256": os.environ["OD2_PACKAGE_SHA"],
    "package_base_commit": optional(os.environ.get("OD2_BASE", "")),
    "candidate": os.environ["OD2_CANDIDATE"],
    "server": os.environ["OD2_SERVER"],
    "execution_mode": "live-read-only" if os.environ["OD2_LIVE_CANDIDATE"] == "1" else "fixture-dry-run",
    "custom_fixture": optional(os.environ.get("OD2_FIXTURE", "")),
    "kubeconfig_path": os.environ["OD2_KUBECONFIG"],
    "plan_status": os.environ.get("OD2_PLAN_STATUS", "unknown"),
    "receipt_validation_status": os.environ.get("OD2_VALIDATION_STATUS", "unknown"),
    "phase2_receipt": os.environ.get("OD2_RECEIPT"),
    "push_status": os.environ.get("OD2_PUSH_STATUS", "unknown"),
    "pr_status": os.environ.get("OD2_PR_STATUS", "unknown"),
    "pr_url": optional(os.environ.get("OD2_PR_URL", "")),
    "gate_timeout_seconds": int(os.environ["OD2_GATE_TIMEOUT"]),
    "enrollment_timeout_seconds": int(os.environ["OD2_ENROLLMENT_TIMEOUT"]),
    "live_mutation_allowed": False,
    "phase3_authorized": False,
    "secret_values_recorded": False,
}
with open(os.environ["OD2_GATES"], encoding="utf-8") as handle:
    gates=[]
    for line in handle:
        line=line.rstrip("\n")
        if not line:
            continue
        name, required, status, code, log_path = line.split("\t", 4)
        gates.append({
            "name": name,
            "required": required == "required",
            "status": status,
            "exit_code": int(code),
            "log": log_path,
        })
result["gates"] = gates
receipt = Path(os.environ.get("OD2_RECEIPT", ""))
result_path = receipt / "phase2-result.json"
if result_path.is_file():
    try:
        inner=json.load(result_path.open(encoding="utf-8"))
    except Exception as exc:
        result["phase2_parse_error"] = f"{type(exc).__name__}: {exc}"
    else:
        result["phase2_summary"] = {
            "status": inner.get("status"),
            "mode": inner.get("mode"),
            "candidate": inner.get("candidate"),
            "transaction_id": inner.get("transaction_id"),
            "plan_sha256": inner.get("plan_sha256"),
            "plan_step_count": inner.get("plan_step_count"),
            "plan_deterministic": inner.get("plan_deterministic") is True,
            "secret_scan_passed": inner.get("secret_scan_passed") is True,
            "source_digests_unchanged": inner.get("source_digests_before") == inner.get("source_digests_after"),
            "live_mutation_performed": inner.get("live_mutation_performed"),
            "candidate_mutation_performed": inner.get("candidate_mutation_performed"),
            "cluster_mutation_performed": inner.get("cluster_mutation_performed"),
            "registry_mutation_performed": inner.get("registry_mutation_performed"),
            "phase3_authorized": inner.get("phase3_authorized"),
            "git_publication_allowed": inner.get("git_publication_allowed") is True,
            "error": inner.get("error"),
        }
with open(os.environ["OD2_RESULT_JSON"], "w", encoding="utf-8") as handle:
    json.dump(result, handle, indent=2, sort_keys=True)
    handle.write("\n")
PYRESULT
  cat >"$RESULT_DIR/PHASE2_RESULT.txt" <<TXT
Overdeck K3s Phase 2 enrollment-planning result
Status: $state
Exit code: $rc
Generated UTC: $(date -u +%FT%TZ)
Repository: $REPO
Worktree: $WORKTREE
Branch: $BRANCH
Commit: ${COMMIT:-not-created}
Origin main at start: ${ORIGIN_MAIN:-unknown}
Package: $PACKAGE
Package SHA-256: $PACKAGE_SHA
Package base: ${BASE_COMMIT:-unknown}
Candidate: $CANDIDATE
Mode: $([[ $LIVE_CANDIDATE -eq 1 ]] && echo live-read-only || echo fixture-dry-run)
Plan status: $PLAN_STATUS
Receipt validation: $RECEIPT_VALIDATION_STATUS
Push: $PUSH_STATUS
Pull request: $PR_STATUS${PR_URL:+ ($PR_URL)}
Live mutation allowed: no
Phase 3 authorized: no

Return the sibling .tar.gz archive for review before merging or generating the Phase 3 canary package.
TXT
}

scan_result() {
  python3 - "$RESULT_DIR" <<'PYSCAN'
from pathlib import Path
import re, stat, sys
root=Path(sys.argv[1]).resolve()
patterns=(
    re.compile(rb"AGE-SECRET-KEY-[A-Z0-9-]+"),
    re.compile(rb"-----BEGIN (?:OPENSSH |RSA |EC |)PRIVATE KEY-----"),
    re.compile(rb"\bK10[0-9a-fA-F]{64}::[^\s]+"),
    re.compile(rb"(?i)\b[a-z0-9]{6}\.[a-z0-9]{16}\b"),
)
for path in root.rglob('*'):
    if path.is_symlink():
        raise SystemExit(f"result contains symlink: {path.relative_to(root)}")
    info=path.stat()
    if path.is_dir():
        continue
    if not stat.S_ISREG(info.st_mode):
        raise SystemExit(f"result contains special file: {path.relative_to(root)}")
    if info.st_size > 32*1024*1024:
        raise SystemExit(f"result file exceeds 32 MiB: {path.relative_to(root)}")
    data=path.read_bytes()
    for pattern in patterns:
        if pattern.search(data):
            raise SystemExit(f"secret-like material found in result: {path.relative_to(root)}")
PYSCAN
}

archive_result() {
  local archive="${RESULT_DIR}.tar.gz"
  rm -rf -- "$GATE_TMP_DIR" 2>/dev/null || true
  if ! scan_result; then
    return 1
  fi
  printf '%s\n' "$archive" >"$RESULT_DIR/RESULT_ARCHIVE_PATH.txt"
  rm -f -- "$archive"
  tar -C "$(dirname -- "$RESULT_DIR")" -czf "$archive" "$(basename -- "$RESULT_DIR")" || return 1
  printf '%s\n' "$archive"
}

on_exit() {
  local rc=$?
  trap - EXIT
  rm -rf -- "$PAYLOAD_DIR" 2>/dev/null || true
  if ((FINISHED == 0)); then
    write_result failed "$rc" || true
    local archive
    archive=$(archive_result 2>/dev/null || true)
    printf '\nPhase 2 failed. The isolated worktree and logs were left intact.\n' >&2
    [[ -z "$archive" ]] || printf 'Return this failure receipt: %s\n' "$archive" >&2
  fi
  exit "$rc"
}
trap on_exit EXIT
exec > >(tee -a "$RESULT_DIR/apply.log") 2>&1

run_gate() {
  local required=$1 name=$2; shift 2
  local safe=${name//[^A-Za-z0-9._-]/_}
  local logfile="$RESULT_DIR/logs/$safe.log" rc status start end
  log "gate: $name"
  start=$(date -u +%FT%TZ)
  set +e
  (
    printf 'started_utc: %s\nexecution_scope: local-candidate-worktree\ncommand:' "$start"
    printf ' env LOCAL_GATE_ACTIVE=1 CPU_GUARD_ACTIVE=1 OD_PHASE_GATE_LOCAL=1 TMPDIR=%q' "$GATE_TMP_DIR"
    printf ' timeout --signal=TERM --kill-after=20s %q' "${GATE_TIMEOUT_SEC}s"
    printf ' %q' "$@"
    printf '\n\n'
    env LOCAL_GATE_ACTIVE=1 CPU_GUARD_ACTIVE=1 OD_PHASE_GATE_LOCAL=1 \
      TMPDIR="$GATE_TMP_DIR" PYTHONPYCACHEPREFIX="$GATE_TMP_DIR/pycache" PYTHONDONTWRITEBYTECODE=1 \
      timeout --signal=TERM --kill-after=20s "${GATE_TIMEOUT_SEC}s" "$@"
  ) >"$logfile" 2>&1
  rc=$?
  set -e
  end=$(date -u +%FT%TZ)
  printf '\nfinished_utc: %s\nexit_code: %s\n' "$end" "$rc" >>"$logfile"
  if ((rc == 0)); then status=passed; else status=failed; fi
  printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$required" "$status" "$rc" "logs/$safe.log" >>"$GATES_TSV"
  if [[ "$required" == required && $rc -ne 0 ]]; then
    tail -n 160 "$logfile" >&2 || true
    die "required gate failed: $name (rc=$rc)"
  fi
  if ((rc != 0)); then log "optional gate failed: $name (recorded)"; fi
}

record_skip() {
  local required=$1 name=$2 reason=$3
  local safe=${name//[^A-Za-z0-9._-]/_}
  printf '%s\n' "$reason" >"$RESULT_DIR/logs/$safe.log"
  printf '%s\t%s\tskipped\t0\t%s\n' "$name" "$required" "logs/$safe.log" >>"$GATES_TSV"
  log "gate skipped: $name — $reason"
}

log "verifying package $PACKAGE"
python3 - "$PACKAGE" "$PAYLOAD_DIR" <<'PYEXTRACT'
from pathlib import Path, PurePosixPath
from zipfile import ZipFile
import os, stat, sys
source=Path(sys.argv[1]); destination=Path(sys.argv[2]).resolve()
seen=set(); total=0
with ZipFile(source) as archive:
    infos=archive.infolist()
    if not infos:
        raise SystemExit('empty package archive')
    for info in infos:
        name=info.filename
        pure=PurePosixPath(name)
        normalized=pure.as_posix().rstrip('/')
        if not name or pure.is_absolute() or '..' in pure.parts or '\\' in name:
            raise SystemExit(f'unsafe package path: {name!r}')
        if normalized in seen:
            raise SystemExit(f'duplicate package path: {name!r}')
        seen.add(normalized)
        mode=(info.external_attr >> 16) & 0o177777
        kind=stat.S_IFMT(mode)
        if kind == stat.S_IFLNK:
            raise SystemExit(f'package symlink refused: {name!r}')
        if not info.is_dir() and kind not in {0, stat.S_IFREG}:
            raise SystemExit(f'package special file refused: {name!r}')
        if info.file_size > 32*1024*1024:
            raise SystemExit(f'package member exceeds 32 MiB: {name!r}')
        total += info.file_size
        if total > 128*1024*1024:
            raise SystemExit('package uncompressed size exceeds 128 MiB')
        target=(destination / pure).resolve()
        if target != destination and destination not in target.parents:
            raise SystemExit(f'package path escapes extraction root: {name!r}')
    for info in infos:
        pure=PurePosixPath(info.filename)
        target=(destination / pure).resolve()
        if info.is_dir():
            target.mkdir(parents=True, exist_ok=True)
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        data=archive.read(info)
        descriptor=os.open(target, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0o600)
        try:
            with os.fdopen(descriptor, 'wb') as handle:
                handle.write(data); handle.flush(); os.fsync(handle.fileno())
        except BaseException:
            target.unlink(missing_ok=True)
            raise
        archived_mode=(info.external_attr >> 16) & 0o777
        os.chmod(target, 0o755 if archived_mode & 0o111 else 0o644)
PYEXTRACT
for file in \
  SHA256SUMS PACKAGE.json CHANGED_PATHS.txt MANIFEST.md PR_BODY.md AGENT_INSTRUCTIONS.md VALIDATION.md \
  PLAN_DOCUMENT_UPDATE.json PLAN_INDEX_UPDATE.json phase2-core.patch phase2-full.patch \
  apply-overdeck-k3s-phase2.sh \
  package-tools/merge-plan-document.py package-tools/test_merge_plan_document.py \
  package-tools/merge-plan-index.py package-tools/test_merge_plan_index.py \
  package-tools/test_runner_contract.py; do
  [[ -f "$PAYLOAD_DIR/$file" ]] || die "package missing $file"
done
python3 - "$SELF_PATH" "$PAYLOAD_DIR/apply-overdeck-k3s-phase2.sh" <<'PYSELF'
from pathlib import Path
import hashlib, sys
external, packaged = map(Path, sys.argv[1:])
def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest()
if digest(external) != digest(packaged):
    raise SystemExit('launcher/package mismatch: use apply-overdeck-k3s-phase2.sh distributed with this ZIP')
PYSELF
(
  cd "$PAYLOAD_DIR"
  sha256sum -c SHA256SUMS
)
python3 - "$PAYLOAD_DIR" <<'PYPACKAGE'
from pathlib import Path, PurePosixPath
import json, re, sys
root=Path(sys.argv[1])
doc=json.load((root/'PACKAGE.json').open(encoding='utf-8'))
if doc.get('schema_version') != 1 or doc.get('phase') != 2 or doc.get('runner_version') != 1:
    raise SystemExit('unsupported package schema/phase/runner')
if doc.get('name') != 'overdeck-k3s-phase2-enrollment':
    raise SystemExit('unexpected package name')
base=doc.get('base_commit','')
if not re.fullmatch(r'[0-9a-f]{40}', base):
    raise SystemExit('invalid package base commit')
paths=doc.get('changed_paths'); conditional=doc.get('conditionally_changed_paths',[]); archive=doc.get('archive_paths')
if not isinstance(paths,list) or not paths or len(paths)!=len(set(paths)):
    raise SystemExit('changed_paths must be a non-empty unique list')
if not isinstance(conditional,list) or len(conditional)!=len(set(conditional)) or not set(conditional).issubset(paths):
    raise SystemExit('conditionally_changed_paths must be a unique subset')
if not isinstance(archive,list) or len(archive)!=len(set(archive)):
    raise SystemExit('archive_paths must be a unique list')
for value in paths+archive:
    pure=PurePosixPath(value)
    if pure.is_absolute() or '..' in pure.parts or not value:
        raise SystemExit(f'unsafe package path declaration: {value!r}')
actual=sorted(str(path.relative_to(root)) for path in root.rglob('*') if path.is_file())
if sorted(archive) != actual:
    raise SystemExit(f'archive path mismatch: missing={sorted(set(archive)-set(actual))!r} unexpected={sorted(set(actual)-set(archive))!r}')
changed_file=(root/'CHANGED_PATHS.txt').read_text(encoding='utf-8').splitlines()
if changed_file != paths:
    raise SystemExit('CHANGED_PATHS.txt differs from PACKAGE.json')
for path in paths:
    if not (root/path).is_file():
        raise SystemExit(f'changed path content missing: {path}')
if doc.get('merge_tolerant_paths') != conditional:
    raise SystemExit('merge_tolerant_paths must equal conditionally_changed_paths')
application=doc.get('patch_application') or {}
if application.get('core_patch') != 'phase2-core.patch' or application.get('full_patch') != 'phase2-full.patch':
    raise SystemExit('unsupported patch filenames')
if application.get('semantic_updates') != ['PLAN_DOCUMENT_UPDATE.json','PLAN_INDEX_UPDATE.json']:
    raise SystemExit('unsupported semantic update order')
ui=doc.get('ui') or {}
if ui.get('product_ui_changed') is not False or ui.get('new_primitives') is not False or ui.get('astryx_required') is not False:
    raise SystemExit('Phase 2 package unexpectedly declares product UI work')
PYPACKAGE
cp "$PAYLOAD_DIR/MANIFEST.md" "$PAYLOAD_DIR/PACKAGE.json" "$PAYLOAD_DIR/SHA256SUMS" \
  "$PAYLOAD_DIR/AGENT_INSTRUCTIONS.md" "$PAYLOAD_DIR/VALIDATION.md" \
  "$PAYLOAD_DIR/PLAN_DOCUMENT_UPDATE.json" "$PAYLOAD_DIR/PLAN_INDEX_UPDATE.json" \
  "$RESULT_DIR/package/"
BASE_COMMIT=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["base_commit"])' "$PAYLOAD_DIR/PACKAGE.json")
mapfile -t EXPECTED_PATHS < <(python3 -c 'import json,sys; print("\n".join(json.load(open(sys.argv[1]))["changed_paths"]))' "$PAYLOAD_DIR/PACKAGE.json")
((${#EXPECTED_PATHS[@]} > 0)) || die "package changed_paths is empty"

run_gate required semantic-plan-document-tests python3 "$PAYLOAD_DIR/package-tools/test_merge_plan_document.py"
run_gate required semantic-plan-index-tests python3 "$PAYLOAD_DIR/package-tools/test_merge_plan_index.py"
run_gate required runner-publication-contract env OD_RUNNER_PATH="$SELF_PATH" python3 "$PAYLOAD_DIR/package-tools/test_runner_contract.py"

log "fetching current origin/main without modifying the shared checkout"
/usr/bin/git -C "$REPO" fetch --prune origin +refs/heads/main:refs/remotes/origin/main
ORIGIN_MAIN=$(/usr/bin/git -C "$REPO" rev-parse origin/main)
if ! /usr/bin/git -C "$REPO" cat-file -e "$BASE_COMMIT^{commit}" 2>/dev/null; then
  /usr/bin/git -C "$REPO" fetch origin "$BASE_COMMIT" || true
fi
/usr/bin/git -C "$REPO" cat-file -e "$BASE_COMMIT^{commit}" 2>/dev/null || die "package base commit is unavailable: $BASE_COMMIT"
/usr/bin/git -C "$REPO" merge-base --is-ancestor "$BASE_COMMIT" "$ORIGIN_MAIN" || die "package base is not an ancestor of current origin/main"

log "creating isolated worktree $WORKTREE"
(
  cd "$REPO"
  modules/workstation/claude/bin/od-worktree add "$SLUG" origin/main
)
[[ -d "$WORKTREE" ]] || die "worktree helper did not create $WORKTREE"

log "applying Phase 2 core patch with three-way conflict detection"
/usr/bin/git -C "$WORKTREE" apply --3way "$PAYLOAD_DIR/phase2-core.patch"
log "semantically merging Phase 1 status and the complete Phase 2 section"
python3 "$PAYLOAD_DIR/package-tools/merge-plan-document.py" \
  --path "$WORKTREE/docs/plans/2026-08-10-k3s-migration-execution.md" \
  --spec "$PAYLOAD_DIR/PLAN_DOCUMENT_UPDATE.json" --apply \
  >"$RESULT_DIR/logs/semantic-plan-document-merge.json"
log "semantically merging the K3s migration row in the plan index"
python3 "$PAYLOAD_DIR/package-tools/merge-plan-index.py" \
  --path "$WORKTREE/docs/plans/INDEX.md" \
  --spec "$PAYLOAD_DIR/PLAN_INDEX_UPDATE.json" --apply \
  >"$RESULT_DIR/logs/semantic-plan-index-merge.json"
/usr/bin/git -C "$WORKTREE" reset --quiet
[[ -z "$(/usr/bin/git -C "$WORKTREE" diff --cached --name-only)" ]] || die "candidate index is not clean before verification"

check_changed_paths() {
  python3 - "$WORKTREE" "$PAYLOAD_DIR/PACKAGE.json" <<'PYCHECK'
import json, subprocess, sys
root, package=sys.argv[1:]
doc=json.load(open(package,encoding='utf-8'))
expected=set(doc['changed_paths']); conditional=set(doc.get('conditionally_changed_paths',[])); required=expected-conditional
tracked=set(subprocess.check_output(['/usr/bin/git','-C',root,'diff','--name-only','HEAD'],text=True).splitlines())
untracked=set(subprocess.check_output(['/usr/bin/git','-C',root,'ls-files','--others','--exclude-standard'],text=True).splitlines())
actual=tracked|untracked
missing=required-actual; unexpected=actual-expected
if missing or unexpected:
    raise SystemExit(f'changed path mismatch: missing_required={sorted(missing)!r} unexpected={sorted(unexpected)!r} actual={sorted(actual)!r}')
for path in sorted(required):
    left=open(f'{root}/{path}','rb').read(); right=open(f'{package.rsplit("/",1)[0]}/{path}','rb').read()
    if left != right:
        raise SystemExit(f'candidate content differs from package for stable path: {path}')
PYCHECK
}
check_changed_paths

run_gate required launcher-bash-syntax bash -n "$SELF_PATH"
run_gate required phase2-bash-syntax bash -n \
  "$WORKTREE/tools/k3s/enroll-node.sh" \
  "$WORKTREE/tools/k3s/test/phase2-enrollment.test.sh" \
  "$WORKTREE/tools/k3s/test/k3s-phase2-enrollment-html.test.sh" \
  "$WORKTREE/tools/k3s/phase1-control-plane.sh" \
  "$WORKTREE/tools/k3s/test/phase1-control-plane.test.sh" \
  "$WORKTREE/tools/k3s/phase0-discover.sh"
run_gate required phase2-python-compile python3 -c '
import pathlib,sys
for value in sys.argv[1:]:
    path=pathlib.Path(value)
    compile(path.read_text(encoding="utf-8"), str(path), "exec")
' \
  "$WORKTREE/tools/k3s/enroll-node.py" \
  "$WORKTREE/tools/k3s/validate-enrollment-receipt.py" \
  "$WORKTREE/tools/k3s/lib/phase2_common.py" \
  "$WORKTREE/tools/k3s/remote/phase2-candidate.py" \
  "$WORKTREE/tools/k3s/remote/phase2-server.py" \
  "$PAYLOAD_DIR/package-tools/merge-plan-document.py" \
  "$PAYLOAD_DIR/package-tools/merge-plan-index.py"
run_gate required k3s-python-unit-and-failure-tests bash -c "cd \"$WORKTREE\" && python3 -m unittest discover -s tools/k3s/test -p 'test_*.py' -v"
run_gate required phase2-deterministic-transaction bash "$WORKTREE/tools/k3s/test/phase2-enrollment.test.sh"
run_gate required phase2-presentation-static bash "$WORKTREE/tools/k3s/test/k3s-phase2-enrollment-html.test.sh"
run_gate required phase1-fake-cluster-regression bash "$WORKTREE/tools/k3s/test/phase1-control-plane.test.sh"
run_gate required phase0-collector-regression bash "$WORKTREE/tools/k3s/test/phase0-discover.test.sh"
run_gate required phase0-presentation-regression bash "$WORKTREE/tools/k3s/test/k3s-migration-audit-html.test.sh"
run_gate required execution-locality env OD_EXPECTED_WORKTREE="$WORKTREE" node -e '
const fs=require("node:fs"), path=require("node:path");
const expected=process.env.OD_EXPECTED_WORKTREE;
if(!expected||!fs.existsSync(path.join(expected,".git"))){console.error(`candidate worktree is not locally visible: ${expected||"<unset>"}`);process.exit(81)}
console.log(`candidate_worktree=${fs.realpathSync(expected)}`);
console.log(`node_exec=${process.execPath}`);
console.log(`phase_gate_local=${process.env.OD_PHASE_GATE_LOCAL||""}`);
'
run_gate required legacy-k3s-unit node "$WORKTREE/modules/workstation/claude/tests/k3s-remote-build.test.mjs"
run_gate required fleet-core bash -c "cd \"$WORKTREE\" && node --test modules/fleet/test/loader.test.mjs modules/fleet/test/expand.test.mjs modules/fleet/test/engine.test.mjs modules/fleet/test/harden.test.mjs"
run_gate required fleet-cli bash -c "cd \"$WORKTREE\" && bash modules/fleet/test/cli.test.sh"
if [[ "$HOME" == /home/user ]]; then
  run_gate optional remote-seat-transaction env HOME="$HOME" node "$WORKTREE/modules/workstation/claude/tests/remote-seat-provision.test.mjs"
else
  record_skip optional remote-seat-transaction "baseline remote-seat test asserts canonical /home/user; current HOME is $HOME"
fi
run_gate optional legacy-k3s-integration bash "$WORKTREE/modules/workstation/claude/tests/k3s-remote-build-integration.sh"
if python3 -c 'import pytest' >/dev/null 2>&1; then
  run_gate optional factory-kubernetes-regression bash -c "cd \"$WORKTREE\" && PYTHONPATH=modules/harness/factory python3 -m pytest -q modules/harness/factory/tests/test_kubernetes_job.py"
else
  record_skip optional factory-kubernetes-regression "python pytest is not installed; Phase 2 does not modify Factory code"
fi
run_gate required git-diff-check bash -c "/usr/bin/git -C \"$WORKTREE\" diff --check && /usr/bin/git -C \"$WORKTREE\" diff --cached --check"
check_changed_paths

if ((LIVE_CANDIDATE)); then
  PLAN_MODE=plan
  PLAN_ARGS=(
    "$CANDIDATE" --plan --repo-root "$WORKTREE"
    --server "$SERVER" --server-ssh-door "$SERVER_SSH_DOOR"
    --candidate-user "$CANDIDATE_USER" --kubeconfig "$KUBECONFIG_PATH"
    --receipt-dir "$PHASE2_RECEIPT" --lock-file "$RESULT_DIR/phase2.lock"
  )
else
  PLAN_MODE=dry-run
  if [[ -z "$FIXTURE_PATH" ]]; then
    FIXTURE_PATH="$WORKTREE/tools/k3s/test/fixtures/phase2-debian4.json"
    [[ "$CANDIDATE" == debian4 ]] || die "the shipped fixture is for debian4; pass --fixture or --live-candidate for $CANDIDATE"
  fi
  PLAN_ARGS=(
    "$CANDIDATE" --dry-run --repo-root "$WORKTREE" --fixture "$FIXTURE_PATH"
    --receipt-dir "$PHASE2_RECEIPT" --lock-file "$RESULT_DIR/phase2.lock"
  )
fi

log "running Phase 2 $PLAN_MODE enrollment planning for $CANDIDATE"
PLAN_STATUS=running
set +e
env LOCAL_GATE_ACTIVE=1 CPU_GUARD_ACTIVE=1 OD_PHASE_GATE_LOCAL=1 \
  TMPDIR="$GATE_TMP_DIR" PYTHONPYCACHEPREFIX="$GATE_TMP_DIR/pycache" PYTHONDONTWRITEBYTECODE=1 \
  timeout --signal=TERM --kill-after=60s "${ENROLLMENT_TIMEOUT_SEC}s" \
  "$WORKTREE/tools/k3s/enroll-node.sh" "${PLAN_ARGS[@]}" \
  >"$RESULT_DIR/logs/phase2-plan.stdout.log" 2>"$RESULT_DIR/logs/phase2-plan.stderr.log"
plan_rc=$?
set -e
printf 'phase2-%s\trequired\t%s\t%s\t%s\n' "$PLAN_MODE" "$([[ $plan_rc -eq 0 ]] && echo passed || echo failed)" "$plan_rc" "logs/phase2-plan.stderr.log" >>"$GATES_TSV"
if ((plan_rc != 0)); then
  PLAN_STATUS=failed
  tail -n 200 "$RESULT_DIR/logs/phase2-plan.stderr.log" >&2 || true
  die "Phase 2 $PLAN_MODE failed (rc=$plan_rc)"
fi
PLAN_STATUS=success

log "independently validating the Phase 2 zero-mutation receipt"
set +e
"$WORKTREE/tools/k3s/validate-enrollment-receipt.py" --receipt "$PHASE2_RECEIPT" --candidate "$CANDIDATE" \
  >"$RESULT_DIR/logs/phase2-receipt-validation.json" 2>"$RESULT_DIR/logs/phase2-receipt-validation.err"
validation_rc=$?
set -e
printf 'phase2-receipt-validation\trequired\t%s\t%s\t%s\n' "$([[ $validation_rc -eq 0 ]] && echo passed || echo failed)" "$validation_rc" "logs/phase2-receipt-validation.json" >>"$GATES_TSV"
if ((validation_rc != 0)); then
  RECEIPT_VALIDATION_STATUS=failed
  cat "$RESULT_DIR/logs/phase2-receipt-validation.json" >&2 || true
  cat "$RESULT_DIR/logs/phase2-receipt-validation.err" >&2 || true
  die "Phase 2 receipt validation failed"
fi
RECEIPT_VALIDATION_STATUS=passed
check_changed_paths
[[ -z "$(/usr/bin/git -C "$WORKTREE" diff --cached --name-only)" ]] || die "candidate index changed before receipt authorization"

log "staging only package-declared repository paths after receipt authorization"
/usr/bin/git -C "$WORKTREE" add -- "${EXPECTED_PATHS[@]}"
python3 - "$WORKTREE" "$PAYLOAD_DIR/PACKAGE.json" <<'PYSTAGE'
import json, subprocess, sys
root, package=sys.argv[1:]
doc=json.load(open(package,encoding='utf-8'))
expected=set(doc['changed_paths']); conditional=set(doc.get('conditionally_changed_paths',[])); required=expected-conditional
actual=set(subprocess.check_output(['/usr/bin/git','-C',root,'diff','--cached','--name-only'],text=True).splitlines())
missing=required-actual; unexpected=actual-expected
if missing or unexpected:
    raise SystemExit(f'staged path mismatch: missing_required={sorted(missing)!r} unexpected={sorted(unexpected)!r} actual={sorted(actual)!r}')
PYSTAGE
/usr/bin/git -C "$WORKTREE" diff --cached --check

log "committing the exact Phase 2 candidate"
GIT_AUTHOR_NAME=${GIT_AUTHOR_NAME:-Overdeck Phase Automation}
GIT_AUTHOR_EMAIL=${GIT_AUTHOR_EMAIL:-overdeck-phase@local}
GIT_COMMITTER_NAME=${GIT_COMMITTER_NAME:-$GIT_AUTHOR_NAME}
GIT_COMMITTER_EMAIL=${GIT_COMMITTER_EMAIL:-$GIT_AUTHOR_EMAIL}
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL
/usr/bin/git -C "$WORKTREE" commit -m "k3s: add deterministic node enrollment planning"
COMMIT=$(/usr/bin/git -C "$WORKTREE" rev-parse HEAD)
/usr/bin/git -C "$WORKTREE" show --stat --oneline --decorate HEAD >"$RESULT_DIR/git-show.txt"
/usr/bin/git -C "$WORKTREE" diff --name-status "${COMMIT}^" "$COMMIT" >"$RESULT_DIR/committed-paths.txt"
/usr/bin/git -C "$WORKTREE" diff "${COMMIT}^" "$COMMIT" --binary | sha256sum | awk '{print $1}' >"$RESULT_DIR/committed-patch.sha256"

log "removing ephemeral gate scratch before pre-publication scanning"
rm -rf -- "$GATE_TMP_DIR"
log "scanning the complete pre-publication receipt for secret-like material"
scan_result || die "pre-publication result secret scan failed"

if ((PUSH)); then
  log "pushing candidate branch $BRANCH"
  set +e
  /usr/bin/git -C "$WORKTREE" push -u origin "HEAD:refs/heads/$BRANCH" >"$RESULT_DIR/logs/git-push.log" 2>&1
  push_rc=$?
  set -e
  if ((push_rc != 0)); then
    PUSH_STATUS=failed
    tail -n 160 "$RESULT_DIR/logs/git-push.log" >&2 || true
    die "candidate branch push failed"
  fi
  PUSH_STATUS=pushed
else
  PUSH_STATUS=skipped
fi

if [[ "$PUSH_STATUS" == pushed && $CREATE_PR -eq 1 ]]; then
  if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
    log "opening a draft Phase 2 pull request"
    set +e
    PR_URL=$(cd "$WORKTREE" && gh pr create --draft --base main --head "$BRANCH" \
      --title "k3s: deterministic one-command enrollment planning" \
      --body-file "$PAYLOAD_DIR/PR_BODY.md" 2>"$RESULT_DIR/logs/gh-pr.err")
    pr_rc=$?
    set -e
    if ((pr_rc == 0)); then PR_STATUS=created; else PR_STATUS=failed; fi
  else
    PR_STATUS=skipped-gh-unavailable
  fi
else
  PR_STATUS=skipped
fi

write_result success 0
if ! archive=$(archive_result); then
  die "final result sanitization or archive creation failed"
fi
FINISHED=1
trap - EXIT
rm -rf -- "$PAYLOAD_DIR" 2>/dev/null || true

printf '\nPhase 2 deterministic enrollment-planning package completed.\n'
printf 'Worktree: %s\nBranch: %s\nCommit: %s\n' "$WORKTREE" "$BRANCH" "$COMMIT"
printf 'Candidate: %s\nMode: %s\nPlan: %s\nReceipt validation: %s\n' "$CANDIDATE" "$PLAN_MODE" "$PLAN_STATUS" "$RECEIPT_VALIDATION_STATUS"
printf 'Push: %s\nPull request: %s%s\n' "$PUSH_STATUS" "$PR_STATUS" "${PR_URL:+ ($PR_URL)}"
printf 'Return this archive for Phase 3 review: %s\n' "$archive"
