Ctrl K
ComunidadGuías…

deploy/updater.sh.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, expect, test, beforeEach } from 'bun:test'
import { spawnSync } from 'node:child_process'
import {
  cpSync,
  existsSync,
  mkdirSync,
  readdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
  chmodSync,
  copyFileSync,
  lstatSync,
  statSync,
  symlinkSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { guardRealStateDir, installCleanup, isolatedEnv, mkTempTree } from './tmp-fixtures'

// Temp-tree ownership: EVERY scratch tree below comes from mkTempTree, and the
// sweep registered here removes it after each test — including after one that
// throws. This file used to leak ~28 /tmp directories per run, on the same
// filesystem as production's database (AGENTS.md §8); see tmp-fixtures.ts.
installCleanup()
// Tripwire for #1101: updater.sh and update.sh both read and write the
// operator's real ~/.synaptomind when RUN_DIR is inherited rather than pinned.
guardRealStateDir()

// ── Fixture helpers ──────────────────────────────────────────────────────────

function seedSingleTagRepo(tag: string, layout: Record<string, string>): string {
  const dir = mkTempTree('synapto-single-')
  spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' })
  spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, encoding: 'utf8' })
  for (const [path, content] of Object.entries(layout)) {
    const full = join(dir, path)
    mkdirSync(join(full, '..'), { recursive: true })
    writeFileSync(full, content)
  }
  spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['commit', '-q', '-m', 'initial'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['tag', tag], { cwd: dir, encoding: 'utf8' })
  return dir
}

function seedNoDeployRepo(): string {
  const dir = mkTempTree('synapto-nodeploy-')
  spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' })
  spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, encoding: 'utf8' })
  writeFileSync(join(dir, 'package.json'), '{"name":"synaptomind","version":"0.7.3"}')
  spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['commit', '-q', '-m', 'no deploy'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['tag', 'v0.7.3'], { cwd: dir, encoding: 'utf8' })
  return dir
}

/** Repo with two stable tags; latest-first sort yields the newer one. */
function seedTwoStableTagsRepo(
  olderTag: string,
  newerTag: string,
  layout: Record<string, string>
): string {
  const dir = mkTempTree('synapto-two-stable-')
  spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' })
  spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, encoding: 'utf8' })
  for (const [path, content] of Object.entries(layout)) {
    const full = join(dir, path)
    mkdirSync(join(full, '..'), { recursive: true })
    writeFileSync(full, content)
  }
  spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['commit', '-q', '-m', 'initial'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['tag', olderTag], { cwd: dir, encoding: 'utf8' })
  // Newer commit on a branch, merged back so both tags exist
  spawnSync('git', ['checkout', '-q', '-b', 'newer'], { cwd: dir, encoding: 'utf8' })
  writeFileSync(join(dir, 'package.json'), '{"name":"synaptomind","version":"0.8.0"}')
  spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['commit', '-q', '-m', 'newer'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['tag', newerTag], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['checkout', '-q', 'master'], { cwd: dir, encoding: 'utf8' })
  return dir
}

function seedNoStableTagsRepo(): string {
  const dir = mkTempTree('synapto-nostable-')
  spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' })
  spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, encoding: 'utf8' })
  writeFileSync(join(dir, 'package.json'), '{"name":"synaptomind","version":"0.8.0-beta.1"}')
  spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['commit', '-q', '-m', 'beta'], { cwd: dir, encoding: 'utf8' })
  spawnSync('git', ['tag', 'v0.8.0-beta.1'], { cwd: dir, encoding: 'utf8' })
  return dir
}

/** Minimal working update.sh that exits with the given code. */
function makeMinimalUpdateSh(exitCode: number): string {
  return [
    '#!/usr/bin/env bash',
    'set -euo pipefail',
    'SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd -P)" || SCRIPT_DIR=""',
    'load_common() {',
    '  local cand',
    '  for cand in "${SCRIPT_DIR}/lib/common.sh" "${SCRIPT_DIR}/common.sh"; do',
    '    if [ -f "$cand" ]; then . "$cand"; return 0; fi',
    '  done',
    '  echo "[app] ERROR: cannot find lib/common.sh next to $0" >&2; exit 1',
    '}',
    'load_common',
    'trap cleanup_run EXIT',
    'ARG_VERSION=""',
    'ASSUME_YES=false',
    'parse_args() {',
    '  while [ $# -gt 0 ]; do',
    '    case "$1" in',
    '      --version) ARG_VERSION="$2"; shift 2 ;;',
    '      --yes|-y)  ASSUME_YES=true;  shift   ;;',
    '      --help|-h) sed -n \'2,16p\' "$0" 2>/dev/null || echo "See header"; exit 0 ;;',
    '      *) error "unknown option: $1 (try --help)" ;;',
    '    esac',
    '  done',
    '}',
    'main() {',
    '  parse_args "$@"',
    '  load_app_env',
    '  exit ' + exitCode,
    '}',
    'main "$@"'
  ].join('\n')
}

/** Tampered update.sh missing --version flag (has --wrongflag instead). */
function makeTamperedUpdateSh(): string {
  return [
    '#!/usr/bin/env bash',
    'set -euo pipefail',
    'SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd -P)" || SCRIPT_DIR=""',
    'load_common() {',
    '  local cand',
    '  for cand in "${SCRIPT_DIR}/lib/common.sh" "${SCRIPT_DIR}/common.sh"; do',
    '    if [ -f "$cand" ]; then . "$cand"; return 0; fi',
    '  done',
    '  echo "[app] ERROR: cannot find lib/common.sh next to $0" >&2; exit 1',
    '}',
    'load_common',
    'trap cleanup_run EXIT',
    'ARG_VERSION=""',
    'ASSUME_YES=false',
    'parse_args() {',
    '  while [ $# -gt 0 ]; do',
    '    case "$1" in',
    '      --wrongflag) ARG_VERSION="$2"; shift 2 ;;',
    '      --yes|-y)  ASSUME_YES=true;  shift   ;;',
    '      --help|-h) sed -n \'2,16p\' "$0" 2>/dev/null || echo "See header"; exit 0 ;;',
    '      *) error "unknown option: $1 (try --help)" ;;',
    '    esac',
    '  done',
    '}',
    'main() {',
    '  parse_args "$@"',
    '  load_app_env',
    '  exit 0',
    '}',
    'main "$@"'
  ].join('\n')
}

/**
 * Fixture update.sh that records its invocation for test assertions.
 * Writes a `.invoked` sentinel at the top of main() and the args to
 * the path given by the UPDATER_ARTIFACT env var (defaults to /dev/null).
 */
function makeTrackingUpdateSh(): string {
  return [
    '#!/usr/bin/env bash',
    'set -euo pipefail',
    'SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd -P)" || SCRIPT_DIR=""',
    'load_common() {',
    '  local cand',
    '  for cand in "${SCRIPT_DIR}/lib/common.sh" "${SCRIPT_DIR}/common.sh"; do',
    '    if [ -f "$cand" ]; then . "$cand"; return 0; fi',
    '  done',
    '  echo "[app] ERROR: cannot find lib/common.sh next to $0" >&2; exit 1',
    '}',
    'load_common',
    'trap cleanup_run EXIT',
    'ARG_VERSION=""',
    'ASSUME_YES=false',
    'parse_args() {',
    '  while [ $# -gt 0 ]; do',
    '    case "$1" in',
    '      --version) ARG_VERSION="$2"; shift 2 ;;',
    '      --yes|-y)  ASSUME_YES=true;  shift   ;;',
    '      --help|-h) printf \'%s\\n\' "$@" > "${UPDATER_ARTIFACT:-/dev/null}" && exit 0 ;;',
    '      *) error "unknown option: $1 (try --help)" ;;',
    '    esac',
    '  done',
    '}',
    'main() {',
    '  # Sentinel at the top of main(): proves invocation even when --help short-circuits.',
    '  touch "${UPDATER_ARTIFACT:-/dev/null}.invoked"',
    '  parse_args "$@"',
    '  load_app_env',
    '  printf \'%s\\n\' "$@" > "${UPDATER_ARTIFACT:-/dev/null}"',
    '  exit 0',
    '}',
    'main "$@"'
  ].join('\n')
}

/**
 * One function's DEFINITION, lifted verbatim from deploy/lib/common.sh.
 *
 * WHY EXTRACTED RATHER THAN COPIED (#1120). This fixture used to hand-write its
 * own url_get, and the copy drifted the moment the shipped helper changed — the
 * resume/retry/wget-fallback rewrite landed with nothing comparing the two, so
 * every bootstrap case went on exercising a helper the framework no longer has.
 * Extraction makes that drift unrepresentable: the fixture carries whatever the
 * shipped file says, on every run.
 *
 * `declare -f` is bash's own parser, so this cannot mis-slice a nested brace, a
 * line continuation or a `}` inside a string the way a brace counter would, and
 * its output is valid bash source to splice back in. Both guards throw instead of
 * returning something empty: an empty definition would fail later, in a test
 * about tag resolution, with nothing pointing at the fixture.
 */
function realCommonShFunction(name: string): string[] {
  const commonShPath = resolve(import.meta.dir, 'lib', 'common.sh')
  const escapedCommonShPath = commonShPath.replace(/[\\\"`$]/g, '\\$&')
  const helperPath = join(
    tmpdir(),
    `extract-common-fn-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.sh`
  )
  writeFileSync(
    helperPath,
    `#!/usr/bin/env bash\n. "${escapedCommonShPath}"\ndeclare -f -- "$1"\n`,
    {
      mode: 0o700
    }
  )
  const res = spawnSync('bash', [helperPath, name], {
    encoding: 'utf8',
    timeout: 10_000
  })
  const out = (res.stdout ?? '').trim()
  // bash prints `name ()` — a space before the parens.
  if (res.status !== 0 || !out.startsWith(`${name} ()`)) {
    throw new Error(
      `cannot extract ${name}() from deploy/lib/common.sh (status ${res.status}): ${(res.stderr ?? '').trim()}`
    )
  }
  return out.split('\n')
}

/**
 * Minimal common.sh providing only what the bootstrap needs.
 *
 * Most of it is still hand-written, and deliberately so: cleanup_run removes
 * FILES only, which is the contract updater.sh's own stage cleanup is tested
 * against (updater.sh's STAGE_ROOT comment), and load_app_env carries no
 * APP_ENV_URL branch, so the fixture needs neither mktemp_owned nor
 * cleanup_release. Those divergences are pinned by the tests below.
 *
 * url_get is NOT one of them — see realCommonShFunction.
 */
const MINIMAL_COMMON_SH = [
  '#!/usr/bin/env bash',
  '',
  'if [ -n "${_DEPLOY_COMMON_LOADED:-}" ]; then return 0; fi',
  '_DEPLOY_COMMON_LOADED=1',
  ': "${ASSUME_YES:=false}"',
  '_DEPLOY_TMP_FILES=()',
  'cleanup_add() { _DEPLOY_TMP_FILES+=("$1"); }',
  'cleanup_run() {',
  '  local f',
  '  if [ "${#_DEPLOY_TMP_FILES[@]}" -gt 0 ]; then',
  '    for f in "${_DEPLOY_TMP_FILES[@]}"; do rm -f "$f"; done',
  '  fi',
  '}',
  'info()  { echo "[${APP_NAME:-app}] $*"; }',
  'warn()  { echo "[${APP_NAME:-app}] WARNING: $*" >&2; }',
  'error() { echo "[${APP_NAME:-app}] ERROR: $*" >&2; exit 1; }',
  'need_cmd() { command -v "$1" >/dev/null 2>&1 || error "required command not found: $1"; }',
  'confirm() {',
  '  if [ "$ASSUME_YES" = true ]; then return 0; fi',
  '  if [ ! -t 0 ]; then warn "non-interactive shell — refusing"; return 1; fi',
  '  local reply',
  '  read -r -p "[${APP_NAME:-app}] $1 [y/N] " reply || return 1',
  '  case "$reply" in [Yy]*) return 0 ;; *) return 1 ;; esac',
  '}',
  'resolve_target_user() {',
  '  if [ -n "${SERVICE_USER:-}" ]; then TARGET_USER="$SERVICE_USER"',
  '  elif [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ]; then TARGET_USER="$SUDO_USER"',
  '  else TARGET_USER="$(id -un)"; fi',
  '  TARGET_HOME=""',
  '  if command -v getent >/dev/null 2>&1; then',
  '    TARGET_HOME="$(getent passwd "$TARGET_USER" 2>/dev/null | cut -d: -f6 || true)",',
  '  fi',
  '  if [ -z "$TARGET_HOME" ]; then',
  '    if [ "$TARGET_USER" = "$(id -un)" ]; then TARGET_HOME="${HOME:-/tmp}";',
  '    else TARGET_HOME="/home/${TARGET_USER}"; fi',
  '  fi',
  '  if [ -z "${RUN_DIR:-}" ]; then RUN_DIR="${TARGET_HOME}/.${APP_NAME}"; fi',
  '}',
  'read_package_version() {',
  '  grep -o \'"version": *"[^"]*"\' "$1" 2>/dev/null | head -1 | sed \'s/"version": *"//;s/"//\' || true',
  '}',
  'parse_json_version() {',
  '  sed -n \'s/.*"version" *: *"\\([^"]*\\)".*/\\1/p\'',
  '}',
  'normalize_v() {',
  '  case "$1" in v*) printf \'%s\' "$1" ;; *) printf \'v%s\' "$1" ;; esac',
  '}',
  // The shipped url_get, not a copy of it (#1120) — spliced in ahead of the
  // hand-written remainder so the real definition is the one that survives.
  ...realCommonShFunction('url_get'),
  'resolve_port() {',
  '  local cfg p',
  '  cfg="${INSTALL_DIR:-}/config.json"',
  '  if [ -f "$cfg" ]; then',
  '    p="$(grep -o \'"port"[[:space:]]*:[[:space:]]*[0-9][0-9]*\' "$cfg" | head -1 | grep -o \'[0-9][0-9]*\' || true)"',
  '    if [ -n "$p" ]; then printf \'%s\' "$p"; return 0; fi',
  '  fi',
  '  printf \'%s\' "${PORT:-3000}"',
  '}',
  'load_app_env() {',
  '  local dir="${SCRIPT_DIR:-}" f tmp',
  '  if [ -n "$dir" ]; then',
  '    for f in "$dir/app.env" "$dir/../app.env"; do',
  '      if [ -f "$f" ]; then . "$f"; APP_ENV_FILE="$f"; return 0; fi',
  '    done',
  '  fi',
  '  error "app.env not found"',
  '}'
].join('\n')

// ── Test globals ─────────────────────────────────────────────────────────────

/**
 * The fixture's helper, as bash parsed it. Sourcing the written-out fixture and
 * asking bash is what makes this comparable to the shipped file: hand-formatting
 * differences (line breaks, spacing) vanish, so only a real behavioural
 * divergence is left to fail on.
 */
function declaredFn(fixtureSource: string, name: string): string {
  const dir = mkTempTree('synapto-drift-')
  try {
    const file = join(dir, 'common.sh')
    writeFileSync(file, fixtureSource)
    const res = spawnSync('bash', ['-c', `. "${file}" >/dev/null 2>&1; declare -f ${name}`], {
      encoding: 'utf8',
      timeout: 10_000
    })
    return (res.stdout ?? '').trim()
  } finally {
    rmSync(dir, { recursive: true, force: true })
  }
}

describe('MINIMAL_COMMON_SH — the fixture ships helpers, not copies of them', () => {
  // #1120, the whole defect in two assertions: the fixture's url_get is the
  // shipped one, byte for byte after bash has parsed both, and it is defined
  // exactly once so a hand-written copy cannot shadow the extracted one and drift
  // again in silence.
  test('url_get is the real common.sh definition, defined once', () => {
    // Both spellings count as a definition: the extracted one (`url_get () `,
    // bash's own `declare -f` rendering) and the hand-written one (`url_get() {`).
    const defs = MINIMAL_COMMON_SH.match(/^url_get\s*\(\s*\)/gm) ?? []
    expect(
      defs,
      'url_get must be defined exactly once — a second copy is what drifted'
    ).toHaveLength(1)
    expect(declaredFn(MINIMAL_COMMON_SH, 'url_get')).toBe(
      declaredFn(readFileSync(resolve(import.meta.dir, 'lib', 'common.sh'), 'utf8'), 'url_get')
    )
  })

  // The divergence that is DELIBERATE, pinned so that "sync everything" cannot
  // quietly void it: updater.sh keeps STAGE_ROOT's own `rm -rf` precisely because
  // the contract it is tested against promises a FILES-only cleanup (see the
  // STAGE_ROOT comment in updater.sh). Handing the fixture the shipped
  // rm -rf cleanup_run would make a frozen script's behaviour depend on an
  // `rm -rf` the fixture never promised.
  test('cleanup_run stays FILES-only — the contract updater.sh stage cleanup is tested against', () => {
    const run = declaredFn(MINIMAL_COMMON_SH, 'cleanup_run')
    expect(run).toContain('rm -f ')
    expect(run, 'a directory-capable cleanup_run changes what updater.sh is proving').not.toContain(
      'rm -rf'
    )
  })

  // The extractor itself, so a silent regression to an empty definition fails
  // HERE rather than in a tag-resolution test that mentions neither common.sh.
  test('the extractor throws on a function common.sh does not define', () => {
    expect(() => realCommonShFunction('definitely_not_a_common_sh_function')).toThrow(
      /cannot extract/
    )
  })
})

let FIXTURE_DIR = ''
let INSTALL_DIR = ''
let BOOTSTRAP_DIR = '' // <tmp>/bootstrap/scripts/ — mirrors installed layout

beforeEach(() => {
  FIXTURE_DIR = mkTempTree('synapto-updater-fix-')
  INSTALL_DIR = mkTempTree('synapto-install-')
  BOOTSTRAP_DIR = join(FIXTURE_DIR, 'bootstrap', 'scripts')
  mkdirSync(join(BOOTSTRAP_DIR, 'lib'), { recursive: true })

  // Copy the real updater.sh and a minimal common.sh into the bootstrap dir
  // so SCRIPT_DIR resolves here and load_app_env finds our test app.env.
  copyFileSync(resolve(import.meta.dir, 'updater.sh'), join(BOOTSTRAP_DIR, 'updater.sh'))
  chmodSync(join(BOOTSTRAP_DIR, 'updater.sh'), 0o755)
  writeFileSync(join(BOOTSTRAP_DIR, 'lib', 'common.sh'), MINIMAL_COMMON_SH)
})

const MINIMAL_APP_ENV = [
  'APP_NAME="synaptomind"',
  'DIST="source"',
  'INSTALL_DIR="/opt/synaptomind"',
  'PORT="3005"'
].join('\n')

// FIXTURE_DIR, INSTALL_DIR and every seed*() tree are created through
// mkTempTree, so the sweep in tmp-fixtures.ts removes them. The explicit
// rmSync pair that used to live here is gone: it was the per-site cleanup the
// next test would have had to remember, and the 28-dir leak was what happened
// when it was forgotten.

/**
 * Write a test-specific app.env into the bootstrap dir and return the
 * absolute path to the installer root (for RUN_DIR overrides).
 * `repoUrl` defaults to an empty file:// so the bootstrap fails early
 * with a useful message rather than silently falling back to the real repo.
 */
function setupBootstrap(opts: { repoUrl?: string; overrides?: Record<string, string> } = {}): void {
  const repoUrl = opts.repoUrl ?? 'file:///tmp/synapto-empty-no-tags'
  // Point RUN_DIR at a scratch dir so the bootstrap never touches the real install.
  const testRunDir = mkTempTree('synapto-run-')
  // Bootstrap's stage_release copies ${RUN_DIR}/scripts/app.env into the staging area;
  // the source must exist or cp aborts the run.
  mkdirSync(join(testRunDir, 'scripts'), { recursive: true })
  writeFileSync(join(testRunDir, 'scripts', 'app.env'), MINIMAL_APP_ENV)
  const appEnvLines = [
    'APP_NAME="synaptomind"',
    'DIST="source"',
    'REPO_URL="' + repoUrl + '"',
    'INSTALL_DIR="' + INSTALL_DIR + '"',
    'RUN_DIR="' + testRunDir + '"',
    'PORT="3005"',
    'SERVICE_USER=""',
    'HOOKS_DIR=""',
    ...Object.entries(opts.overrides ?? {}).map(([k, v]) => `${k}="${v}"`)
  ]
  writeFileSync(join(BOOTSTRAP_DIR, 'app.env'), appEnvLines.join('\n') + '\n')
}

/**
 * Run updater.sh from the test bootstrap dir with the given args.
 * Returns spawnSync result.
 */
function runUpdater(
  args: string[] = [],
  input?: string,
  envOverrides?: Record<string, string>
): ReturnType<typeof spawnSync> {
  // isolatedEnv, not a bare `...process.env` (task #1104): updater.sh:118 reads
  // ${RUN_DIR}/scripts/app.env and updater.sh:158 exports RUN_DIR, so an
  // inherited RUN_DIR is the only thing keeping this spawn off the operator's
  // real ~/.synaptomind. Here the fixture's app.env also pins RUN_DIR, so both
  // the environment and the sourced config agree — a defence that costs nothing
  // and holds even if setupBootstrap's app.env is edited later.
  const res = spawnSync('bash', [join(BOOTSTRAP_DIR, 'updater.sh'), ...args], {
    encoding: 'utf8',
    env: isolatedEnv(FIXTURE_DIR, {
      // Ensure we use our test bootstrap dir's scripts
      PATH: join(BOOTSTRAP_DIR, 'lib') + ':' + process.env.PATH!,
      ...envOverrides
    }),
    input,
    timeout: 15_000,
    cwd: FIXTURE_DIR
  })
  return res
}

const hasScript = spawnSync('script', ['--version'], { encoding: 'utf8' }).status === 0

/**
 * Run updater.sh under a pseudo-tty (`script`) so the interactive menu and
 * confirm() prompt are reachable. `input` is fed to the pty; `-e` propagates
 * the child's exit status.
 */
function runUpdaterPty(
  input: string,
  envOverrides?: Record<string, string>
): ReturnType<typeof spawnSync> {
  const cmd = `bash ${join(BOOTSTRAP_DIR, 'updater.sh')}`
  return spawnSync('script', ['-qec', cmd, '/dev/null'], {
    encoding: 'utf8',
    input,
    // isolatedEnv for the same reason as runUpdater: updater.sh resolves RUN_DIR
    // from the environment, so an inherited one reaches the operator's real
    // ~/.synaptomind (task #1104). The pty wrapper changes the TTY, not the
    // isolation, so this path needs the same pin.
    env: isolatedEnv(FIXTURE_DIR, {
      PATH: join(BOOTSTRAP_DIR, 'lib') + ':' + process.env.PATH!,
      ...envOverrides
    }),
    timeout: 15_000,
    cwd: FIXTURE_DIR
  })
}

// ── Tag resolution tests ─────────────────────────────────────────────────────

describe('updater.sh — tag resolution', () => {
  test('selects newest stable tag when stable tags exist', () => {
    const artifact = join(FIXTURE_DIR, 'chosen-tag.txt')
    const repo = seedTwoStableTagsRepo('v0.7.3', 'v0.8.0', {
      'deploy/update.sh': makeTrackingUpdateSh(),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    const res = runUpdater(['--yes'], undefined, { UPDATER_ARTIFACT: artifact })
    expect(res.status, res.stderr + '\n' + res.stdout).toBe(0)
    // Newest stable tag must be selected — v0.8.0, not v0.7.3
    const chosen = readFileSync(artifact, 'utf8').trim()
    expect(chosen).toContain('v0.8.0')
    expect(chosen).not.toContain('v0.7.3')
  })

  test('excludes prerelease tags from stable selection', () => {
    const artifact = join(FIXTURE_DIR, 'chosen-tag.txt')
    // Repo where a prerelease is newer than the stable tag
    const dir = mkTempTree('synapto-newer-prerelease-')
    spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' })
    spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, encoding: 'utf8' })
    // v0.7.3 (stable) first
    mkdirSync(join(dir, 'deploy', 'lib'), { recursive: true })
    writeFileSync(join(dir, 'deploy', 'update.sh'), makeTrackingUpdateSh())
    writeFileSync(join(dir, 'deploy', 'lib', 'common.sh'), MINIMAL_COMMON_SH)
    writeFileSync(join(dir, 'package.json'), '{"name":"synaptomind","version":"0.7.3"}')
    spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['commit', '-q', '-m', 'stable'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['tag', 'v0.7.3'], { cwd: dir, encoding: 'utf8' })
    // v0.8.0-beta.2 (prerelease) on a branch — newer by sort -V
    spawnSync('git', ['checkout', '-q', '-b', 'beta'], { cwd: dir, encoding: 'utf8' })
    writeFileSync(join(dir, 'package.json'), '{"name":"synaptomind","version":"0.8.0-beta.2"}')
    spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['commit', '-q', '-m', 'beta'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['tag', 'v0.8.0-beta.2'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['checkout', '-q', 'master'], { cwd: dir, encoding: 'utf8' })

    setupBootstrap({ repoUrl: 'file://' + dir })
    const res = runUpdater(['--yes'], undefined, { UPDATER_ARTIFACT: artifact })
    expect(res.status, res.stderr + '\n' + res.stdout).toBe(0)
    // Must select the stable tag, never the prerelease
    const chosen = readFileSync(artifact, 'utf8').trim()
    expect(chosen).toContain('v0.7.3')
    expect(chosen).not.toContain('v0.8.0-beta')
  })

  test('exits 1 when no stable tags exist in the repo', () => {
    const repo = seedNoStableTagsRepo()
    setupBootstrap({ repoUrl: 'file://' + repo })
    const res = runUpdater(['--yes'], undefined)
    expect(res.status).toBe(1)
    expect(res.stderr).toContain('no stable release tags')
  })
})

// ── Staging tests ────────────────────────────────────────────────────────────

describe('updater.sh — staging', () => {
  test('staged files land under mktemp -d and INSTALL_DIR is unchanged', () => {
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeMinimalUpdateSh(0),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    // Record INSTALL_DIR state before
    const before = spawnSync('find', [INSTALL_DIR, '-type', 'f'], {
      encoding: 'utf8'
    }).stdout
    const res = runUpdater(['--yes'], undefined)
    // INSTALL_DIR should be unchanged (bootstrap never writes there)
    const after = spawnSync('find', [INSTALL_DIR, '-type', 'f'], {
      encoding: 'utf8'
    }).stdout
    expect(after).toBe(before)
    // Stage root should have been cleaned up by the EXIT trap
    expect([0, 1].includes(res.status!)).toBe(true)
  })

  test('contract probe passes with a valid staged update.sh', () => {
    const artifact = join(FIXTURE_DIR, 'probe-args.txt')
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeTrackingUpdateSh(),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    const res = runUpdater(['--yes'], undefined, { UPDATER_ARTIFACT: artifact })
    // Contract probe must have actually invoked the staged update.sh.
    // The probe calls `bash ... --version 0.0.0 --yes --help`; --help exits 0
    // after writing the args, so the .invoked sentinel proves the probe ran.
    expect(res.status, res.stderr + '\n' + res.stdout).toBe(0)
    expect(
      existsSync(artifact + '.invoked'),
      'contract probe did not invoke the staged update.sh'
    ).toBe(true)
  })
})

// ── Guard tests ──────────────────────────────────────────────────────────────

describe('updater.sh — guards', () => {
  test('a target tag without deploy/ aborts with the pre-v0.8.0 message', () => {
    const repo = seedNoDeployRepo()
    setupBootstrap({ repoUrl: 'file://' + repo })
    const res = runUpdater(['--yes'], undefined)
    expect(res.status).toBe(1)
    expect(res.stderr).toContain('no deploy/update.sh')
  })

  test('a tampered update.sh (missing --version flag) fails the contract probe', () => {
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeTamperedUpdateSh(),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    const res = runUpdater(['--yes'], undefined)
    expect(res.status).toBe(1)
    expect(res.stderr).toContain('frozen contract violated')
  })
})

// ── Non-interactive tests ────────────────────────────────────────────────────

describe('updater.sh — non-interactive mode', () => {
  test('--yes picks newest stable and forwards --yes to update.sh', () => {
    const artifact = join(FIXTURE_DIR, 'chosen-args.txt')
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeTrackingUpdateSh(),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    const res = runUpdater(['--yes'], undefined, { UPDATER_ARTIFACT: artifact })
    expect(res.status, res.stderr + '\n' + res.stdout).toBe(0)
    const args = readFileSync(artifact, 'utf8').trim().split('\n')
    // Newest stable tag must be passed via --version
    expect(args).toContain('--version')
    const versionIdx = args.indexOf('--version')
    expect(args[versionIdx + 1]).toBe('v0.8.0')
    // --yes must be forwarded
    expect(args).toContain('--yes')
  })

  test('non-TTY without --yes refuses to proceed', () => {
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeMinimalUpdateSh(0),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    // No --yes, no TTY (input is undefined → non-interactive)
    const res = runUpdater([], undefined)
    expect(res.status).toBe(1)
    expect(res.stderr).toContain('non-interactive shell')
  })

  test('--version with a prerelease tag is rejected', () => {
    // Repo has both a stable tag and a prerelease tag; requesting the prerelease
    // should fail with "not a known stable release", not "no stable tags".
    const dir = mkTempTree('synapto-prerelease-')
    spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' })
    spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir, encoding: 'utf8' })
    // v0.7.3 (stable)
    writeFileSync(join(dir, 'package.json'), '{"name":"synaptomind","version":"0.7.3"}')
    spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['commit', '-q', '-m', 'stable'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['tag', 'v0.7.3'], { cwd: dir, encoding: 'utf8' })
    // v0.8.0-beta.1 (prerelease) on a branch
    spawnSync('git', ['checkout', '-q', '-b', 'beta'], { cwd: dir, encoding: 'utf8' })
    mkdirSync(join(dir, 'deploy', 'lib'), { recursive: true })
    writeFileSync(join(dir, 'deploy', 'update.sh'), makeMinimalUpdateSh(0))
    writeFileSync(join(dir, 'deploy', 'lib', 'common.sh'), MINIMAL_COMMON_SH)
    writeFileSync(join(dir, 'package.json'), '{"name":"synaptomind","version":"0.8.0-beta.1"}')
    spawnSync('git', ['add', '.'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['commit', '-q', '-m', 'beta'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['tag', 'v0.8.0-beta.1'], { cwd: dir, encoding: 'utf8' })
    spawnSync('git', ['checkout', '-q', 'master'], { cwd: dir, encoding: 'utf8' })

    setupBootstrap({ repoUrl: 'file://' + dir })
    const res = runUpdater(['--version', 'v0.8.0-beta.1'], undefined)
    expect(res.status).toBe(1)
    expect(res.stderr).toContain('not a known stable release')
  })
})

// ── Exit-code propagation tests ──────────────────────────────────────────────

describe('updater.sh — exit-code propagation', () => {
  test('propagates the chosen update.sh exit code verbatim', () => {
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeMinimalUpdateSh(42),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    const res = runUpdater(['--yes'], undefined)
    expect(res.status).toBe(42)
  })
})

// ── Exit-code contract tests ─────────────────────────────────────────────────
// Guards the regression where the EXIT trap's bare `[ -n "$STAGE_ROOT" ]` failed
// with an empty stage root, overriding a pending `exit 0` and forcing exit 1.

describe('updater.sh — exit-code contract', () => {
  test('--help exits 0 and prints usage', () => {
    const res = runUpdater(['--help'], undefined)
    expect(res.status, res.stderr + '\n' + res.stdout).toBe(0)
    expect(res.stdout).toContain('Usage:')
    expect(res.stdout).toContain('--version')
  })

  test('unknown option still exits 1', () => {
    const res = runUpdater(['--bogus'], undefined)
    expect(res.status).toBe(1)
    expect(res.stderr).toContain('unknown option')
  })

  test.skipIf(!hasScript)('declining the interactive menu aborts with exit 0', () => {
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeTrackingUpdateSh(),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo })
    // pty answers the version menu with 1, then declines the confirm prompt.
    // STAGE_ROOT is still empty here, so this is the empty-stage success path.
    const res = runUpdaterPty('1\nn\n')
    expect(res.status, res.stderr + '\n' + res.stdout).toBe(0)
  })
})

// ════════════════════════════════════════════════════════════════════════════
//  DIST=binary — release tarball update (ADR 0001 §2.9) and the unit re-render
//  the conformance audit found missing (audit finding 1: render_systemd_unit
//  had exactly ONE call site, install.sh, so an install updating INTO binary
//  mode never received Environment=LD_LIBRARY_PATH).
//
//  No real sudo and no real systemctl: both are PATH stubs whose sudo variant is
//  a GUARDED passthrough — it refuses to execute unless every path-like argument
//  lives under the fixture root, so the suite cannot touch the live service even
//  by accident (AGENTS.md §8).
// ════════════════════════════════════════════════════════════════════════════

const U_APP = 'synaptomind'

// ══════════════════════════════════════════════════════════════════════════════
//  F3 — release_resolve_tag must read the release list the API actually returns.
//
//  RELEASE_API is the LIST endpoint, and a JSON array of releases is served as
//  ONE minified line: `[{"tag_name":…},{"tag_name":…},…]`. The extraction was a
//  line-based `sed`, so it saw a single entry in that line — the LAST one — and
//  treated it as the only release that exists. With the newest prerelease last
//  (the shape GitHub's newest-first ordering produces whenever a beta follows a
//  stable), `stable` hard-failed with "no stable release tag" and stable, latest
//  and prerelease ALL resolved to that one prerelease.
//
//  No test used a multi-entry list: the fixture's api.json holds exactly one,
//  which a line-based parse handles by accident.
// ══════════════════════════════════════════════════════════════════════════════

/** The LIST endpoint shape, minified the way the API serves it: one line. */
const RELEASE_LIST_MINIFIED = JSON.stringify([
  { tag_name: 'v0.9.0', draft: false },
  { tag_name: 'v0.8.2', draft: false },
  { tag_name: 'v0.9.0-beta.1', prerelease: true }
])

/** The same list pretty-printed, which is what a hand-written fixture used to be. */
const RELEASE_LIST_PRETTY = JSON.stringify(
  [
    { tag_name: 'v0.9.0', draft: false },
    { tag_name: 'v0.8.2', draft: false },
    { tag_name: 'v0.9.0-beta.1', prerelease: true }
  ],
  null,
  2
)

/** The single-object shape (/releases/latest), which must keep working. */
const RELEASE_OBJECT_STABLE = JSON.stringify({
  tag_name: 'v0.9.0',
  draft: false,
  prerelease: false
})
const RELEASE_OBJECT_PRERELEASE = JSON.stringify({
  tag_name: 'v0.9.0-beta.1',
  draft: false,
  prerelease: true
})

/**
 * Run the real release_resolve_tag against a payload, with url_get replaced so
 * the LIST is the fixture's bytes. Prints the resolved tag, or the error.
 */
function resolveTag(body: string, policy: string): { status: number | null; out: string } {
  const dir = mkTempTree('synapto-tag-')
  try {
    const api = join(dir, 'api.json')
    writeFileSync(api, body)
    const res = spawnSync(
      'bash',
      [
        '-c',
        [
          `APP_NAME=${U_APP}`,
          `CHECKOUT_POLICY=${policy}`,
          `RELEASE_API=file://${api}`,
          `. "${resolve(import.meta.dir, 'lib', 'common.sh')}"`,
          'url_get() { cat "${1#file://}"; }',
          'release_resolve_tag',
          'printf "%s" "$RESOLVED_TAG"'
        ].join('\n')
      ],
      { encoding: 'utf8', timeout: 30_000 }
    )
    return { status: res.status, out: `${res.stdout}${res.stderr}` }
  } finally {
    rmSync(dir, { recursive: true, force: true })
  }
}

describe('release_resolve_tag — both payload shapes, every channel', () => {
  for (const [shape, body] of [
    ['a minified list', RELEASE_LIST_MINIFIED],
    ['a pretty-printed list', RELEASE_LIST_PRETTY]
  ] as const) {
    test(`stable resolves v0.9.0 from ${shape}`, () => {
      const r = resolveTag(body, 'stable')
      expect(r.status, r.out).toBe(0)
      expect(r.out).toContain('v0.9.0')
    })

    test(`prerelease resolves v0.9.0-beta.1 from ${shape}`, () => {
      // The direction that used to hard-fail outright: a minified list whose last
      // entry is the prerelease hid every stable release from `stable` and made
      // `prerelease` succeed by accident, on the wrong evidence.
      const r = resolveTag(body, 'prerelease')
      expect(r.status, r.out).toBe(0)
      expect(r.out).toContain('v0.9.0-beta.1')
    })

    test(`stable does not fall through to the prerelease in ${shape}`, () => {
      // The silent half of the same defect, asserted on its own: a policy must
      // never resolve a tag of the WRONG channel, in either direction.
      const r = resolveTag(body, 'stable')
      expect(r.out).not.toContain('beta')
    })
  }

  // The other shape the endpoint can answer with: a single release object. It has
  // to keep working, and a channel it cannot satisfy must fail by name rather than
  // hand back a tag of the wrong kind.
  test('stable resolves the tag of a single release object', () => {
    const r = resolveTag(RELEASE_OBJECT_STABLE, 'stable')
    expect(r.status, r.out).toBe(0)
    expect(r.out).toContain('v0.9.0')
  })

  test('prerelease resolves the tag of a single prerelease object', () => {
    const r = resolveTag(RELEASE_OBJECT_PRERELEASE, 'prerelease')
    expect(r.status, r.out).toBe(0)
    expect(r.out).toContain('v0.9.0-beta.1')
  })

  test('stable on a prerelease-only object fails by name, and names no tag', () => {
    const r = resolveTag(RELEASE_OBJECT_PRERELEASE, 'stable')
    expect(r.status).toBe(1)
    expect(r.out).toContain('no stable release tag')
    expect(r.out).not.toMatch(/RESOLVED_TAG=v/)
  })

  test('latest takes the HIGHEST tag, not the last line and not the first entry', () => {
    // Both wrong answers are pinned: a line-based parse took the last entry of a
    // minified list, and trusting the endpoint's newest-first order would take the
    // first. The channel is "newest", so it has to be a version comparison.
    const r = resolveTag(RELEASE_LIST_MINIFIED, 'latest')
    expect(r.status, r.out).toBe(0)
    expect(r.out).toContain('v0.9.0')
    expect(r.out).not.toContain('beta.1')
  })

  test('a list with the OLDEST entry last is not mistaken for the newest', () => {
    // The silent direction: newest-first, no prerelease, and the last entry is
    // v0.8.2. A line-based parse resolved `latest` to v0.8.2, so an installed
    // 0.8.2 host reported "already up to date" instead of upgrading.
    const r = resolveTag(JSON.stringify([{ tag_name: 'v0.9.0' }, { tag_name: 'v0.8.2' }]), 'latest')
    expect(r.status, r.out).toBe(0)
    expect(r.out).toContain('v0.9.0')
  })

  test('a payload with no release at all still fails by name', () => {
    const r = resolveTag(JSON.stringify([{ message: 'Not Found' }]), 'stable')
    expect(r.status).toBe(1)
    expect(r.out).toContain('no stable release tag')
  })

  test('a branch policy is still refused (a branch has no release asset)', () => {
    const r = resolveTag(RELEASE_LIST_MINIFIED, 'main')
    expect(r.status).toBe(1)
    expect(r.out).toContain('requires DIST=source')
  })
})

/**
 * Guarded sudo: logs argv, then executes only if every path is under ROOT.
 * `refuse` names commands the stub pretends to be denied for (a sudo that needs
 * a password it cannot get), so a test can fail ONE privileged call — the unit
 * copy — while every other call still runs.
 */
function makeGuardedSudo(
  guard: string,
  opts: { refuse?: string[]; refuseUnder?: string } = {}
): string {
  const lines = [
    '#!/usr/bin/env bash',
    'printf \'sudo\' >> "$STUB_PRIV_LOG"',
    'for a in "$@"; do printf \' %s\' "$a" >> "$STUB_PRIV_LOG"; done',
    'printf \'\\n\' >> "$STUB_PRIV_LOG"'
  ]
  for (const cmd of opts.refuse ?? []) {
    lines.push(
      `case " $* " in *" ${cmd} "*) echo "sudo: ${cmd}: a terminal is required to ask for a password" >&2; exit 1 ;; esac`
    )
  }
  // Refuse every privileged write AIMED AT one directory — a read-only or full
  // unit directory, the way an operator meets it. Narrower than refusing a
  // command by name: the payload's .prev copy is a privileged write too now
  // (write_file_atomically, task #1079 F7), and refusing ALL of them aborts the
  // run before the unit is ever reached, which would test something else.
  if (opts.refuseUnder) {
    lines.push(
      `for a in "$@"; do case "$a" in ${opts.refuseUnder}/*) echo "sudo: $a: a terminal is required to ask for a password" >&2; exit 1 ;; esac; done`
    )
  }
  lines.push(
    // Everything must be inside the fixture root, or refuse loudly.
    'for a in "$@"; do',
    '  case "$a" in /*) case "$a" in ' +
      guard +
      ') ;; *) echo "REFUSED: $a" >&2; exit 99 ;; esac ;; esac',
    'done',
    'exec "$@"'
  )
  return lines.join('\n')
}

const SYSTEMCTL_STUB = [
  '#!/usr/bin/env bash',
  'printf \'systemctl\' >> "$STUB_PRIV_LOG"',
  'for a in "$@"; do printf \' %s\' "$a" >> "$STUB_PRIV_LOG"; done',
  'printf \'\\n\' >> "$STUB_PRIV_LOG"',
  'case "$1" in',
  '  is-system-running) echo running ;;',
  '  is-active) exit 0 ;;',
  'esac',
  'exit 0'
].join('\n')

/** The same stub on a host where systemd is not PID 1 (is-system-running -> offline). */
const SYSTEMCTL_STUB_OFFLINE = SYSTEMCTL_STUB.replace('echo running', 'echo offline')

/** A stub that reloads nothing: daemon-reload fails, everything else answers. */
const SYSTEMCTL_STUB_RELOAD_FAILS = SYSTEMCTL_STUB.replace(
  '  is-active) exit 0 ;;',
  '  is-active) exit 0 ;;\n  daemon-reload) echo "Failed to reload daemon" >&2; exit 1 ;;'
)

/**
 * A scratch install tree that already holds an OLD binary-mode payload, plus
 * the deploy/ scripts and a RELEASE_API file listing the target tag.
 */
function seedBinaryUpdate(opts: {
  currentVersion: string
  targetVersion: string
  includeDb?: boolean
  /** Override the release-metadata payload; the default is a one-entry list. */
  releasesBody?: string
  /**
   * Replace one installed hook with a stub that exits non-zero, to drive the
   * caller-side decision a hook failure has to reach (task #1102).
   *
   * The stub is written over ${RUN_DIR}/hooks/<name> — the path update.sh
   * resolves when app.env leaves HOOKS_DIR empty — so it is the hook the real
   * run executes, not a lookalike. The pre-update stub also prints the one line
   * collect_db_backups parses, so a test can see whether the tee kept both
   * streaming (its stdout) and capturing (that line reaching the recovery block).
   */
  failingHook?: { hook: 'pre-update' | 'post-update'; code: number }
}): ReturnType<typeof seedBinaryUpdateShape> {
  return seedBinaryUpdateShape(opts)
}

function seedBinaryUpdateShape(opts: {
  currentVersion: string
  targetVersion: string
  includeDb?: boolean
  releasesBody?: string
  failingHook?: { hook: 'pre-update' | 'post-update'; code: number }
}) {
  const root = mkTempTree('synapto-binu-')
  const deployDir = join(root, 'deploy')
  const stubsDir = join(root, 'stubs')
  const installDir = join(root, 'opt', U_APP)
  const runDir = join(root, 'run')
  const privLog = join(root, 'privileged.log')
  const unitFile = join(root, 'unit', `${U_APP}.service`)
  // Copy only the scripts update.sh actually needs; the flat `common.sh` only
  // exists inside an installed ${RUN_DIR}/scripts, never in the repo.
  mkdirSync(deployDir, { recursive: true })
  for (const f of ['update.sh', 'updater.sh', 'uninstall.sh']) {
    cpSync(join(import.meta.dir, f), join(deployDir, f))
  }
  mkdirSync(join(deployDir, 'lib'), { recursive: true })
  cpSync(join(import.meta.dir, 'lib', 'common.sh'), join(deployDir, 'lib', 'common.sh'))
  mkdirSync(join(deployDir, 'hooks'), { recursive: true })
  for (const f of ['pre-update', 'post-update']) {
    cpSync(join(import.meta.dir, 'hooks', f), join(deployDir, 'hooks', f))
  }
  mkdirSync(stubsDir, { recursive: true })
  mkdirSync(join(root, 'unit'), { recursive: true })
  mkdirSync(join(runDir, 'scripts'), { recursive: true })
  mkdirSync(join(runDir, 'hooks'), { recursive: true })
  mkdirSync(installDir, { recursive: true })
  mkdirSync(join(installDir, 'lib'), { recursive: true })
  writeFileSync(privLog, '')

  // An old, already-installed payload (as a previous release left it).
  writeFileSync(join(installDir, U_APP), `#!/bin/sh\necho "${U_APP} ${opts.currentVersion}"\n`)
  chmodSync(join(installDir, U_APP), 0o755)
  writeFileSync(join(installDir, 'vec0.so'), 'old vec0\n')
  writeFileSync(join(installDir, 'lib', 'libonnxruntime.so.1'), 'old onnxruntime\n')

  // The NEW payload as a release directory.
  const releases = join(root, 'releases')
  const tag = `v${opts.targetVersion}`
  const build = join(root, 'build', `${U_APP}-${opts.targetVersion}-linux-x86_64`)
  const payload: Record<string, string> = {
    [U_APP]: `#!/bin/sh\necho "${U_APP} v${opts.targetVersion}"\n`,
    'vec0.so': 'new vec0\n',
    'lib/libonnxruntime.so.1': 'new onnxruntime\n',
    'config.json.example':
      '{ "server": { "port": 3999 }, "database": { "path": "./data/synaptomind.db" } }\n',
    '.env.example': 'SYNAPTOMIND_SECRET=\n'
  }
  for (const [rel, content] of Object.entries(payload)) {
    const full = join(build, rel)
    mkdirSync(resolve(full, '..'), { recursive: true })
    writeFileSync(full, content)
  }
  chmodSync(join(build, U_APP), 0o755)
  mkdirSync(join(releases, tag), { recursive: true })
  const tarRes = spawnSync(
    'tar',
    [
      '-czf',
      join(releases, tag, `${U_APP}-${tag}-linux-x86_64.tar.gz`),
      '-C',
      join(root, 'build'),
      `${U_APP}-${opts.targetVersion}-linux-x86_64`
    ],
    { encoding: 'utf8' }
  )
  expect(tarRes.status, tarRes.stderr).toBe(0)

  // A file:// "RELEASE_API" — release_resolve_tag reads it with url_get, which
  // handles file:// through curl, so no HTTP server is needed.
  // A one-entry list by default, which is what every test before #1079 used: a
  // line-based read of a minified list happens to be right for exactly one entry.
  writeFileSync(
    join(releases, 'api.json'),
    opts.releasesBody ?? JSON.stringify([{ tag_name: tag, draft: false }])
  )

  if (opts.includeDb) {
    mkdirSync(join(installDir, 'data'), { recursive: true })
    spawnSync(
      'sqlite3',
      [
        join(installDir, 'data', 'synaptomind.db'),
        'create table t(x int); insert into t values (1);'
      ],
      {
        encoding: 'utf8'
      }
    )
  }

  // Helpers, hooks and the installed app.env (the layout update.sh expects).
  for (const f of ['update.sh', 'updater.sh', 'uninstall.sh']) {
    cpSync(join(import.meta.dir, f), join(runDir, 'scripts', f))
  }
  for (const f of ['pre-update', 'post-update']) {
    cpSync(join(import.meta.dir, 'hooks', f), join(runDir, 'hooks', f))
    chmodSync(join(runDir, 'hooks', f), 0o755)
  }
  if (opts.failingHook) {
    const { hook, code } = opts.failingHook
    const stub = [
      '#!/usr/bin/env bash',
      // The line collect_db_backups parses, so a test can tell whether the tee
      // still CAPTURED the transcript as well as streaming it.
      `echo "Database backed up: ${join(installDir, 'data', 'synaptomind.db.backup', 'simulated.bak')}"`,
      `echo "simulated ${hook} failure" >&2`,
      `exit ${code}`
    ].join('\n')
    writeFileSync(join(runDir, 'hooks', hook), stub)
    chmodSync(join(runDir, 'hooks', hook), 0o755)
  }
  cpSync(join(import.meta.dir, 'lib', 'common.sh'), join(runDir, 'scripts', 'common.sh'))

  // UNIT_FILE redirects refresh_unit away from the real /etc unit.
  const env = {
    APP_NAME: U_APP,
    APP_DESC: 'Synaptomind — thought-graph engine',
    DIST: 'binary',
    INSTALL_DIR: installDir,
    DATA_DIR: join(root, 'data'),
    RUN_DIR: runDir,
    PORT: '3999',
    RELEASES_BASE: `file://${releases}`,
    RELEASE_API: `file://${join(releases, 'api.json')}`,
    // SINGLE-QUOTED, like the shipped app.env: the file is SOURCED, so a
    // double-quoted ${TAG} would be expanded at load time to an empty string.
    ASSET_PATTERN: "'${APP_NAME}-${TAG}-${OS}-${ARCH}.tar.gz'",
    APP_VERSION_CMD: "'${BIN} --version'",
    CHECKOUT_POLICY: 'stable',
    REQUIRES_BUN: 'no',
    SYSTEM_DEP_CMDS: '',
    SERVICE_USER: '',
    SEED_FILES: 'config.json.example:config.json .env.example:.env',
    GENERATE_SECRET_IN: '.env',
    HOOKS_DIR: '',
    UNIT_FILE: unitFile,
    // No service runs in these tests, so point the post-restart poll at a
    // closed port. The poll is stubbed (see FAKE_HEALTH_STUB) to answer on the
    // first attempt; without it wait_health sleeps for the full HEALTH_TIMEOUT.
    HEALTH_URL: 'http://127.0.0.1:1/health',
    HEALTH_TIMEOUT: '1',
    // The shipped default is 30s; 2s here so a test that reaches the re-check
    // window does not spend 30 seconds proving that nothing answered.
    HEALTH_CONFIRM_TIMEOUT: '2'
  }
  writeFileSync(
    join(runDir, 'scripts', 'app.env'),
    Object.entries(env)
      .map(([k, v]) =>
        k === 'ASSET_PATTERN' || k === 'APP_VERSION_CMD' ? `${k}=${v}` : `${k}="${v}"`
      )
      .join('\n') + '\n'
  )

  // The sudo guard allows: everything under the fixture root, plus the $TMPDIR
  // paths that render_systemd_unit's `mktemp -d` produces before the copy.
  const guard = [`"${root}"/*|"${root}"/*`, `"${tmpdir()}"/*|"${tmpdir()}"/*`].join('|')
  writeFileSync(join(stubsDir, 'sudo'), makeGuardedSudo(guard))
  writeFileSync(join(stubsDir, 'systemctl'), SYSTEMCTL_STUB)
  for (const s of ['sudo', 'systemctl']) chmodSync(join(stubsDir, s), 0o755)

  // The unit is rendered into $TMPDIR by render_systemd_unit before it is
  // copied to UNIT_FILE, so the sudo guard must also allow mktemp -d paths.
  // curl stub: the /health poll only. Everything else (the file:// release
  // downloads) must reach the REAL curl, so the stub passes non-health URLs
  // straight through.
  const realCurl = spawnSync('bash', ['-c', 'command -v curl'], { encoding: 'utf8' }).stdout.trim()
  writeFileSync(
    join(stubsDir, 'curl'),
    [
      '#!/usr/bin/env bash',
      'for a in "$@"; do',
      '  case "$a" in',
      `    */health) printf '%s' "$FAKE_HEALTH_BODY"; exit 0 ;;`,
      '  esac',
      'done',
      `exec ${realCurl} "$@"`
    ].join('\n')
  )
  chmodSync(join(stubsDir, 'curl'), 0o755)

  const healthBody = JSON.stringify({
    status: 'ok',
    version: opts.targetVersion,
    checks: { database: 'ok', embedder: 'ok' }
  })
  return { root, deployDir, stubsDir, installDir, runDir, privLog, unitFile, healthBody, guard }
}

/** The four fields every update.sh fixture needs to be run. */
type UpdateFixture = { runDir: string; stubsDir: string; privLog: string; healthBody: string }

/**
 * One /health sample. A bare string is a complete answer (curl exits 0).
 *
 * `{ body, rc }` is a sample that did NOT arrive whole: a server that closes
 * mid-body leaves real curl rc=18 and whatever it had already written, so the
 * partial body and the failed transfer are ONE event. Measured against a real
 * loopback socket: rc=18, 25 bytes of a promised 4096.
 */
type HealthSample = string | { body: string; rc: number }

/**
 * Replace the fixture's /health curl stub with one that answers a SEQUENCE of
 * payloads, one per poll, repeating the last one once exhausted. FAKE_HEALTH_BODY
 * can only express a single static body, so a recovery case ("failed now,
 * healthy on the next poll") needs a counter — kept in a file, because wait_health
 * calls url_get inside a command substitution and a shell variable would be lost.
 *
 * Returns the counter file so a test can assert how many samples were consumed;
 * the HEALTH_TIMEOUT in the seeded app.env is raised from 1s to match the number
 * of samples, otherwise the gate times out after the first one.
 */
function sequenceHealth(fx: UpdateFixture, samples: HealthSample[], timeout = 6): string {
  const realCurl = spawnSync('bash', ['-c', 'command -v curl'], { encoding: 'utf8' }).stdout.trim()
  const counter = join(fx.runDir, 'health-polls')
  const seq = samples.map(s => (typeof s === 'string' ? { body: s, rc: 0 } : s))
  const script = [
    `__bodies=(${seq.map(b => `'${b.body}'`).join(' ')})`,
    `__rcs=(${seq.map(b => String(b.rc)).join(' ')})`,
    `__poll='${counter}'`,
    '__n=$(cat "$__poll" 2>/dev/null || printf 0)',
    '__n=$((__n + 1))',
    'printf "%s" "$__n" > "$__poll"',
    '__max=${#__bodies[@]}',
    '[ "$__n" -gt "$__max" ] && __n=$__max',
    'printf "%s" "${__bodies[$((__n - 1))]}"',
    // The rc this transfer reports, so a truncated sample is a FAILED transfer
    // and not a short-but-complete answer. `exit` (not `return`): the stub is
    // run as a program, not sourced.
    'exit "${__rcs[$((__n - 1))]}" ;;'
  ].join('\n')
  writeFileSync(
    join(fx.stubsDir, 'curl'),
    [
      '#!/usr/bin/env bash',
      'for a in "$@"; do',
      '  case "$a" in',
      '    */health)',
      script,
      '  esac',
      'done',
      `exec ${realCurl} "$@"`
    ].join('\n')
  )
  chmodSync(join(fx.stubsDir, 'curl'), 0o755)
  const envFile = join(fx.runDir, 'scripts', 'app.env')
  writeFileSync(
    envFile,
    readFileSync(envFile, 'utf8').replace('HEALTH_TIMEOUT="1"', `HEALTH_TIMEOUT="${timeout}"`)
  )
  return counter
}

function runUpdate(fx: UpdateFixture, args: string[]) {
  // fx.runDir is this fixture's OWN RUN_DIR (update.sh reads ${RUN_DIR}/scripts/
  // app.env at :258 and refresh_unit writes ${RUN_DIR}/unit-refresh at :532), so
  // it is passed explicitly through isolatedEnv rather than left to be derived.
  // Without it this spawn reached the operator's real ~/.synaptomind through
  // the same resolve_target_user fallback that destroyed app.env in #1101.
  return spawnSync('bash', [join(fx.runDir, 'scripts', 'update.sh'), ...args], {
    encoding: 'utf8',
    env: isolatedEnv(fx.runDir, {
      RUN_DIR: fx.runDir,
      PATH: `${fx.stubsDir}:${process.env.PATH}`,
      STUB_PRIV_LOG: fx.privLog,
      // The fake /health body must report the version update.sh is polling for,
      // otherwise wait_health times out. Tests that want a FAILING health check
      // override FAKE_HEALTH_BODY with something the version check rejects.
      FAKE_HEALTH_BODY: fx.healthBody
    }),
    timeout: 60_000
  })
}

describe('update.sh — DIST=binary', () => {
  // No per-describe cleanup is needed: seedBinaryUpdate's tree comes from
  // mkTempTree and the file-level sweep removes it. The try/finally pairs below
  // are kept as defence in depth — they release the tree a moment earlier, and
  // the sweep is `force`, so the second removal is a no-op rather than an error.

  test('swaps the payload, keeps .prev for all three files, and moves the executable last', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      // New payload in place.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
      expect(readFileSync(join(fx.installDir, 'lib', 'libonnxruntime.so.1'), 'utf8')).toBe(
        'new onnxruntime\n'
      )
      // Previous copy kept for a no-git rollback.
      expect(readFileSync(join(fx.installDir, 'vec0.so.prev'), 'utf8')).toBe('old vec0\n')
      expect(readFileSync(join(fx.installDir, U_APP + '.prev'), 'utf8')).toContain('v0.7.1')
      expect(readFileSync(join(fx.installDir, 'lib', 'libonnxruntime.so.1.prev'), 'utf8')).toBe(
        'old onnxruntime\n'
      )
      // No staging or tarball left behind.
      const leftovers = readdirSync(fx.installDir).filter(
        f => f.startsWith('.stage.') || f.endsWith('.tar.gz')
      )
      expect(leftovers).toEqual([])
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('the version guard sees an upgrade, not a downgrade (v-prefix normalisation)', () => {
    // current_version() returns app_version's "v0.7.1"; TARGET_VERSION is bare
    // "0.8.0". Unnormalised, ver_cmp "v0.7.1" "0.8.0" answers "newer" and a
    // plain upgrade is misreported as a downgrade (ADR §2.6).
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stderr).not.toContain('Downgrade')
      expect(res.stdout).toContain('Current:  v0.7.1')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('refreshes the systemd unit so LD_LIBRARY_PATH reaches an updated host', () => {
    // Audit finding 1: without this, a host updating INTO binary mode keeps a
    // unit without Environment=LD_LIBRARY_PATH and the embedder dies on
    // ERR_DLOPEN_FAILED.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, '# stale pre-binary unit\n')
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      const unit = readFileSync(fx.unitFile, 'utf8')
      expect(unit).toContain(`Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`)
      expect(unit).toContain(`ExecStart=${fx.installDir}/${U_APP}`)
      // The line must be immediately after NODE_ENV, per ADR §2.2.
      const lines = unit.split('\n')
      const i = lines.indexOf(`Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`)
      expect(lines[i - 1]).toBe('Environment=NODE_ENV=production')
      // daemon-reload must follow the write, before the restart.
      const log = readFileSync(fx.privLog, 'utf8')
      expect(log).toContain('daemon-reload')
      expect(log).not.toContain('enable')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('the pre-update hook backs up the DB before the swap, and its backup is usable', () => {
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.7.1',
      targetVersion: '0.8.0',
      includeDb: true
    })
    try {
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      const db = join(fx.installDir, 'data', 'synaptomind.db')
      const backups = readdirSync(`${db}.backup`)
      expect(backups).toHaveLength(1)
      const restored = spawnSync(
        'sqlite3',
        [join(`${db}.backup`, backups[0]!), 'select count(*) from t'],
        {
          encoding: 'utf8'
        }
      )
      expect(restored.stdout.trim()).toBe('1')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('source mode does NOT rewrite the unit', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      // Flip the installed app.env to source mode and give it a checkout.
      const envFile = join(fx.runDir, 'scripts', 'app.env')
      writeFileSync(
        envFile,
        readFileSync(envFile, 'utf8').replace('DIST="binary"', 'DIST="source"')
      )
      writeFileSync(fx.unitFile, '# hand-edited source unit\n')
      // update_binary is unreachable in source mode, so run --help-free path:
      // the guard rejects the missing .git before anything else.
      const res = runUpdate(fx, ['--yes'])
      expect(res.status).toBe(1)
      expect(res.stderr).toContain('not installed at')
      // The hand-edited unit is untouched.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe('# hand-edited source unit\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('the recovery block names the .prev set and the mandatory DB restore', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      // Make the poll report a version update.sh did not ask for: that is a
      // health-check failure, which must exit non-zero with the recovery block.
      fx.healthBody = JSON.stringify({ status: 'ok', version: '0.0.1' })
      const res = runUpdate(fx, ['--yes'])
      expect(res.status).toBe(1)
      expect(res.stderr).toContain('update did not finish cleanly')
      expect(res.stderr).toContain('.prev')
      expect(res.stderr).toContain('synaptomind.db.backup')
      // Migrations are forward-only: the DB restore is mandatory, not optional.
      expect(res.stderr).toContain('Restoring the DB is mandatory')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('a payload missing lib/libonnxruntime.so.1 aborts before the swap', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      // Rebuild the release tarball without the shared library.
      const tag = 'v0.8.0'
      const build = join(fx.root, 'build')
      rmSync(join(build, `${U_APP}-0.8.0-linux-x86_64`, 'lib', 'libonnxruntime.so.1'))
      const releases = join(fx.root, 'releases')
      rmSync(join(releases, tag, `${U_APP}-${tag}-linux-x86_64.tar.gz`))
      const tarRes = spawnSync(
        'tar',
        [
          '-czf',
          join(releases, tag, `${U_APP}-${tag}-linux-x86_64.tar.gz`),
          '-C',
          build,
          `${U_APP}-0.8.0-linux-x86_64`
        ],
        { encoding: 'utf8' }
      )
      expect(tarRes.status, tarRes.stderr).toBe(0)

      const res = runUpdate(fx, ['--yes'])
      expect(res.status).toBe(1)
      expect(res.stderr).toContain('lib/libonnxruntime.so.1')
      // The old payload must be intact — nothing was swapped.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('old vec0\n')
      expect(existsSync(join(fx.installDir, 'vec0.so.prev'))).toBe(false)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  // ══════════════════════════════════════════════════════════════════════════
  //  The health gate must see a DEAD embedder, not wave it through as "ok".
  //
  //  A unit rendered without Environment=LD_LIBRARY_PATH makes the embedder
  //  child die on ERR_DLOPEN_FAILED in a loop. /health still answers status
  //  "ok" (the payload's status is DB-only on purpose — the gate fetches it
  //  with `curl -f`, so a 503 would discard the payload that explains the
  //  failure), which is why a gate reading only `status` reports clean success
  //  over permanently dead embeddings. The client latches the crashed state and
  //  the payload carries it as checks.embedder="failed".
  //
  //  Both directions are asserted, and the loading direction matters just as
  //  much: a first binary install legitimately answers "not ready" while the
  //  model downloads, so a gate that rejected it would break every cold install.
  //  (wait_health itself is covered in wait-health.test.ts.)
  // ══════════════════════════════════════════════════════════════════════════

  test('a dead embedder (checks.embedder=failed) fails the update and names the remedy', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      // Everything the gate checks apart from the embedder is healthy: status
      // ok and the exact version update.sh asked for.
      fx.healthBody = JSON.stringify({
        status: 'ok',
        version: '0.8.0',
        checks: { database: 'ok', embedder: 'failed' }
      })

      const res = runUpdate(fx, ['--yes'])

      expect(res.status).not.toBe(0)
      expect(res.stdout).not.toContain('Done.')
      // The specific failure, and what to do about it.
      expect(res.stderr).toContain('checks.embedder=failed')
      expect(res.stderr).toContain(`LD_LIBRARY_PATH=${fx.installDir}/lib`)
      // A failed gate is a failed update: the recovery block must be printed.
      expect(res.stderr).toContain('update did not finish cleanly')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 30_000)

  test('a still-loading embedder (checks.embedder="not ready") passes the update', () => {
    // The first install of a binary: the model is still downloading. This must
    // succeed, or every cold install is reported as broken.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      fx.healthBody = JSON.stringify({
        status: 'ok',
        version: '0.8.0',
        checks: { database: 'ok', embedder: 'not ready' }
      })

      const res = runUpdate(fx, ['--yes'])

      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stdout).toContain('Done.')
      expect(res.stderr).not.toContain('checks.embedder=failed')
      // The payload really did swap.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('an embedder that recovers on the retry does not fail the update', () => {
    // The check is re-derived per sample, not latched: a crash that clears
    // itself must not fail the install it left healthy.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      const counter = sequenceHealth(
        fx,
        [
          JSON.stringify({
            status: 'ok',
            version: '0.8.0',
            checks: { database: 'ok', embedder: 'failed' }
          }),
          JSON.stringify({
            status: 'ok',
            version: '0.8.0',
            checks: { database: 'ok', embedder: 'ok' }
          })
        ],
        // The gate sleeps 2s between polls; the slack keeps the second sample
        // reachable on a loaded machine.
        8
      )

      const res = runUpdate(fx, ['--yes'])

      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stdout).toContain('Done.')
      // The failing sample was really served first — otherwise this test would
      // pass on a gate that never saw a failure.
      expect(Number(readFileSync(counter, 'utf8').trim())).toBeGreaterThanOrEqual(2)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 30_000)
})

// ════════════════════════════════════════════════════════════════════════════
//  F4 — the health verdict must survive a slow start, and only a CONFIRMED
//  failure may be answered with a destructive remedy.
//
//  The verdict used to be version-only and time-boxed, and the two properties
//  compounded: with HEALTH_TIMEOUT=2 a HEALTHY 0.8.2 upgrade was declared "did
//  not finish cleanly" — together with a remedy that stops the unit, moves the
//  .prev payload back and RESTORES THE DATABASE over the new schema's — while
//  the very same service went on answering /health 0.8.2 eight seconds later.
//  A timeout is not evidence that an upgrade is bad, and a remedy that destroys
//  state must never be printed for one.
// ════════════════════════════════════════════════════════════════════════════

describe('update.sh — the health verdict is re-checked, and only a confirmed failure is destructive', () => {
  const HEALTHY_082 = JSON.stringify({
    status: 'ok',
    version: '0.8.2',
    checks: { database: 'ok', embedder: 'ok' }
  })

  test('a healthy upgrade is not failed when HEALTH_TIMEOUT buys only ONE poll (F4)', () => {
    // The counterexample, verbatim: HEALTH_TIMEOUT=2 and a service that answers
    // from the second poll on (a cold start). The gate sleeps 2s between polls,
    // so a 2s window buys exactly one sample and the old code read that as a
    // timeout — a failure verdict, plus a rollback that restores the database.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.8.1', targetVersion: '0.8.2' })
    try {
      const counter = sequenceHealth(fx, ['', HEALTHY_082], 2)

      const res = runUpdate(fx, ['--yes'])

      // The healthy sample was really consumed by a SECOND window: this is not a
      // gate that passed without ever looking again.
      expect(
        Number(readFileSync(counter, 'utf8').trim()),
        'the verdict must be re-checked, not decided on the first window'
      ).toBeGreaterThanOrEqual(2)
      expect(res.status, res.stdout + res.stderr).toBe(0)
      expect(res.stdout).toContain('Done.')
      // And the successful path prints no remedy of any kind.
      expect(res.stderr).not.toContain('did not finish cleanly')
      expect(res.stderr).not.toContain('sudo cp')
      expect(res.stderr).not.toContain('sudo mv -f')
      // A successful update, not a skipped one: the payload really swapped.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('a service that never answers fails UNCONFIRMED, with no destructive remedy (F4)', () => {
    // The other direction, and the one that destroys data: a payload swapped in
    // and a service that is merely slow must not be answered with "stop the
    // unit, move the .prev payload back and restore the database". Re-checking
    // cannot manufacture an answer, so a second timed-out window is still
    // unconfirmed — the re-check must not promote it to a failure.
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.8.1',
      targetVersion: '0.8.2',
      includeDb: true
    })
    try {
      const counter = sequenceHealth(fx, [''], 1)
      const db = join(fx.installDir, 'data', 'synaptomind.db')

      const res = runUpdate(fx, ['--yes'])

      // The pre-update hook creates the backup directory DURING the run, so the
      // rollback point is only readable after it.
      const backups = readdirSync(`${db}.backup`)
      expect(backups).toHaveLength(1)
      expect(backups[0]).toMatch(/\.bak$/)
      // Both windows were used, and the verdict stayed unconfirmed.
      expect(Number(readFileSync(counter, 'utf8').trim())).toBeGreaterThanOrEqual(2)
      expect(res.status, res.stdout + res.stderr).toBe(1)
      expect(res.stderr).toContain('UNVERIFIED')
      // Nothing destructive is advised. Each of these is a line the old
      // recovery block printed for a timeout it had not actually observed.
      expect(res.stderr).not.toContain('did not finish cleanly')
      expect(res.stderr).not.toContain('Restoring the DB is mandatory')
      expect(res.stderr).not.toContain('sudo cp')
      expect(res.stderr).not.toContain('sudo mv -f')
      expect(res.stderr).not.toContain('git checkout --force')
      // The rollback point is left exactly where it was: the backup is still on
      // disk and the swapped payload is not reverted behind the operator's back.
      expect(readdirSync(`${db}.backup`)).toEqual(backups)
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('a service that answers with the WRONG version is confirmed, and keeps the remedy (F4)', () => {
    // The re-check must not soften a failure that was actually OBSERVED: this
    // answer came from the service (a stale 0.8.1 still holding the port, the
    // shape the identity check in wait_health exists for), so the destructive
    // remedy is earned and must still be printed.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.8.1', targetVersion: '0.8.2' })
    try {
      fx.healthBody = JSON.stringify({ status: 'ok', version: '0.8.1' })

      const res = runUpdate(fx, ['--yes'])

      expect(res.status, res.stdout + res.stderr).toBe(1)
      expect(res.stderr).toContain('did not finish cleanly')
      expect(res.stderr).toContain('Restoring the DB is mandatory')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)
})

// ════════════════════════════════════════════════════════════════════════════
//  A body that DIES MID-TRANSFER, then silence, is the transient F4 exists for.
//
//  The gate's identity verdict was re-derived only when a body arrived, so a
//  poll that answered nothing inherited it. A server that closes mid-body
//  leaves real curl rc=18 with a SHORT body (measured on this host: 25 bytes of
//  a promised 4096), the next poll found nothing at all, and the verdict came
//  out `contract` — an OBSERVED failure. update.sh then took the confirmed
//  branch: health_recheck (the re-check that exists precisely to keep a
//  destructive remedy off an unconfirmed failure) was SKIPPED, and the
//  per-database restore was printed.
//
//  A partial body during a rolling restart is ordinary. The pair below is the
//  whole contract of the fix: the transient is unverified and prints nothing
//  destructive, and a REAL contradiction — even one arriving after the
//  transient — is still confirmed and still prints the remedy.
// ════════════════════════════════════════════════════════════════════════════

describe('update.sh — a truncated /health body, then silence, is UNVERIFIED', () => {
  // rc 18 with a short body: a transfer closed mid-body. Measured, not invented
  // (the wait-health cases carry the same sequence at the gate itself).
  const CUT_OFF = { body: '<html><head><title>502 Ba', rc: 18 }
  // Nothing on the port at all afterwards (curl rc 7, empty body).
  const SILENCE = { body: '', rc: 7 }

  test('it re-checks, reports UNVERIFIED, and prints no database restore', () => {
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.8.1',
      targetVersion: '0.8.2',
      includeDb: true
    })
    try {
      // 6s of polling buys the truncated sample and the silent polls after it;
      // the seeded HEALTH_CONFIRM_TIMEOUT=2 then adds the second window.
      const counter = sequenceHealth(fx, [CUT_OFF, SILENCE], 6)
      const db = join(fx.installDir, 'data', 'synaptomind.db')

      const res = runUpdate(fx, ['--yes'])

      const backups = readdirSync(`${db}.backup`)
      expect(backups).toHaveLength(1)
      expect(res.status, res.stdout + res.stderr).toBe(1)
      // The re-check was NOT skipped, and not merely "two windows were opened":
      // this line is printed only when health_recheck actually ran and came back
      // without a verdict (HEALTH_RECHECKED). With the verdict latched, the run
      // consumed the truncated sample, called it a failure there and then, and
      // never spent a second window.
      expect(res.stderr).toContain('re-check that followed it')
      // The first window was really polled.
      expect(Number(readFileSync(counter, 'utf8').trim())).toBeGreaterThanOrEqual(2)
      expect(res.stderr).toContain('UNVERIFIED')
      // Nothing destructive is advised. Each of these is a line the confirmed
      // block prints for a failure that was never observed.
      expect(res.stderr).not.toContain('did not finish cleanly')
      expect(res.stderr).not.toContain('Restoring the DB is mandatory')
      expect(res.stderr).not.toContain('sudo cp')
      expect(res.stderr).not.toContain('sudo mv -f')
      expect(res.stderr).not.toContain('git checkout --force')
      // And no stranger on the port is diagnosed out of a silent window.
      expect(res.stderr).not.toContain('not with a')
      // The rollback point is left exactly where it was.
      expect(readdirSync(`${db}.backup`)).toEqual(backups)
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('a truncated body followed by a REAL wrong version is still confirmed', () => {
    // The direction the fix must not trade away. A stale 0.8.1 answering after
    // the transient IS an observed contradiction: the verdict is `version`, the
    // remedy is earned, and a "fix" that made the gate go quiet after one bad
    // body would swallow it and call a bad upgrade a slow start.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.8.1', targetVersion: '0.8.2' })
    try {
      const stale = JSON.stringify({ status: 'ok', version: '0.8.1' })
      sequenceHealth(fx, [CUT_OFF, stale], 6)

      const res = runUpdate(fx, ['--yes'])

      expect(res.status, res.stdout + res.stderr).toBe(1)
      expect(res.stderr).not.toContain('UNVERIFIED')
      expect(res.stderr).toContain('did not finish cleanly')
      expect(res.stderr).toContain('Restoring the DB is mandatory')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)
})

// ══════════════════════════════════════════════════════════════════════════════
//  F6 — the recovery block must name the backups the pre-update hook produced.
//
//  It used to print `sudo cp data/synaptomind.db.backup/synaptomind.db.
//  <timestamp>.bak data/synaptomind.db` — a template, not a path. The only DB
//  restore it offered was a command that cannot run, and the very next lines
//  told the operator the hook "printed each backup path" instead of printing the
//  paths it had. The hook's own output is the record, so it is read from there.
// ══════════════════════════════════════════════════════════════════════════════

describe('update.sh — the recovery block names the real database backups (F6)', () => {
  test('every backup the hook made is named, with a runnable cp/rm pair', () => {
    // A CONFIRMED failure (the service answers with a version nobody asked for),
    // so the destructive block is the right output here — and it has to be
    // runnable. Two databases, because the block used to hand the operator one
    // template and a note to "repeat for every database the hook backed up".
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.8.1',
      targetVersion: '0.8.2',
      includeDb: true
    })
    try {
      const db = join(fx.installDir, 'data', 'synaptomind.db')
      writeFileSync(
        join(fx.installDir, 'config.json'),
        JSON.stringify({ server: { port: 3999 }, database: { path: './data/synaptomind.db' } })
      )
      fx.healthBody = JSON.stringify({ status: 'ok', version: '0.0.1' })

      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stdout + res.stderr).toBe(1)

      // The database was really backed up…
      const mainBackups = readdirSync(`${db}.backup`)
      expect(mainBackups).toHaveLength(1)

      // …and the block names it, by its real file name, in a form that can
      // be pasted: the backup path and the database it restores.
      const restores = res.stderr.split('\n').filter(l => l.includes('sudo cp -p'))
      expect(restores).toHaveLength(1)
      for (const name of mainBackups) {
        const line = restores.find(l => l.includes(name))
        expect(line, `the recovery block must name the real backup ${name}`).toBeDefined()
        // Single-quoted operands, so a path with a space survives being pasted.
        expect(line).toMatch(/sudo cp -p '[^']+' '[^']+'/)
      }
      // And the placeholder is gone: no command here is unrunnable.
      expect(res.stderr).not.toContain('<timestamp>')
      // The WAL pair is named per database, next to its own cp.
      const rms = res.stderr.split('\n').filter(l => l.includes('sudo rm -f'))
      expect(rms).toHaveLength(1)
      expect(res.stderr).toContain('Restoring the DB is mandatory')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)
})

// ══════════════════════════════════════════════════════════════════════════════
//  Task #1102, end to end — a failing hook reaches the caller's decision.
//
//  run_hook could not carry a hook's exit status out of its tee pipeline (the
//  assignment was in the pipeline's left STAGE, i.e. a subshell), so
//  `if ! run_hook pre-update` never fired: a failed database backup was followed
//  by a code swap, while update.sh:809 called the hook fatal. run-hook.test.ts
//  pins the function; these pin the two CALLERS, which is where a status that
//  never arrives turns into a wrong outcome on disk.
// ══════════════════════════════════════════════════════════════════════════════

describe('update.sh — a failed pre-update hook aborts before the swap (task #1102)', () => {
  test('exit 7 stops the run with the fatal message, and nothing on disk changed', () => {
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.7.1',
      targetVersion: '0.8.0',
      failingHook: { hook: 'pre-update', code: 7 }
    })
    try {
      const res = runUpdate(fx, ['--yes'])

      // The guard fired, with the message the code has always claimed to print.
      expect(res.status, res.stdout + res.stderr).toBe(1)
      expect(res.stderr).toContain('pre-update hook failed — aborting before switching code')
      expect(res.stderr).toContain('pre-update hook failed (exit 7)')

      // BEFORE the swap: the installed payload is still the old one. This is the
      // whole point of the guard — a failed backup must not be followed by new
      // code running against a database nobody backed up.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('old vec0\n')
      expect(readFileSync(join(fx.installDir, 'lib', 'libonnxruntime.so.1'), 'utf8')).toBe(
        'old onnxruntime\n'
      )
      // No .prev copies either: binary_install_payload never ran.
      expect(existsSync(join(fx.installDir, 'vec0.so.prev'))).toBe(false)
      expect(existsSync(join(fx.installDir, U_APP + '.prev'))).toBe(false)

      // BEFORE the unit refresh and the restart: nothing privileged was asked to
      // do anything except what reading the release needs. A `systemctl restart`
      // here would mean the new code was already live.
      const log = readFileSync(fx.privLog, 'utf8')
      expect(log).not.toContain('restart')
      expect(log).not.toContain('daemon-reload')

      // Streaming survived the status fix: the failing hook's own output is still
      // on the operator's terminal, which is how they see WHY it failed.
      expect(res.stdout).toContain('simulated.bak')
      // The hook's stderr is folded into the transcript by `2>&1`, so it lands
      // on stdout too — that is the point of the fold: one stream for the
      // operator, one file for the parser.
      expect(res.stdout).toContain('simulated pre-update failure')

      // The run never claimed to be done.
      expect(res.stdout).not.toContain('Done. Now at')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('the abort still names nothing to restore, because nothing was touched', () => {
    // The pre-update abort is the ONE hook failure with no recovery block to
    // print: the payload, the unit and the service are all untouched, so naming
    // restore commands would imply a damage that did not happen. Asserted so the
    // asymmetry with post-update (which does swap) stays deliberate.
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.7.1',
      targetVersion: '0.8.0',
      failingHook: { hook: 'pre-update', code: 1 }
    })
    try {
      const res = runUpdate(fx, ['--yes'])
      expect(res.status).toBe(1)
      expect(res.stderr).not.toContain('sudo cp -p')
      expect(res.stderr).not.toContain('Restoring the DB is mandatory')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('a pre-update hook that exits 0 proceeds exactly as it did', () => {
    // The other direction, so the new `|| rc=${PIPESTATUS[0]}` cannot be "fixed"
    // into aborting a good run: with PIPESTATUS[0] the success path is tee and
    // nothing else.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stdout + res.stderr).toBe(0)
      expect(res.stderr).not.toContain('hook failed')
      expect(res.stdout).toContain('Done. Now at 0.8.0')
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
      expect(readFileSync(fx.privLog, 'utf8')).toContain('sudo systemctl restart synaptomind')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('the temp-root entry count is unchanged across a failing run', () => {
    // Task #1101 closed the mktemp-ownership leak class; the status fix must not
    // reopen it. The transcript is still created (mktemp_owned) and must still be
    // removed by the EXIT trap on the ABORT path — which `error` takes, so the
    // trap runs on a path the happy path never exercises.
    const tmpRoot = mkTempTree('synapto-hookcount-')
    const before = readdirSync(tmpRoot)
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.7.1',
      targetVersion: '0.8.0',
      failingHook: { hook: 'pre-update', code: 7 }
    })
    try {
      const res = spawnSync('bash', [join(fx.runDir, 'scripts', 'update.sh'), '--yes'], {
        encoding: 'utf8',
        env: isolatedEnv(fx.runDir, {
          RUN_DIR: fx.runDir,
          PATH: `${fx.stubsDir}:${process.env.PATH}`,
          STUB_PRIV_LOG: fx.privLog,
          FAKE_HEALTH_BODY: fx.healthBody,
          // A temp root THIS test owns, so the count is exact and cannot be moved
          // by another session's scratch in the host's shared /tmp.
          TMPDIR: tmpRoot
        }),
        timeout: 60_000
      })
      expect(res.status, res.stdout + res.stderr).toBe(1)
      expect(readdirSync(tmpRoot)).toEqual(before)
      expect(before).toEqual([])
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)
})

describe('update.sh — a failed post-update hook does not abort mid-flight (task #1102)', () => {
  // The deliberate decision, pinned so it cannot be changed by accident. Aborting
  // after the swap would leave the host worse off than continuing: the payload
  // would be new, the unit unrefreshed and the service never restarted — swapped
  // code nothing has ever executed, with no health verdict. So the run finishes
  // and reports the failure with a non-zero exit AFTER the restart.

  test('the swap, the unit refresh and the restart all happen, then it exits 1 naming the hook', () => {
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.7.1',
      targetVersion: '0.8.0',
      failingHook: { hook: 'post-update', code: 3 }
    })
    try {
      const res = runUpdate(fx, ['--yes'])

      expect(res.status, res.stdout + res.stderr).toBe(1)
      expect(res.stderr).toContain('post-update hook failed (exit 3)')
      // Not reported as success: the message says the update landed AND what did
      // not, and the exit status is non-zero so a wrapper sees it.
      expect(res.stderr).toContain('update landed at 0.8.0')
      expect(res.stderr).toContain('the service was restarted on the new code anyway')

      // …and the run really did finish the work a mid-flight abort would have
      // skipped: new payload in place, unit refreshed, service restarted.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
      const log = readFileSync(fx.privLog, 'utf8')
      expect(log).toContain('daemon-reload')
      expect(log).toContain('restart')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('the failing post-update hook still streams and captures, and a recovery block can name its backup', () => {
    // The tee properties under the OTHER caller, and the reason a post-update
    // failure is reported rather than silently ignored: post-update is where a
    // future migration hook would live, and the recovery block below is built
    // from the transcript the pre-update hook produced.
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.7.1',
      targetVersion: '0.8.0',
      includeDb: true,
      failingHook: { hook: 'post-update', code: 3 }
    })
    try {
      // Fail the health gate as well, so the recovery block prints.
      fx.healthBody = JSON.stringify({ status: 'ok', version: '0.0.1' })
      const res = runUpdate(fx, ['--yes'])

      // The health verdict is what ends the run here (a confirmed wrong version),
      // which is also the only path that prints a recovery block — so the
      // post-update status is reported but is not the reason for the exit.
      expect(res.status).toBe(1)
      expect(res.stderr).toContain('post-update hook failed (exit 3)')
      // The real pre-update backup is named: the capture half of the tee is
      // intact, so collect_db_backups still parsed the transcript.
      const db = join(fx.installDir, 'data', 'synaptomind.db')
      const backups = readdirSync(`${db}.backup`)
      expect(backups).toHaveLength(1)
      // Matched on the file NAME, not on a rebuilt absolute path: the hook prints
      // the path it derived from config.json (`./data/…`), so the block names that
      // spelling. What matters here is that the name from the transcript reached
      // the block at all — which is exactly what a dropped capture would lose.
      expect(res.stderr).toContain(`sudo cp -p '`)
      expect(res.stderr).toContain(backups[0]!)
      expect(res.stderr).toContain("synaptomind.db'")
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)
})

// ══════════════════════════════════════════════════════════════════════════════
//  F3, end to end — the tag a real update resolves, through release_resolve_tag.
//
//  The direct shape tests above pin the parse; these two pin what the parse is
//  FOR, on the path the 0.9.0 cutover takes: which payload an update installs.
// ══════════════════════════════════════════════════════════════════════════════

describe('update.sh — the release list decides which payload is installed (F3)', () => {
  test('stable upgrades to v0.9.0 from a minified list whose LAST entry is a prerelease', () => {
    // The hard-fail direction, through the real code path: with the prerelease
    // last, a line-based read saw one entry, `stable` found no stable tag and the
    // update aborted with "no stable release tag" — on a host whose newest stable
    // release was sitting in the very same list.
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.8.1',
      targetVersion: '0.9.0',
      releasesBody: JSON.stringify([
        { tag_name: 'v0.9.0', draft: false },
        { tag_name: 'v0.8.2', draft: false },
        { tag_name: 'v0.9.0-beta.1', prerelease: true }
      ])
    })
    try {
      const res = runUpdate(fx, ['--yes'])

      expect(res.status, res.stdout + res.stderr).toBe(0)
      expect(res.stdout).toContain('Target:   0.9.0')
      expect(res.stdout).not.toContain('no stable release tag')
      expect(readFileSync(join(fx.installDir, U_APP), 'utf8')).toContain('v0.9.0')
      expect(readFileSync(join(fx.installDir, U_APP + '.prev'), 'utf8')).toContain('v0.8.1')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)

  test('latest upgrades to v0.9.0 from a newest-first list, not to the oldest entry', () => {
    // The silent direction: newest-first with v0.8.2 last, `latest` is "newest
    // tag of ANY kind". A line-based read resolved v0.8.2, which this host already
    // runs — so the update reported "already up to date" and exited 0 without
    // installing anything. An exit code of 0 for the wrong payload is the worst
    // of the two failures, so it gets its own test.
    const fx = seedBinaryUpdate({
      currentVersion: 'v0.8.2',
      targetVersion: '0.9.0',
      releasesBody: JSON.stringify([{ tag_name: 'v0.9.0' }, { tag_name: 'v0.8.2' }])
    })
    try {
      const envFile = join(fx.runDir, 'scripts', 'app.env')
      writeFileSync(
        envFile,
        readFileSync(envFile, 'utf8').replace(
          'CHECKOUT_POLICY="stable"',
          'CHECKOUT_POLICY="latest"'
        )
      )

      const res = runUpdate(fx, ['--yes'])

      expect(res.status, res.stdout + res.stderr).toBe(0)
      expect(res.stdout).not.toContain('Already up to date')
      expect(res.stdout).toContain('Target:   0.9.0')
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  }, 60_000)
})

// ════════════════════════════════════════════════════════════════════════════
//  A FAILED unit refresh must not report a successful binary update.
//
//  Epic-review finding (task #1039, the reviewer's own): refresh_unit
//  warn-and-continued on every failure path, so update.sh returned 0, restarted
//  the service and printed "Done." while the installed unit still lacked
//  Environment=LD_LIBRARY_PATH. The payload just swapped in is a compiled
//  binary whose embedder child cannot dlopen lib/libonnxruntime.so.1 without
//  that line, so it dies on ERR_DLOPEN_FAILED — and /health still answers
//  status "ok", which is all the health gate read (finding F1).
//
//  In binary mode a refresh that did not happen is FATAL. What must NOT become
//  fatal: source mode (no LD_LIBRARY_PATH line exists there) and a host with no
//  unit on disk to go stale (a --no-service / container install) — both
//  asserted below, so the rule cannot widen by accident.
//
//  The rendered unit is kept on failure, because the remedy cannot be "re-run
//  update.sh": the "Already up to date" guard in main() exits BEFORE
//  refresh_unit is reached again, so a re-run is a no-op.
// ════════════════════════════════════════════════════════════════════════════

/**
 * A `cp` that fails only for the given destination and passes everything else
 * to the real cp. It has to be narrow: the payload's .prev copy and the unit's
 * .bak both go through write_file_atomically (task #1094 / #1079 F7), and
 * refresh_unit saves the rendered unit as well, so a blanket failure would abort
 * the swap itself and the run would exit 1 for an unrelated reason.
 */
/**
 * A `cp` that fails for every destination inside `dir` — "cannot write the unit"
 * as an operator experiences it (a read-only or full unit directory).
 *
 * Matched on the DIRECTORY, not on the exact path: the unit is no longer written
 * by `cp` straight onto itself, so a stub that failed only for the unit's own
 * path would now fail NOTHING and the test would pass for the wrong reason.
 * Every cp aimed at the staging file and at the .bak is refused; every other cp
 * in the run passes through, so the payload swap still happens for real.
 */
function makeCpFailingInDir(dir: string): string {
  const realCp = spawnSync('bash', ['-c', 'command -v cp'], { encoding: 'utf8' }).stdout.trim()
  return [
    '#!/usr/bin/env bash',
    'for a in "$@"; do',
    `  case "$a" in ${dir}/*)`,
    '    echo "cp: cannot create regular file $a: Permission denied" >&2; exit 1',
    '  ;; esac',
    'done',
    `exec ${realCp} "$@"`
  ].join('\n')
}

/** Rewrite one PATH stub of an existing fixture. */
function rewriteStub(fx: ReturnType<typeof seedBinaryUpdate>, name: string, body: string): void {
  writeFileSync(join(fx.stubsDir, name), body)
  chmodSync(join(fx.stubsDir, name), 0o755)
}

/**
 * Override one key of the installed app.env, which is `.`-sourced, so the value
 * is written single-quoted (a double-quoted one would be expanded at source
 * time — the reason the shipped app.env quotes ASSET_PATTERN that way).
 */
function setAppEnv(fx: ReturnType<typeof seedBinaryUpdate>, key: string, value: string): void {
  const envFile = join(fx.runDir, 'scripts', 'app.env')
  const text = readFileSync(envFile, 'utf8')
  const line = new RegExp(`^${key}=.*$`, 'm')
  expect(text, `app.env must carry ${key}`).toMatch(line)
  writeFileSync(envFile, text.replace(line, `${key}='${value.replace(/'/g, `'\\''`)}'`))
}

describe('update.sh — a failed unit refresh is fatal in binary mode', () => {
  test('a unit that cannot be written aborts instead of reporting success', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, '# stale pre-binary unit\n')
      rewriteStub(fx, 'cp', makeCpFailingInDir(dirname(fx.unitFile)))
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
      expect(res.stdout).not.toContain('Done.')
      // The service must not be restarted on a unit that was not refreshed.
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('restart')
      // The stale unit is still the one on disk, and the payload is in place.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe('# stale pre-binary unit\n')
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('a refused privileged write aborts, and the service keeps running the old payload', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      const stale = '# stale pre-binary unit\n'
      writeFileSync(fx.unitFile, stale)
      // sudo works for systemctl, but every write aimed at the UNIT DIRECTORY is
      // refused (no tty to ask for a password) — a read-only or full unit
      // directory as an operator meets it. Scoped to that directory on purpose:
      // the payload's .prev copy is a privileged write too (write_file_atomically,
      // task #1079 F7), so refusing every write primitive would abort the run at
      // step 6 and never reach refresh_unit at all.
      rewriteStub(fx, 'sudo', makeGuardedSudo(fx.guard, { refuseUnder: dirname(fx.unitFile) }))
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
      expect(res.stdout).not.toContain('Done.')
      const log = readFileSync(fx.privLog, 'utf8')
      expect(log).toContain(dirname(fx.unitFile))
      expect(log).not.toContain('restart')
      // A refused write is a no-op: the operator's unit is byte for byte intact.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(stale)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('systemd down with a unit already on disk aborts (that unit governs the next boot)', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, '# stale pre-binary unit\n')
      rewriteStub(fx, 'systemctl', SYSTEMCTL_STUB_OFFLINE)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
      expect(res.stdout).not.toContain('Done.')
      expect(readFileSync(fx.unitFile, 'utf8')).toBe('# stale pre-binary unit\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('a failed daemon-reload aborts too — a restart would apply the old body', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, '# stale pre-binary unit\n')
      rewriteStub(fx, 'systemctl', SYSTEMCTL_STUB_RELOAD_FAILS)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
      expect(res.stdout).not.toContain('Done.')
      expect(res.stderr).toContain('daemon-reload')
      // The unit on disk IS the refreshed one here, so the remedy must not
      // install the unit over itself.
      expect(readFileSync(fx.unitFile, 'utf8')).toContain(
        `Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`
      )
      expect(res.stderr).not.toContain('sudo install')
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('restart')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('the failure names what failed, the line at stake, and a remedy that works', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, '# stale pre-binary unit\n')
      rewriteStub(fx, 'cp', makeCpFailingInDir(dirname(fx.unitFile)))
      const res = runUpdate(fx, ['--yes'])
      const err = res.stderr
      // What failed, in words an operator can act on.
      expect(err).toContain(fx.unitFile)
      expect(err).toContain(`Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`)
      expect(err).toContain('ERR_DLOPEN_FAILED')
      // The remedy must point at a file that really exists and really carries
      // the line, not just at words.
      const saved = err.match(/rendered:\s*(\S+)/)?.[1]
      expect(saved, `no rendered-unit path in:\n${err}`).toBeTruthy()
      expect(readFileSync(saved!, 'utf8')).toContain(
        `Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`
      )
      expect(err).toContain('sudo install')
      expect(err).toContain('daemon-reload')
      // The rollback block still ships: the payload is already swapped, and
      // migrations are forward-only.
      expect(err).toContain('update did not finish cleanly')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('no unit on disk and no systemd is NOT fatal — there is nothing stale to refresh', () => {
    // The counter-test that keeps the rule honest: a --no-service / container
    // binary install has no unit to go stale, so the update must still succeed.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      rewriteStub(fx, 'systemctl', SYSTEMCTL_STUB_OFFLINE)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stdout).toContain('Done.')
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  // ── a value the render REFUSES reports recovery like every other failure ──
  // #1096 F3. render_systemd_unit() guards the substituted values (a trailing
  // backslash folds the next directive away, a newline forges one), and until
  // this task the guard ended in `error`, which is exit(1). It runs inside
  // refresh_unit — AFTER the payload has been swapped — so main()'s
  //   if ! refresh_unit; then print_recovery; error …
  // never ran: the operator got no rollback block, no "a re-run will not fix
  // this" warning, and a re-run reported the version as already up to date
  // without touching the unit. The exit code was right; the report was missing.
  test('a refused value aborts with the recovery block, not a bare exit', () => {
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      const stale = '# stale pre-binary unit\n'
      writeFileSync(fx.unitFile, stale)
      // A DATA_DIR ending in a backslash: `ReadWritePaths=/opt/x /var/lib/y\`
      // CONTINUES onto the next line, so PrivateTmp=true is absorbed into it.
      // Same refusal, same fail-closed exit, as a newline.
      setAppEnv(fx, 'DATA_DIR', `${join(fx.root, 'data')}\\`)

      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
      expect(res.stdout).not.toContain('Done.')

      // THE FINDING. The recovery block, exactly as every other failure prints.
      expect(res.stderr).toContain('update did not finish cleanly')
      expect(res.stderr).toContain('rollback:')
      // …and the warning that the unit on disk is not this update's unit.
      expect(res.stderr).toContain('systemd unit NOT refreshed')
      expect(res.stderr).toContain('DATA_DIR')
      expect(res.stderr).toContain(`Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`)
      expect(res.stderr).toContain('ERR_DLOPEN_FAILED')
      // …and the trap a re-run walks into, which is why the remedy is not
      // "just re-run".
      expect(res.stderr).toContain('already up to date')

      // The state this is about, asserted rather than assumed: the payload IS
      // swapped (so the rollback block is the honest output), the unit is NOT
      // touched, and the service is not restarted on a unit that cannot load it.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(stale)
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('restart')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('the refused-value remedy is actionable, because a re-run cannot fix it', () => {
    // The two halves of the trap, pinned: the re-run really does refuse to fix
    // the unit, and the message says what to do instead of sending the operator
    // into that re-run.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, '# stale pre-binary unit\n')
      setAppEnv(fx, 'DATA_DIR', `${join(fx.root, 'data')}\\`)

      const first = runUpdate(fx, ['--yes'])
      expect(first.status, first.stderr).toBe(1)
      expect(first.stderr).toContain('fix the value named above')
      // Nothing to install over the installed one: the refusal happens before
      // any unit text is written, so the message must not name a rendered file.
      expect(first.stderr).toContain('no file to install over the installed one')
      expect(first.stderr).not.toMatch(/sudo install -m 644 \S+/)

      const second = runUpdate(fx, ['--yes'])
      expect(second.status, second.stderr).toBe(0)
      expect(second.stdout).toContain('Already up to date')
      // …and the unit is still the stale one: the trap the remedy must route
      // around rather than tell the operator to walk into.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe('# stale pre-binary unit\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })
})

// ── Source mode ──────────────────────────────────────────────────────────────

/**
 * A DIST=source install: a real clone sitting at v0.7.1 whose origin carries a
 * newer stable tag, plus the installed-layout state dir. REQUIRES_BUN=no keeps
 * the fixture off `bun install` — the unit decision does not depend on it.
 */
function seedSourceUpdate() {
  const origin = seedTwoStableTagsRepo('v0.7.1', 'v0.8.0', {
    'package.json': '{"name":"synaptomind","version":"0.7.1"}'
  })
  const root = mkTempTree('synapto-src-')
  const installDir = join(root, 'opt', U_APP)
  const runDir = join(root, 'run')
  const stubsDir = join(root, 'stubs')
  const privLog = join(root, 'privileged.log')
  const unitFile = join(root, 'unit', `${U_APP}.service`)
  mkdirSync(join(root, 'unit'), { recursive: true })
  mkdirSync(join(runDir, 'scripts'), { recursive: true })
  mkdirSync(join(runDir, 'hooks'), { recursive: true })
  mkdirSync(stubsDir, { recursive: true })
  writeFileSync(privLog, '')

  const clone = spawnSync('git', ['clone', '-q', origin, installDir], { encoding: 'utf8' })
  expect(clone.status, clone.stderr).toBe(0)
  // Park the installed checkout on the OLD tag, so the run is a real upgrade.
  const co = spawnSync('git', ['-C', installDir, 'checkout', '-q', '--force', 'v0.7.1'], {
    encoding: 'utf8'
  })
  expect(co.status, co.stderr).toBe(0)

  for (const f of ['update.sh', 'updater.sh', 'uninstall.sh']) {
    cpSync(join(import.meta.dir, f), join(runDir, 'scripts', f))
  }
  for (const f of ['pre-update', 'post-update']) {
    cpSync(join(import.meta.dir, 'hooks', f), join(runDir, 'hooks', f))
    chmodSync(join(runDir, 'hooks', f), 0o755)
  }
  cpSync(join(import.meta.dir, 'lib', 'common.sh'), join(runDir, 'scripts', 'common.sh'))

  const guard = [`"${root}"/*|"${root}"/*`, `"${tmpdir()}"/*|"${tmpdir()}"/*`].join('|')
  writeFileSync(join(stubsDir, 'sudo'), makeGuardedSudo(guard))
  writeFileSync(join(stubsDir, 'systemctl'), SYSTEMCTL_STUB)
  for (const s of ['sudo', 'systemctl']) chmodSync(join(stubsDir, s), 0o755)
  const realCurl = spawnSync('bash', ['-c', 'command -v curl'], { encoding: 'utf8' }).stdout.trim()
  writeFileSync(
    join(stubsDir, 'curl'),
    [
      '#!/usr/bin/env bash',
      'for a in "$@"; do case "$a" in */health) printf \'%s\' "$FAKE_HEALTH_BODY"; exit 0 ;; esac; done',
      `exec ${realCurl} "$@"`
    ].join('\n')
  )
  chmodSync(join(stubsDir, 'curl'), 0o755)

  const env = {
    APP_NAME: U_APP,
    APP_DESC: 'Synaptomind — thought-graph engine',
    DIST: 'source',
    INSTALL_DIR: installDir,
    DATA_DIR: join(root, 'data'),
    RUN_DIR: runDir,
    PORT: '3999',
    RELEASES_BASE: '',
    RELEASE_API: '',
    ASSET_PATTERN: "'${APP_NAME}-${TAG}-${OS}-${ARCH}.tar.gz'",
    APP_VERSION_CMD: "'${BIN} --version'",
    CHECKOUT_POLICY: 'stable',
    REQUIRES_BUN: 'no',
    SYSTEM_DEP_CMDS: '',
    SERVICE_USER: '',
    SEED_FILES: 'config.json.example:config.json .env.example:.env',
    GENERATE_SECRET_IN: '.env',
    HOOKS_DIR: '',
    UNIT_FILE: unitFile,
    HEALTH_URL: 'http://127.0.0.1:1/health',
    HEALTH_TIMEOUT: '1',
    HEALTH_CONFIRM_TIMEOUT: '2'
  }
  writeFileSync(
    join(runDir, 'scripts', 'app.env'),
    Object.entries(env)
      .map(([k, v]) =>
        k === 'ASSET_PATTERN' || k === 'APP_VERSION_CMD' ? `${k}=${v}` : `${k}="${v}"`
      )
      .join('\n') + '\n'
  )

  const healthBody = JSON.stringify({
    status: 'ok',
    version: '0.8.0',
    checks: { database: 'ok', embedder: 'ok' }
  })
  return { root, origin, stubsDir, installDir, runDir, privLog, unitFile, healthBody, guard }
}

// ════════════════════════════════════════════════════════════════════════════
//  updater.sh in DIST=binary — the two changes ADR 0001 §2.9 sanctions and
//  nothing else: the DIST guard must accept `binary`, and the version read must
//  prefer app_version. The frozen contract (path, flags, exit codes,
//  stable-only) is untouched and still asserted by the suites above.
// ════════════════════════════════════════════════════════════════════════════

describe('update.sh — source mode delivers the restart policy surgically', () => {
  // Production is DIST=source (ExecStart=bun run start), so refresh_unit()'s
  // source-mode early return — correct while the rendered body was identical for
  // source mode — made the template's Restart= line undeliverable to exactly the
  // hosts that had the outage. ensure_restart_policy() therefore rewrites ONE
  // line in place instead of re-rendering, which is what keeps the hand-edit
  // invariant of the block above intact.

  const HAND_EDITED = [
    '[Unit]',
    'Description=Synaptomind — thought-graph engine (v0.7.1)',
    'Group=opencode',
    'StartLimitIntervalSec=60',
    '',
    '[Service]',
    'Type=simple',
    'Environment=HAND_EDITED=yes',
    'ExecStart=/usr/local/bin/bun run start',
    'Restart=on-failure',
    'RestartSec=5',
    '',
    '[Install]',
    'WantedBy=multi-user.target',
    ''
  ].join('\n')

  test('a source update rewrites only the Restart= line and keeps every hand edit', () => {
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)

      const unit = readFileSync(fx.unitFile, 'utf8')
      expect(unit).toContain('\nRestart=always\n')
      expect(unit).not.toContain('Restart=on-failure')
      // The whole point of the surgical path: the operator's unit survives.
      expect(unit).toContain('Description=Synaptomind — thought-graph engine (v0.7.1)')
      expect(unit).toContain('Group=opencode')
      expect(unit).toContain('Environment=HAND_EDITED=yes')
      expect(unit).toContain('ExecStart=/usr/local/bin/bun run start')
      // Exactly one Restart directive — a second one would win in systemd.
      expect(unit.split('\n').filter(l => /^Restart=/.test(l))).toHaveLength(1)
      expect(res.stdout).toContain('now carries Restart=always (was: Restart=on-failure)')
      // systemd must read it, and only then does the restart apply it.
      expect(readFileSync(fx.privLog, 'utf8')).toContain('daemon-reload')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a unit already on Restart=always is not rewritten and does not daemon-reload', () => {
    // Idempotency: an update that changed nothing must not spend a privileged
    // write plus a daemon-reload on every run.
    const fx = seedSourceUpdate()
    try {
      const already = HAND_EDITED.replace('Restart=on-failure', 'Restart=always')
      writeFileSync(fx.unitFile, already)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(already)
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('daemon-reload')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a unit with no Restart= line is left byte-identical and the remedy is named', () => {
    // Nothing is ever INSERTED: where the directive belongs is not something to
    // guess, and a comment-only unit must survive untouched. This is the same
    // invariant the "source mode leaves the unit alone" block asserts, reached
    // through a real update instead of an aborted one.
    const fx = seedSourceUpdate()
    try {
      const unit = '# hand-edited source unit\n[Service]\nExecStart=/usr/local/bin/bun run start\n'
      writeFileSync(fx.unitFile, unit)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(unit)
      expect(res.stderr).toContain('it declares no Restart= line')
      expect(res.stderr).toContain('Restart=always')
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('daemon-reload')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a unit that cannot be rewritten warns and still lets the update succeed', () => {
    // Never fatal, unlike refresh_unit() in binary mode: an undelivered restart
    // policy leaves a running service, whereas failing here would block updates
    // on an otherwise healthy host.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      writeFileSync(join(fx.stubsDir, 'sudo'), makeGuardedSudo(fx.guard, { refuse: ['cp'] }))
      chmodSync(join(fx.stubsDir, 'sudo'), 0o755)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stdout).toContain('Done. Now at 0.8.0.')
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(HAND_EDITED)
      expect(res.stderr).toContain('Restart policy unchanged')
      expect(res.stderr).toContain('Restart=on-failure')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a unit with no Restart= line keeps the invariant its name claims', () => {
    // The case the deleted "source mode leaves the unit alone" block used to
    // stand for, but it asserted only that the update exits 0 — it passed
    // because its fixture unit had no Restart= line, so it could not have
    // detected a re-render. Here the unit is a REAL one that lacks the
    // directive, so the bytes on disk after the run are the claim.
    const fx = seedSourceUpdate()
    try {
      const unit = '# hand-edited source unit\n[Service]\nExecStart=/usr/local/bin/bun run start\n'
      writeFileSync(fx.unitFile, unit)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stdout).toContain('Done. Now at 0.8.0.')
      // Nothing was ever inserted: where the directive belongs is not guessed.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(unit)
      expect(res.stderr).toContain('it declares no Restart= line')
      const log = readFileSync(fx.privLog, 'utf8')
      expect(log).not.toContain('daemon-reload')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })
})

// ════════════════════════════════════════════════════════════════════════════
//  The write to the LIVE unit: atomic, recoverable, and honest about the file
//  on disk. Every test here reproduced a defect in the first version of
//  ensure_restart_policy (d9ff4bb, review of #1082) — a truncated unit reported
//  as unchanged, a widened mode, an unreadable unit misreported, and a
//  last-wins duplicate that skipped the update silently.
// ════════════════════════════════════════════════════════════════════════════

describe('update.sh — the restart-policy write is atomic and honest', () => {
  const HAND_EDITED = [
    '[Unit]',
    'Description=Synaptomind — thought-graph engine (v0.7.1)',
    'Group=opencode',
    'StartLimitIntervalSec=60',
    '',
    '[Service]',
    'Type=simple',
    'Environment=HAND_EDITED=yes',
    'ExecStart=/usr/local/bin/bun run start',
    'Restart=on-failure',
    'RestartSec=5',
    '',
    '[Install]',
    'WantedBy=multi-user.target',
    ''
  ].join('\n')

  /**
   * A `cp` that dies after truncating its destination, the way cp(1) behaves
   * when the write fails with ENOSPC/EIO or the process is killed mid-copy.
   * Every other cp in the run passes through, so only the unit write fails.
   * Installed INTO the fixture root (not the repo), never system-wide.
   */
  function breakCpForTheUnit(fx: ReturnType<typeof seedSourceUpdate>): void {
    const real = spawnSync('bash', ['-c', 'command -v cp'], { encoding: 'utf8' }).stdout.trim()
    const p = join(fx.stubsDir, 'cp')
    writeFileSync(
      p,
      [
        '#!/usr/bin/env bash',
        // Every cp aimed at the unit's own directory dies after truncating:
        // the staged body and the .bak copy. Every other cp passes through, so
        // the rest of the update still runs for real.
        `dst="\${@: -1}"; src="\${@: -2:1}"`,
        `case "$dst" in ${dirname(fx.unitFile)}/*) ;; *) exec ${real} "$@" ;; esac`,
        ': > "$dst"                     # cp(1) opens the destination O_TRUNC...',
        'head -c 24 "$src" > "$dst"     # ...and only part of the payload lands',
        'echo "cp: error writing $dst: No space left on device" >&2',
        'exit 1'
      ].join('\n')
    )
    chmodSync(p, 0o755)
  }

  test('a copy that dies partway leaves the live unit intact and reports the real state', () => {
    // The counterexample from the review: the unit went 711 -> 24 bytes while
    // stderr said "Restart policy unchanged ... it still says
    // Restart=on-failure" — both halves false, the file no longer contained
    // Restart= at all, and the staged good copy was then deleted.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      breakCpForTheUnit(fx)
      const res = runUpdate(fx, ['--yes'])
      // Never fatal: a running service beats a blocked update.
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stdout).toContain('Done. Now at 0.8.0.')
      // The unit is BYTE-IDENTICAL: not truncated, not half-written.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(HAND_EDITED)
      // And the message describes that file, not the one we meant to write.
      expect(res.stderr).toContain('Restart policy unchanged')
      expect(res.stderr).toContain('Restart=on-failure')
      expect(res.stderr).toContain('was NOT touched')
      // systemd must not be told to reload a unit that did not change.
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('daemon-reload')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a successful rewrite keeps the previous unit as <unit>.bak', () => {
    // The binary path keeps a `saved=` artifact for exactly this case
    // (refresh_unit); a plain re-run cannot fix a bad refresh because main()'s
    // "already up to date" guard returns first, so the operator needs the old
    // file as a file.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(readFileSync(fx.unitFile, 'utf8')).toContain('\nRestart=always\n')
      const backup = `${fx.unitFile}.bak`
      expect(existsSync(backup), 'the pre-change unit must be recoverable').toBe(true)
      expect(readFileSync(backup, 'utf8')).toBe(HAND_EDITED)
      expect(res.stdout).toContain('previous unit kept at')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a hand-edited unit keeps its mode instead of being widened to 644', () => {
    // cp -f over an EXISTING file does not change its mode, so the old chmod 644
    // bought nothing and only ever widened a unit that may carry
    // Environment= secrets — which is the case this function exists to serve.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      chmodSync(fx.unitFile, 0o600)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(readFileSync(fx.unitFile, 'utf8')).toContain('\nRestart=always\n')
      expect(statSync(fx.unitFile).mode & 0o777).toBe(0o600)
      // The recovery copy is a copy of the operator's file, mode and all.
      expect(statSync(`${fx.unitFile}.bak`).mode & 0o777).toBe(0o600)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('an unreadable unit is reported as unreadable, not as declaring no Restart= line', () => {
    // The greps used to swallow their errors (2>/dev/null, || true), so a
    // mode-000 unit produced an empty result and was reported as declaring no
    // Restart= line — with a remedy (edit the unit) the operator cannot apply
    // for the very permission reason that made it unreadable.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      chmodSync(fx.unitFile, 0o000)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stderr).toContain('could not be READ')
      // The distinction the fix exists for: a read FAILURE is not an ABSENCE.
      expect(res.stderr).not.toContain('it declares no Restart= line')
      expect(res.stderr).not.toContain("add 'Restart=always' under [Service]")
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('daemon-reload')
      chmodSync(fx.unitFile, 0o644)
      // Nothing was written, so the file is still the operator's byte for byte.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(HAND_EDITED)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a duplicate Restart= line is judged on the EFFECTIVE one (last wins)', () => {
    // The idempotency grep matched Restart=anywhere=always, so a unit with
    // `Restart=always` followed by `Restart=no` exited 0 silently: no write, no
    // warning, and the effective policy stayed `no` — the exact outage shape
    // this change exists to prevent.
    const fx = seedSourceUpdate()
    try {
      const dup = HAND_EDITED.replace('Restart=on-failure', 'Restart=always\nRestart=no')
      writeFileSync(fx.unitFile, dup)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      const unit = readFileSync(fx.unitFile, 'utf8')
      // Collapsed onto the policy we want, so last-wins cannot pick `no`.
      expect(unit.split('\n').filter(l => /^Restart=/.test(l))).toEqual(['Restart=always'])
      // The message names the effective directive it replaced.
      expect(res.stdout).toContain('was: Restart=no')
      expect(readFileSync(fx.privLog, 'utf8')).toContain('daemon-reload')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('an already-correct unit is left byte-identical with no .bak and no reload', () => {
    const fx = seedSourceUpdate()
    try {
      const already = HAND_EDITED.replace('Restart=on-failure', 'Restart=always')
      writeFileSync(fx.unitFile, already)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(already)
      // Idempotent means no privileged write at all: no backup, no reload.
      expect(existsSync(`${fx.unitFile}.bak`)).toBe(false)
      expect(readFileSync(fx.privLog, 'utf8')).not.toContain('daemon-reload')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('every rewrite leaves exactly one Restart= line, hand edits intact', () => {
    // The surgical invariant, on a unit whose edits must all survive: the
    // rewrite is a rename onto a sed'd body, so nothing else can drift.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      const unit = readFileSync(fx.unitFile, 'utf8')
      expect(unit.split('\n').filter(l => /^Restart=/.test(l))).toEqual(['Restart=always'])
      for (const kept of [
        'Description=Synaptomind — thought-graph engine (v0.7.1)',
        'Group=opencode',
        'Environment=HAND_EDITED=yes',
        'ExecStart=/usr/local/bin/bun run start',
        'StartLimitIntervalSec=60'
      ]) {
        expect(unit, kept).toContain(kept)
      }
      // Only the Restart= line changed, byte for byte.
      expect(unit.replace('Restart=always', 'Restart=on-failure')).toBe(HAND_EDITED)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('no staging file is left in the unit directory', () => {
    // The staged copy lives BESIDE the unit (a rename only stays atomic within
    // one filesystem), so it must not survive the run: systemd scans that
    // directory, and a leftover .synaptomind.service.new.* is litter that the
    // next update would trip over.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      const leftovers = readdirSync(dirname(fx.unitFile)).filter(f => f.includes('.new.'))
      expect(leftovers).toEqual([])
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  // ── task #1094: the same block, for the two paths #1092's diff never
  // reached, plus the staging-file hazards its own fix left behind. Each test
  // below reproduces a counterexample measured against cc15c74; the same
  // fixture now serves as the direction control for the new code.
  //
  // The staging file is a dot-file whose name systemd never loads, and it now
  // lives in THREE places: the unit swap, the unit's .bak recovery copy (which
  // is itself written through the same atomic path, so a .bak is never a
  // truncated file), and — via the shared mechanism — both binary-mode writes.
  const SECRETED = [
    '[Unit]',
    'Description=Synaptomind — thought-graph engine (v0.7.1)',
    '',
    '[Service]',
    'Type=simple',
    'Environment=SYNAPTOMIND_API_TOKEN=super-secret-value',
    'ExecStart=/usr/local/bin/bun run start',
    'Restart=on-failure',
    '',
    '[Install]',
    'WantedBy=multi-user.target',
    ''
  ].join('\n')

  /** Every leftover staging file in the unit directory, with its mode. */
  function stagedLeftovers(unit: string): { name: string; mode: number; body: string }[] {
    return readdirSync(dirname(unit))
      .filter(f => f.includes('.new.'))
      .map(name => {
        const p = join(dirname(unit), name)
        return { name, mode: statSync(p).mode & 0o777, body: readFileSync(p, 'utf8') }
      })
  }

  test('a FAILED stage leaves no partial staging file in the unit directory', () => {
    // cc15c74's failure branch removed only $tmpdir, so a partial
    // .synaptomind.service.new.$$ survived: the reviewer's run left 6 of them,
    // one per update.sh process, and systemd logged "Failed to prepare filename
    // ... Invalid argument" for each. The old test above covers only the success
    // path — a green suite proved nothing about the branch that actually fails.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, HAND_EDITED)
      breakCpForTheUnit(fx)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      // The stage FAILED (cp died), and the directory is still clean.
      expect(stagedLeftovers(fx.unitFile), 'a failed stage must clean up after itself').toEqual([])
      expect(readdirSync(dirname(fx.unitFile))).toEqual([`${U_APP}.service`])
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a partial stage of a 600 unit never exposes Environment= secrets', () => {
    // The optional security finding from the #1093 re-review, reproduced with
    // the shape it needs: a 600 unit carrying a secret, a write that dies
    // partway. cc15c74 staged with `cp -p` from an awk redirect, so the staging
    // file inherited 644 (umask 022) and `chmod "$mode"` ran only AFTER a
    // successful stage — leaving the first bytes of a root-only unit readable by
    // anyone, in /etc/systemd/system in production. The pre-fix code leaked it
    // too; only this shape exposes it.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, SECRETED)
      chmodSync(fx.unitFile, 0o600)
      breakCpForTheUnit(fx)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      for (const leftover of stagedLeftovers(fx.unitFile)) {
        // Nothing is left at all (the stronger property), and if a future
        // change ever does leave a fragment it must not be group/other-readable.
        expect(leftover.mode & 0o077, `${leftover.name} must not be world-readable`).toBe(0)
        expect(leftover.body).not.toContain('super-secret-value')
      }
      // The live unit is byte-identical, and still 600.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(SECRETED)
      expect(statSync(fx.unitFile).mode & 0o777).toBe(0o600)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a successful rewrite keeps a 600 unit at 600 and secrets out of the .bak', () => {
    // The success path of the same property: the mode is the OPERATOR's, the
    // .bak is a copy of their file (mode and all), and neither is widened.
    const fx = seedSourceUpdate()
    try {
      writeFileSync(fx.unitFile, SECRETED)
      chmodSync(fx.unitFile, 0o600)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      const unit = readFileSync(fx.unitFile, 'utf8')
      expect(unit).toContain('\nRestart=always\n')
      expect(statSync(fx.unitFile).mode & 0o777).toBe(0o600)
      const backup = `${fx.unitFile}.bak`
      expect(readFileSync(backup, 'utf8')).toBe(SECRETED)
      expect(statSync(backup).mode & 0o777).toBe(0o600)
      expect(stagedLeftovers(fx.unitFile)).toEqual([])
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a symlinked unit is written THROUGH, not replaced by a regular file', () => {
    // The third #1093 finding, and a deliberate decision rather than an
    // accident: `systemctl link` puts a symlink in /etc/systemd/system, and the
    // operator edits the real file elsewhere. cc15c74's `mv -f` replaced the
    // LINK with a regular file — silently changing the unit's shape, orphaning
    // the file systemd was NOT loading — and `stat -c %a` without -L reported
    // the link's own mode (777, a symlink always is), so the resulting unit
    // landed world-writable and systemd warned about it. DECIDED: follow the
    // link and swap the file it points at; the link survives.
    //
    // Production's unit is a regular file (verified), so this is about not
    // surprising an operator who linked one.
    const fx = seedSourceUpdate()
    const real = join(fx.root, 'linked', `${U_APP}.service`)
    try {
      mkdirSync(join(fx.root, 'linked'), { recursive: true })
      writeFileSync(real, HAND_EDITED)
      chmodSync(real, 0o600)
      // seedSourceUpdate() does not pre-create the unit, so rmSync needs force.
      rmSync(fx.unitFile, { force: true })
      symlinkSync(real, fx.unitFile)

      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      // The link is still a link...
      expect(lstatSync(fx.unitFile).isSymbolicLink(), 'the link must survive the rewrite').toBe(
        true
      )
      // ...and the update landed in the file it points at.
      expect(readFileSync(real, 'utf8')).toContain('\nRestart=always\n')
      // Mode is the TARGET's (600), not the link's own 777.
      expect(statSync(real).mode & 0o777).toBe(0o600)
      // And the operator is told their link was followed, not silently ignored.
      expect(res.stdout).toContain('is a link; wrote through it to')
      // A .bak beside the link is a plain file (nothing linked it), and its mode
      // is the unit's own — not 777.
      const backup = `${fx.unitFile}.bak`
      expect(lstatSync(backup).isSymbolicLink()).toBe(false)
      expect(statSync(backup).mode & 0o777).toBe(0o600)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })

  test('a dangling link is not silently replaced by a regular file', () => {
    // The corner the follow-the-link decision has to answer. write_file_atomically
    // replaces a DANGLING link on purpose — there is no file to write through
    // and nothing that could be reading it — and says so. This path never
    // reaches it: ensure_restart_policy returns early on a unit it cannot READ,
    // and a dangling link is exactly that. The link must still be there.
    const fx = seedSourceUpdate()
    try {
      mkdirSync(join(fx.root, 'linked'), { recursive: true })
      rmSync(fx.unitFile, { force: true })
      symlinkSync(join(fx.root, 'linked', 'gone.service'), fx.unitFile)

      const res = runUpdate(fx, ['--yes'])
      expect(res.status, res.stderr + res.stdout).toBe(0)
      expect(res.stdout).not.toContain('wrote through it to')
      expect(
        lstatSync(fx.unitFile).isSymbolicLink(),
        'a dangling link must be reported, not reshaped'
      ).toBe(true)
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
      rmSync(fx.origin, { recursive: true, force: true })
    }
  })
})

// ════════════════════════════════════════════════════════════════════════════
//  The binary-mode write: `cp -f "$tmp" "$unit" && run_root chmod 644 "$unit"`
//  (update.sh refresh_unit) — the SAME O_TRUNC-over-the-live-unit pattern and
//  the same forced 644 that was a blocker in #1092, at an entry point outside
//  that diff. app.env ships DIST="binary", so a fresh 0.9.0 install lands here
//  and so does every binary-mode update.
// ════════════════════════════════════════════════════════════════════════════

describe('update.sh — the binary-mode unit refresh is atomic too', () => {
  /** A pre-binary unit: hand-edited, with a secret, at mode 600. */
  const STALE_SECRETED = [
    '# stale pre-binary unit, hand-edited',
    '[Unit]',
    'Description=Synaptomind — thought-graph engine (v0.7.1)',
    '',
    '[Service]',
    'Type=simple',
    'Environment=SYNAPTOMIND_API_TOKEN=super-secret-value',
    `ExecStart=/opt/synaptomind/synaptomind`,
    'Restart=on-failure',
    '',
    '[Install]',
    'WantedBy=multi-user.target',
    ''
  ].join('\n')

  /**
   * A `cp` that dies after truncating its destination, aimed at the unit
   * directory only, so the payload swap and the rest of the update still run for
   * real. Installed into the fixture's stubs, never system-wide.
   */
  function breakCpForTheUnitDir(fx: ReturnType<typeof seedBinaryUpdate>): void {
    const real = spawnSync('bash', ['-c', 'command -v cp'], { encoding: 'utf8' }).stdout.trim()
    writeFileSync(
      join(fx.stubsDir, 'cp'),
      [
        '#!/usr/bin/env bash',
        `dst="\${@: -1}"; src="\${@: -2:1}"`,
        `case "$dst" in ${dirname(fx.unitFile)}/*) ;; *) exec ${real} "$@" ;; esac`,
        ': > "$dst"                     # cp(1) opens the destination O_TRUNC...',
        'head -c 24 "$src" > "$dst"     # ...and only part of the payload lands',
        'echo "cp: error writing $dst: No space left on device" >&2',
        'exit 1'
      ].join('\n')
    )
    chmodSync(join(fx.stubsDir, 'cp'), 0o755)
  }

  test('a copy that dies partway leaves the installed unit byte-identical', () => {
    // The counterexample, measured: the 265 B unit became 24 B ("[Unit]" /
    // "Description=Synap") while the message said the unit was "left unchanged"
    // and pointed the operator at a remedy. The refresh is fatal in binary mode,
    // so the run must still abort — but it must abort with the operator's unit
    // intact and the message true.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, STALE_SECRETED)
      chmodSync(fx.unitFile, 0o600)
      breakCpForTheUnitDir(fx)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(1)
      expect(res.stdout).not.toContain('Done.')
      // The unit is NOT a 24-byte stub any more.
      expect(readFileSync(fx.unitFile, 'utf8')).toBe(STALE_SECRETED)
      expect(statSync(fx.unitFile).mode & 0o777).toBe(0o600)
      // No staging litter in the unit directory either.
      expect(readdirSync(dirname(fx.unitFile)).filter(f => f.includes('.new.'))).toEqual([])
      // The payload swap still happened; only the unit refresh failed.
      expect(readFileSync(join(fx.installDir, 'vec0.so'), 'utf8')).toBe('new vec0\n')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('a successful refresh keeps the replaced unit as <unit>.bak', () => {
    // The rendered unit replaces a hand-edited one on a binary refresh, so the
    // body it replaces is kept: main()'s "Already up to date" guard returns
    // before refresh_unit is reached again, so a plain re-run cannot fix a bad
    // refresh. The .bak is written through the same atomic path, so it is a
    // complete copy rather than a fragment.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    try {
      writeFileSync(fx.unitFile, STALE_SECRETED)
      chmodSync(fx.unitFile, 0o600)
      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(0)
      const unit = readFileSync(fx.unitFile, 'utf8')
      expect(unit).toContain(`Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`)
      // The old 644 is not imposed on a 600 unit, and the recovery copy keeps
      // the operator's file, mode and all.
      expect(statSync(fx.unitFile).mode & 0o777).toBe(0o600)
      const backup = `${fx.unitFile}.bak`
      expect(readFileSync(backup, 'utf8')).toBe(STALE_SECRETED)
      expect(statSync(backup).mode & 0o777).toBe(0o600)
      expect(readdirSync(dirname(fx.unitFile)).filter(f => f.includes('.new.'))).toEqual([])
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })

  test('a symlinked unit is written through, not replaced at mode 777', () => {
    // Same decision as in the source-mode block, on the path a binary install
    // takes. Measured against cc15c74: the link became a regular file and the
    // unit landed mode 777 (stat -c '%a' without -L reports the link's own
    // mode), which systemd warns about as world-writable.
    const fx = seedBinaryUpdate({ currentVersion: 'v0.7.1', targetVersion: '0.8.0' })
    const real = join(fx.root, 'linked', `${U_APP}.service`)
    try {
      mkdirSync(join(fx.root, 'linked'), { recursive: true })
      writeFileSync(real, STALE_SECRETED)
      // seedSourceUpdate() does not pre-create the unit, so rmSync needs force.
      rmSync(fx.unitFile, { force: true })
      symlinkSync(real, fx.unitFile)

      const res = runUpdate(fx, ['--yes'])
      expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(0)
      expect(lstatSync(fx.unitFile).isSymbolicLink(), 'the link must survive the refresh').toBe(
        true
      )
      expect(readFileSync(real, 'utf8')).toContain(
        `Environment=LD_LIBRARY_PATH=${fx.installDir}/lib`
      )
      // The target's own mode, never the link's 777.
      expect(statSync(real).mode & 0o777).toBe(0o644)
      expect(res.stdout).toContain('is a link; wrote through it to')
    } finally {
      rmSync(fx.root, { recursive: true, force: true })
    }
  })
})

describe('updater.sh — DIST=binary bootstrap', () => {
  test('the DIST guard accepts binary (it used to reject everything but source)', () => {
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeTrackingUpdateSh(),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo, overrides: { DIST: 'binary' } })
    const artifact = join(FIXTURE_DIR, 'args.txt')
    const res = runUpdater(['--yes'], undefined, { UPDATER_ARTIFACT: artifact })
    expect(res.stderr).not.toContain('updater supports DIST=source only')
    expect(res.status, res.stderr + '\n' + res.stdout).toBe(0)
    expect(readFileSync(artifact, 'utf8')).toContain('v0.8.0')
  })

  test('an unknown DIST is still refused, naming both valid values', () => {
    const repo = seedSingleTagRepo('v0.8.0', {
      'deploy/update.sh': makeTrackingUpdateSh(),
      'deploy/lib/common.sh': MINIMAL_COMMON_SH,
      'package.json': '{"name":"synaptomind","version":"0.7.0"}'
    })
    setupBootstrap({ repoUrl: 'file://' + repo, overrides: { DIST: 'tarball' } })
    const res = runUpdater(['--yes'])
    expect(res.status).toBe(1)
    expect(res.stderr).toContain('DIST=source or DIST=binary')
  })

  test('current_version prefers app_version for a binary install (offline, no HTTP)', () => {
    // app_version returns a v-PREFIXED string; the mark and the printed messages
    // must strip it, or the current version never matches in the menu.
    const fakeInstall = mkTempTree('synapto-binver-')
    writeFileSync(join(fakeInstall, 'synaptomind'), '#!/bin/sh\necho "synaptomind v0.7.4"\n')
    chmodSync(join(fakeInstall, 'synaptomind'), 0o755)

    // current_version() is updater.sh's own, so probe the REAL function instead of
    // a copy: extract it from the real script by sourcing a prefix of it.
    const realUpdater = readFileSync(join(import.meta.dir, 'updater.sh'), 'utf8')
    const fnStart = realUpdater.indexOf('current_version() {')
    const fnEnd = realUpdater.indexOf('\n}\n', fnStart) + 3
    expect(fnStart, 'current_version() not found in updater.sh').toBeGreaterThan(-1)
    const script = join(FIXTURE_DIR, 'probe.sh')
    const helper = join(FIXTURE_DIR, 'common-with-app-version.sh')
    writeFileSync(helper, MINIMAL_COMMON_SH_WITH_APP_VERSION)
    // probe.sh: source the helpers, define the REAL current_version, run it.
    writeFileSync(
      script,
      [
        '#!/usr/bin/env bash',
        'set -euo pipefail',
        'APP_NAME="synaptomind"',
        'DIST="binary"',
        `APP_VERSION_CMD='\${BIN} --version'`,
        'HEALTH_URL="http://127.0.0.1:1/health"',
        '. "$1"',
        'INSTALL_DIR="$2"',
        realUpdater.slice(fnStart, fnEnd),
        'CURRENT="$(current_version)"',
        'printf \'CURRENT=%s\\n\' "$CURRENT"',
        // The menu marker compares a v-stripped tag against the raw value.
        'printf \'MENU=%s\\n\' "$([ "${CURRENT#v}" = "0.7.4" ] && echo yes || echo no)"',
        // The old comparison, unnormalised, must NOT match — that is the bug.
        'printf \'OLD_MARKER=%s\\n\' "$([ "0.7.4" = "$CURRENT" ] && echo yes || echo no)"'
      ].join('\n')
    )
    const res = spawnSync('bash', [script, helper, fakeInstall], {
      encoding: 'utf8'
    })
    expect(res.status, res.stderr).toBe(0)
    // No HTTP fallback needed: the binary answered, v-prefixed.
    expect(res.stdout).toContain('CURRENT=v0.7.4')
    // ${CURRENT#v} matches a v-stripped tag (the fixed marker)…
    expect(res.stdout).toContain('MENU=yes')
    // …while the old unnormalised comparison does not — that was the bug.
    expect(res.stdout).toContain('OLD_MARKER=no')
    rmSync(fakeInstall, { recursive: true, force: true })
  })
})

/** MINIMAL_COMMON_SH plus app_version — what current_version() needs in binary mode. */
const MINIMAL_COMMON_SH_WITH_APP_VERSION =
  MINIMAL_COMMON_SH +
  '\n' +
  [
    'app_version() {',
    '  local bin="$1" out cmd',
    '  cmd="${APP_VERSION_CMD:-\\${BIN} --version}"',
    '  out="$(BIN="$bin" sh -c "$cmd" 2>/dev/null || true)"',
    '  out="${out%%$\'\\n\'*}"',
    '  printf \'%s\' "${out#"${APP_NAME}" }"',
    '}'
  ].join('\n')

// ════════════════════════════════════════════════════════════════════════════
//  update.sh: FOUR ownership calls around the source-mode fetch, in the order
//  the residue needs — three inside update_source() and one in main()
//
//  THE RESIDUE. #1114 left /opt/subagentix with 387 root-owned entries under a
//  top directory that read opencode:opencode — .git/index among them. update.sh
//  is run by the OPERATOR, not as root, so on such a tree `git fetch` fails with
//  an EACCES on an index it cannot write. update.sh runs under `set -euo
//  pipefail`, so that failure ABORTS the run — which means the ownership calls
//  that come after the fetch are never reached. Without a call BEFORE the fetch,
//  the run is killed by the very residue it exists to heal, and the operator
//  cannot recover by re-running.
//
//  So the ordering IS the fix, and it is asserted here as an ORDER over a real
//  run rather than as a count of calls: a test that only counted `apply_ownership`
//  invocations would be satisfied by all four in the wrong place, or by one.
//
//  HOW THE ORDER IS OBSERVED. Each phase writes a line to ONE shared log, from
//  a stub on PATH, so the sequence is read off the file the run produced:
//    - the `git` stub logs `git <args>` and FORWARDS to the real binary;
//    - apply_ownership reaches `find` on every call (ownership_mismatch walks
//      the tree whenever the top directory matches), so a `find` stub logs
//      `apply_ownership`;
//    - the build is a real BUILD_CMD the fixture installs, which logs itself.
//
//  The mutations at the end remove each pre-fetch call IN TURN from a COPY of
//  update.sh (never the repo's file) and re-run the identical fixture: the
//  residue must then reach the fetch and the run must die there. Two of them,
//  because there are two pre-fetch calls — main()'s before the run's first write
//  (#1139, update.sh:868) and update_source()'s before its own (#1136, :687) —
//  and a proof aimed at only one of them says nothing about the other. Without
//  both, the shipped ordering could be replaced by calls in the wrong order (or
//  with one of the two deleted) and this block would stay green.
// ════════════════════════════════════════════════════════════════════════════

/**
 * A group the runner belongs to that is NOT its own primary group, or null.
 *
 * The same mechanism ownership.test.ts uses, and for the same reason: an
 * unprivileged process cannot make a file belong to another USER, but it CAN
 * chgrp one of its own files to a group it is a member of, and a setgid
 * directory propagates that group to the files created inside it. That is
 * enough to build the #1114 shape — a subtree whose GROUP disagrees with the
 * target's while the top directory agrees — which is what
 * ownership_mismatch's `find` walk looks for.
 */
function sourceSecondaryGroup(): { name: string; gid: number } | null {
  const out = (argv: string[]) =>
    (spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', timeout: 10_000 }).stdout ?? '').trim()
  const mine = out(['id', '-g'])
  for (const gid of out(['id', '-G']).split(/\s+/).filter(Boolean)) {
    if (gid === mine) continue
    const name = out(['getent', 'group', gid]).split(':')[0]
    if (name) return { name, gid: Number(gid) }
  }
  return null
}

const SOURCE_FOREIGN = sourceSecondaryGroup()

/**
 * The runner's PRIMARY group — the group apply_ownership repairs TO, since
 * TARGET_GROUP defaults to TARGET_USER. Probed rather than read from $GROUP,
 * which a test runner need not export.
 */
const RUNNER_GROUP = (
  spawnSync('bash', ['-c', 'id -gn'], { encoding: 'utf8', timeout: 10_000 }).stdout ?? ''
).trim()

/**
 * plant-residue.sh DIR — make DIR/.git carry a group that is not the target's.
 *
 * `.git` is where the #1114 residue lived (`git fetch` writes there first), and
 * the subtree is a setgid directory chgrp'd to a secondary group, so the file
 * created inside it lands in that group too — a genuine `find ! -group` hit,
 * built with no privilege.
 */
function seedPlantResidue(root: string, group: { name: string; gid: number }): string {
  const script = join(root, 'plant-residue.sh')
  writeFileSync(
    script,
    [
      '#!/usr/bin/env bash',
      'set -eu',
      'd="$1/.git/residue"',
      'mkdir -p "$d"',
      `chgrp ${group.gid} "$d"`,
      'chmod 2775 "$d"',
      ': > "$d/index"'
    ].join('\n') + '\n'
  )
  chmodSync(script, 0o755)
  return script
}

/**
 * The fixture for the ordering tests: seedSourceUpdate() plus stubs that log
 * every phase into one file.
 *
 * `git` forwards to the real binary and MODELLS what a foreign-owned .git does
 * to it: a real git cannot write an index it does not own, and that is an EACCES
 * that aborts the run under `set -e`. The model fires on EVERY fetch, because
 * #1139 gave main() its own apply_ownership immediately before its fetch
 * (update.sh:868) — before it, a residue planted up front killed the run at the
 * FIRST fetch and nothing inside update_source() was ever reached, so gating the
 * model to the second fetch was asserting about a path that had no fix at all.
 * Both pre-fetch calls are covered now, by two separate non-vacuity tests, each
 * removing a different one.
 *
 * Two observations make the claims falsifiable rather than narrated:
 *   * every fetch records the residue's group AT THAT MOMENT (`residue <group>`),
 *     so a test can assert a tree was already repaired before the fetch that
 *     would have died on it — the difference between main()'s call and
 *     update_source()'s, which the end-of-run gid check cannot see;
 *   * PLANT_AFTER_FETCH=1 makes a successful fetch leave residue of its own (a
 *     root-run fetch writes .git as root), which is the only way to reach
 *     update_source()'s fetch with a mismatched tree when main()'s call has
 *     already healed the one planted up front.
 */
function seedOrderedSourceUpdate(group: { name: string; gid: number }) {
  const fx = seedSourceUpdate()
  const order = join(fx.root, 'order.log')
  writeFileSync(order, '')
  const plant = seedPlantResidue(fx.root, group)

  const realGit = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).stdout.trim()
  const realFind = spawnSync('bash', ['-c', 'command -v find'], { encoding: 'utf8' }).stdout.trim()

  writeFileSync(
    join(fx.stubsDir, 'find'),
    [
      '#!/usr/bin/env bash',
      // ownership_mismatch walks the tree on EVERY call once the top directory
      // matches, so this is the observable for "apply_ownership ran here".
      'case " $* " in *" ! -user "*) printf \'apply_ownership %s\\n\' "$1" >> "$ORDER_LOG" ;; esac',
      `exec ${JSON.stringify(realFind)} "$@"`
    ].join('\n')
  )
  writeFileSync(
    join(fx.stubsDir, 'git'),
    [
      '#!/usr/bin/env bash',
      'printf \'git %s\\n\' "$*" >> "$ORDER_LOG"',
      '__verb=""',
      'for a in "$@"; do case "$a" in fetch|checkout) __verb="$a" ;; esac; done',
      '__n=$(cat "$FETCH_COUNT" 2>/dev/null || printf 0)',
      'if [ "$__verb" = fetch ]; then __n=$((__n + 1)); printf \'%s\' "$__n" > "$FETCH_COUNT"; fi',
      // The model, on EVERY fetch (see the note above) — fetches only: a checkout
      // is what the post-checkout call exists to precede, and modelling it here
      // would kill the very phase that reproduces the residue. The residue's group
      // is logged FIRST, so the order file also records what each fetch saw: a walk
      // that repaired the tree shows up as EXPECT_GROUP on the following fetch,
      // which is how the two pre-fetch calls are told apart.
      'if [ "$__verb" = fetch ] && [ -d "$INSTALL_DIR/.git/residue" ]; then',
      '  g=$(stat -c %G "$INSTALL_DIR/.git/residue" 2>/dev/null || echo "?")',
      '  printf \'residue %s\\n\' "$g" >> "$ORDER_LOG"',
      '  if [ "$g" != "$EXPECT_GROUP" ]; then',
      '    printf "fatal: could not write index: %s/.git/residue is %s, not %s (EACCES)\\n" "$INSTALL_DIR" "$g" "$EXPECT_GROUP" >&2',
      '    exit 128',
      '  fi',
      'fi',
      `rc=0; ${JSON.stringify(realGit)} "$@" || rc=$?`,
      'if [ "$__verb" = fetch ] && [ "$rc" -eq 0 ] && [ "${PLANT_AFTER_FETCH:-0}" = 1 ]; then',
      // A fetch that ran as root leaves .git root-owned, so the NEXT fetch is the
      // first one that meets a residue main()'s own call could not have healed.
      `  ${JSON.stringify('bash')} ${JSON.stringify(plant)} "$INSTALL_DIR" >/dev/null 2>&1 || true`,
      'fi',
      'if [ "$__verb" = checkout ] && [ "$rc" -eq 0 ]; then',
      // A checkout rewrites the working tree AND .git, so a root-owned tree is
      // reproduced right here: the post-checkout apply_ownership has something to
      // repair, which is what makes it observable at all.
      `  ${JSON.stringify('bash')} ${JSON.stringify(plant)} "$INSTALL_DIR" >/dev/null 2>&1 || true`,
      'fi',
      'exit $rc'
    ].join('\n')
  )
  chmodSync(join(fx.stubsDir, 'find'), 0o755)
  chmodSync(join(fx.stubsDir, 'git'), 0o755)

  // The build the fixture installs. It logs itself, and it plants residue of its
  // own — a build that ran as root writes root-owned output, which is the third
  // thing the post-build apply_ownership exists to cover.
  setSourceAppEnv(
    fx,
    'BUILD_CMD',
    'printf "build\\n" >> "$ORDER_LOG"; bash "$PLANT_RESIDUE" "$INSTALL_DIR"'
  )
  return { ...fx, order, plant }
}

/**
 * Override one key of the fixture's installed app.env. It is `.`-sourced, so the
 * value is written single-quoted — a double-quoted one would be expanded at
 * source time, which is why the shipped app.env quotes ASSET_PATTERN that way.
 */
function setSourceAppEnv(
  fx: ReturnType<typeof seedSourceUpdate>,
  key: string,
  value: string
): void {
  const envFile = join(fx.runDir, 'scripts', 'app.env')
  const text = readFileSync(envFile, 'utf8')
  const line = new RegExp(`^${key}=.*$`, 'm')
  if (line.test(text)) {
    writeFileSync(envFile, text.replace(line, `${key}='${value.replace(/'/g, `'\\''`)}'`))
    return
  }
  writeFileSync(envFile, `${text}${key}='${value.replace(/'/g, `'\\''`)}'\n`)
}

/** Run the fixture's update.sh with the extra env the stubs read. */
function runOrderedUpdate(
  fx: ReturnType<typeof seedOrderedSourceUpdate>,
  opts: { plantAfterFetch?: boolean } = {}
): { status: number | null; stdout: string; stderr: string } {
  const res = spawnSync('bash', [join(fx.runDir, 'scripts', 'update.sh'), '--yes'], {
    encoding: 'utf8',
    env: isolatedEnv(fx.runDir, {
      RUN_DIR: fx.runDir,
      PATH: `${fx.stubsDir}:${process.env.PATH}`,
      STUB_PRIV_LOG: fx.privLog,
      FAKE_HEALTH_BODY: fx.healthBody,
      ORDER_LOG: fx.order,
      FETCH_COUNT: join(fx.root, 'fetches'),
      PLANT_RESIDUE: fx.plant,
      // A successful fetch leaves residue behind when asked, so update_source()'s
      // own pre-fetch call can be exercised on a residue main()'s call could not
      // have reached (see seedOrderedSourceUpdate).
      PLANT_AFTER_FETCH: opts.plantAfterFetch ? '1' : '0',
      // Exported because update.sh derives INSTALL_DIR from its OWN app.env and
      // does not export it, so a stub that read the variable would resolve it as
      // empty — and `mkdir /.git` fails as Permission denied, which reads like a
      // fixture bug rather than a missing export.
      INSTALL_DIR: fx.installDir,
      // The group the tree is OWNED BY, i.e. the one apply_ownership repairs TO —
      // which is the runner's PRIMARY group (TARGET_GROUP defaults to
      // TARGET_USER), NOT the foreign group the residue was planted in. Naming
      // the foreign one here would make the model fire on a healthy tree and pass
      // on a mismatched one, which is the inverse of the defect.
      EXPECT_GROUP: RUNNER_GROUP
    }),
    timeout: 90_000
  })
  return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }
}

/**
 * The significant phases of a run, in the order the log recorded them.
 *
 * Reduced to the four events the ordering is about — an ownership walk of
 * INSTALL_DIR, a fetch, a checkout, the build — and nothing else. `git tag`,
 * `git rev-parse` and friends are noise here: they are the ref RESOLUTION that
 * sits between the fetch and the checkout, they write nothing, and keeping them
 * would make the assertion about git's internals rather than about the order of
 * the ownership calls.
 */
function orderEvents(log: string, installDir: string): string[] {
  const out: string[] = []
  for (const raw of log.split('\n')) {
    const line = raw.trim()
    if (!line) continue
    if (line.startsWith('apply_ownership ')) {
      // Only walks of INSTALL_DIR: apply_ownership also walks DATA_DIR, and a
      // walk of either is the same call.
      if (line.endsWith(installDir)) out.push('apply_ownership')
      continue
    }
    if (line.startsWith('build')) {
      out.push('build')
      continue
    }
    if (!line.startsWith('git ')) continue
    const verb = /\b(fetch|checkout)\b/.exec(line)?.[1]
    if (verb) out.push(`git ${verb}`)
  }
  return out
}

/**
 * The residue group each fetch MET, in fetch order — or null where there was no
 * residue to meet.
 *
 * The git stub logs `residue <group>` immediately after the `git …` line of the
 * fetch it is modelling, so a residue record belongs to the fetch line above it.
 * This is the observation the end-of-run gid check cannot make: it says what the
 * tree looked like AT each fetch, which is the only thing that distinguishes
 * main()'s pre-fetch call from update_source()'s.
 */
function fetchResidueGroups(log: string): (string | null)[] {
  const out: (string | null)[] = []
  let lastWasFetch = false
  for (const raw of log.split('\n')) {
    const line = raw.trim()
    if (line.startsWith('git ')) {
      lastWasFetch = /\bfetch\b/.test(line)
      if (lastWasFetch) out.push(null)
      continue
    }
    if (line.startsWith('residue ') && lastWasFetch && out.length > 0) {
      out[out.length - 1] = line.slice('residue '.length)
    }
  }
  return out
}

/**
 * Remove ONE of update.sh's two pre-fetch apply_ownership calls from the
 * FIXTURE'S copy of the script.
 *
 * NEVER the repo's file — the mutation exists only to prove the ordering
 * assertion can fail. Throwing when the target text is absent is deliberate
 * (install.sh.test.ts's mutateInstallScript, ownership.test.ts's
 * seedTopDirOnlyOwnership): a reformat must not leave the mutation a silent
 * no-op, which would make the proof vacuous.
 *
 * The two calls are told apart by their indentation and the blank line between
 * the call and its fetch: update_source()'s sits at two spaces inside the
 * function (#1136, update.sh:687), main()'s at four inside the source branch of
 * main() (#1139, update.sh:868). Matching on the surrounding text rather than on
 * the bare call is what keeps each mutation aimed at its own call — the old
 * single-target helper silently became a no-op for the wrong one when #1139
 * landed, which is exactly how a non-vacuity test stops proving anything.
 */
function removePrefetchOwnership(
  fx: ReturnType<typeof seedOrderedSourceUpdate>,
  where: 'main' | 'update_source'
): void {
  const path = join(fx.runDir, 'scripts', 'update.sh')
  const src = readFileSync(path, 'utf8')
  const fetch = 'git -C "$INSTALL_DIR" fetch --tags --force origin'
  const from =
    where === 'update_source'
      ? `  apply_ownership\n\n  ${fetch}\n`
      : `    apply_ownership\n    ${fetch}\n`
  if (!src.includes(from)) {
    throw new Error(
      `mutation target absent from update.sh: ${where}'s pre-fetch apply_ownership moved`
    )
  }
  writeFileSync(path, src.replace(from, `${where === 'update_source' ? '  ' : '    '}${fetch}\n`))
}

describe.skipIf(SOURCE_FOREIGN === null)(
  'update.sh — apply_ownership is ordered around the fetch, the checkout and the build',
  () => {
    const group = SOURCE_FOREIGN!

    test('the ownership calls land before the fetch, after the checkout and after the build', () => {
      // The whole claim, as one sequence. Every element is load-bearing: an
      // apply_ownership after the build but before the fetch would satisfy a
      // count, and it is exactly the arrangement the residue cannot survive.
      const fx = seedOrderedSourceUpdate(group)
      try {
        // The residue #1114 left: a clean checkout whose .git carries a group the
        // service user does not own. Planted through the same helper the git stub
        // uses, so the shape is identical to the one produced mid-run.
        spawnSync('bash', [fx.plant, fx.installDir], { encoding: 'utf8' })
        const before = statSync(join(fx.installDir, '.git', 'residue'), { throwIfNoEntry: false })
        expect(before, 'the fixture must start out mismatched').not.toBeUndefined()

        const res = runOrderedUpdate(fx)

        expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(0)
        expect(res.stdout).toContain('Done. Now at 0.8.0.')

        // The whole sequence, including the phases that are NOT part of the claim,
        // so that a fifth ownership call cannot slip in unnoticed: one in main()
        // before its own fetch (#1139, update.sh:868), three inside update_source()
        // (#1136, :687/:722/:741) and the one the call site makes after the
        // function returns (update.sh:938), which is a different function's
        // business.
        const log = readFileSync(fx.order, 'utf8')
        const events = orderEvents(log, fx.installDir)
        expect(events, 'the phase sequence the run produced').toEqual([
          'apply_ownership',
          'git fetch',
          'apply_ownership',
          'git fetch',
          'git checkout',
          'apply_ownership',
          'build',
          'apply_ownership',
          'apply_ownership'
        ])
        // AND the effect at the moment that matters, which the end-of-run gid check
        // below can no longer distinguish: the FIRST fetch is the one a residue
        // planted up front would kill, so a repaired group there is main()'s call
        // doing its job (#1139), not one of update_source()'s — none of which has
        // run yet at that point.
        expect(fetchResidueGroups(log)[0], 'the first fetch met a mismatched .git').toBe(
          RUNNER_GROUP
        )
        // And the residue the fetches would have died on is repaired by the end.
        expect(
          statSync(join(fx.installDir, '.git', 'residue')).gid,
          'the residue was never repaired'
        ).toBe(statSync(fx.installDir).gid)
      } finally {
        rmSync(fx.root, { recursive: true, force: true })
        rmSync(fx.origin, { recursive: true, force: true })
      }
    }, 120_000)

    test('a tree with no residue still walks the tree on every phase — the calls are unconditional', () => {
      // The other half, and the one that keeps "the pre-fetch calls exist" honest.
      // On a clean tree ownership_mismatch finds nothing and says nothing, so the
      // only observable is the WALK itself; a version that guarded a pre-fetch
      // call behind a mismatch would pass the test above on a residue but silently
      // skip the fetch on a clean tree.
      const fx = seedOrderedSourceUpdate(group)
      try {
        const res = runOrderedUpdate(fx)

        expect(res.status, `stdout:\n${res.stdout}\nstderr:\n${res.stderr}`).toBe(0)
        const events = orderEvents(readFileSync(fx.order, 'utf8'), fx.installDir)
        // FIVE walks of INSTALL_DIR: one in main() before its own fetch (#1139),
        // three inside update_source() and one more at the call site after the
        // function returns (update.sh:938, before the post-update hook). The count
        // is asserted as the exact sequence in the test above rather than as a
        // number there, so the extra call is accounted for rather than absorbed.
        const walks = events.filter(e => e === 'apply_ownership')
        expect(walks).toHaveLength(5)
        // And BOTH pre-fetch walks are genuinely before their own fetch — the first
        // against the FIRST fetch, which is the claim #1139 added. Asserting against
        // the last fetch instead (as this did before #1139) is satisfied by a walk
        // anywhere earlier in the run and would pass with main()'s call deleted.
        expect(events.indexOf('apply_ownership'), 'no walk before the first fetch').toBeLessThan(
          events.indexOf('git fetch')
        )
        const secondFetch = events.indexOf('git fetch', events.indexOf('git fetch') + 1)
        expect(
          events.lastIndexOf('apply_ownership', secondFetch),
          'no walk before the second fetch'
        ).toBeLessThan(secondFetch)
      } finally {
        rmSync(fx.root, { recursive: true, force: true })
        rmSync(fx.origin, { recursive: true, force: true })
      }
    }, 120_000)

    test("non-vacuity: without main()'s pre-fetch call the FIRST fetch dies on the residue", () => {
      // THE PROOF for #1139. The SAME fixture, the SAME planted residue, the SAME
      // git model — run against a copy of update.sh whose main() pre-fetch
      // apply_ownership is gone. That call is the run's first write into
      // INSTALL_DIR, so the FIRST fetch must now fail the way a real git fails on
      // an index it cannot write, and the run must abort there — before the
      // pre-update hook and before update_source() is ever entered, which is
      // exactly the defect #1139 was opened for. If this ever passes, the
      // mutation stopped matching the shipped call and the assertions above prove
      // nothing.
      const fx = seedOrderedSourceUpdate(group)
      try {
        spawnSync('bash', [fx.plant, fx.installDir], { encoding: 'utf8' })
        removePrefetchOwnership(fx, 'main')

        const res = runOrderedUpdate(fx)

        expect(res.status, 'the run reached the end despite an unwritable .git').not.toBe(0)
        expect(res.stdout, 'the checkout ran on a tree the fetch could not write').not.toContain(
          'Checked out'
        )
        expect(res.stderr).toContain('EACCES')
        // It died on the FIRST fetch — no second one was attempted — and the
        // post-checkout and post-build calls were never reached, which is the other
        // half of what an abort costs.
        expect(fetchResidueGroups(readFileSync(fx.order, 'utf8'))).toHaveLength(1)
        expect(orderEvents(readFileSync(fx.order, 'utf8'), fx.installDir)).not.toContain('build')
      } finally {
        rmSync(fx.root, { recursive: true, force: true })
        rmSync(fx.origin, { recursive: true, force: true })
      }
    }, 120_000)

    test("non-vacuity: without update_source()'s own pre-fetch call its fetch dies on a mid-run residue", () => {
      // THE PROOF for #1136, and the one that main()'s call would otherwise have
      // made unfalsifiable: with a residue planted up front, main() heals it, so
      // deleting update_source()'s call changed nothing. Here the residue appears
      // AFTER main()'s call — a fetch that ran as root writes .git as root, which
      // the stub reproduces (PLANT_AFTER_FETCH) — so only update_source()'s own
      // pre-fetch walk stands between that tree and the EACCES.
      //
      // The control run gets its OWN fixture, not a second run over this one: an
      // update consumes the upgrade it performs, so a control run first would leave
      // the mutated run with nothing to do ("Already up to date") and the proof
      // would pass for the wrong reason.
      const control = seedOrderedSourceUpdate(group)
      try {
        const res = runOrderedUpdate(control, { plantAfterFetch: true })
        expect(
          res.status,
          `the planting fixture alone must succeed\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}\norder:\n${readFileSync(control.order, 'utf8')}`
        ).toBe(0)
        expect(res.stdout).toContain('Done. Now at 0.8.0.')
        // Which is only meaningful because update_source()'s call is what healed the
        // residue its own fetch left behind.
        expect(fetchResidueGroups(readFileSync(control.order, 'utf8'))[1]).toBe(RUNNER_GROUP)
      } finally {
        rmSync(control.root, { recursive: true, force: true })
        rmSync(control.origin, { recursive: true, force: true })
      }

      const fx = seedOrderedSourceUpdate(group)
      try {
        removePrefetchOwnership(fx, 'update_source')
        const res = runOrderedUpdate(fx, { plantAfterFetch: true })

        expect(
          res.status,
          `the run reached the end despite an unwritable .git\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}\norder:\n${readFileSync(fx.order, 'utf8')}`
        ).not.toBe(0)
        expect(res.stdout, 'the checkout ran on a tree the fetch could not write').not.toContain(
          'Checked out'
        )
        expect(res.stderr).toContain('EACCES')
        // Died on the SECOND fetch — the first one succeeded and left the residue.
        const seen = fetchResidueGroups(readFileSync(fx.order, 'utf8'))
        expect(seen).toHaveLength(2)
        expect(seen[0], 'the first fetch must have found no residue to die on').toBeNull()
        expect(seen[1], 'the second fetch met a residue nothing had repaired').not.toBe(
          RUNNER_GROUP
        )
        expect(orderEvents(readFileSync(fx.order, 'utf8'), fx.installDir)).not.toContain('build')
      } finally {
        rmSync(fx.root, { recursive: true, force: true })
        rmSync(fx.origin, { recursive: true, force: true })
      }
    }, 120_000)
  }
)