#!/usr/bin/env bash
# Inventory this machine's AI tooling. The entry point for the shipped artifacts.
# macOS and Linux both -- the composed artifact is chosen by `uname -s` below.
#
#     bin/discover                     # base inventory, as one JSON array
#     bin/discover --deep              # base inventory plus a LIVE container scan
#     bin/discover --write PATH        # EVERY branch, independently, into a cache file
#     bin/discover --write PATH --status TXT   # ...and a one-line health summary
#     bin/discover --cache PATH        # emit that file. no queries run, nothing can block
#     bin/discover --deep | bin/classify
#     bin/discover --names             # ONE LINE: the agents found, then the status line
#     bin/discover --names --cache PATH   # the same, from the cache: an Extension Attribute
#
# --names MATCHES ON THE ENDPOINT, which every other mode refuses to do, and it is the one
# mode that should. The JSON is for a collector; a Jamf Extension Attribute is one printed
# value a Smart Group matches on, and a reader with osqueryi and no collector wants a name,
# not a payload. The signatures come from dist/signatures.sql, the catalog rendered into
# SQL by tools/render_match.py and held row for row to bin/classify by
# test/test_match_sql.py. The line carries the status vocabulary after a `; `, so `none`
# and `could not look` are never the same line:
#
#     Claude Code 2.0.1, Cursor 1.7.44; ok 2026-10-05T13:20:11Z rows=775 apps=ok ... containers=absent
#     none; degraded 2026-10-05T13:20:11Z rows=612 ... containers=timeout:6
#
# TWO ARTIFACTS, TWO PROCESSES, AND THAT IS THE POINT.
#
#   dist/discovery-<platform>.sql  every fast and slow query for this platform,
#                                  composed. ~1.2s, cannot block.
#   dist/discovery-deep.sql  the expensive ones. CAN BLOCK FOREVER, so it runs behind a
#                            health guard and a wall-clock kill -- bin/container-scan.
#
# osquery has no per-query timeout (5.23.1: --docker_socket is the only docker flag,
# --alarm_timeout is shutdown-only, --schedule_timeout is osqueryd's schedule), so the only
# bound available is a kill on a process. A kill is indivisible: composed into one file the
# two would share one process, and bounding the container branch would mean discarding the
# 580-odd rows the other branches had already produced. Separate processes is what makes
# the bound cost nothing but the container rows.
#
# WITHOUT --deep NOTHING CHANGES. Same artifact, same single process, same cost as the
# quickstart has always had. The flag is opt-in because the deep path talks to a daemon,
# and a daemon is a dependency the base path deliberately does not have.
#
# A deep failure never costs the base rows. It prints them, plus one kind=scan marker row
# saying why the container half is missing, and still exits 0 -- because the inventory did
# succeed, and because a consumer that records stdout and discards the exit status sees only
# what was printed,
# so the marker row is the only channel the reason has. See bin/container-scan.
set -uo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# THE ARTIFACT IS PER PLATFORM, and picking the wrong one is not a soft failure: the
# tables are disjoint, so a Linux artifact on macOS dies on the first table and prints
# "[\n\n]" -- valid, empty, entirely plausible JSON. It does at least exit non-zero,
# which is the only reason that state is recoverable.
#
# `uname -s` rather than a flag, because the endpoint has no operator to ask. An
# unrecognised platform stops here rather than falling through to a missing file, where
# the message would be about a path instead of about the platform.
case "$(uname -s)" in
  Darwin) PLATFORM=darwin ;;
  Linux)  PLATFORM=linux ;;
  *) echo "discover: unsupported platform $(uname -s). This repo ships darwin and linux;" \
          "Windows is a separate implementation, not a port." >&2; exit 1 ;;
esac
BASE="${DISCOVER_BASE:-$ROOT/dist/discovery-$PLATFORM.sql}"
DEEP_SCAN=0
# --cache reads what a scheduled bin/container-scan --cache wrote, and never talks to a
# daemon. That is the deployment shape to prefer: see bin/container-scan's cache_write.
# --cache emits the file --write produced and RUNS NOTHING. The file already holds every
# branch, so re-running the base query beside it double-counted the whole inventory: 773
# rows read back as 1530. It is `cat` with a parse check, which is the point -- the endpoint
# integration is a file read, and this is the same thing with a diagnostic.
CACHE_PATH=""
WRITE=""
# --status writes ONE plain line, for a second attribute that inventory tooling can actually
# match on. A consumer that compresses this payload for transport cannot see inside the result,
# and a consumer that stores it as one blob cannot query it -- so the payload alone cannot
# answer "show me every machine where the container scan is failing". This can:
#
#   degraded 2026-08-31T17:52:03Z apps=ok browser=ok containers=timeout:6 ...
#
# First token is ok or degraded, so a group is `like "degraded"`; per-branch tokens let a
# group narrow to `like "containers=timeout"`.
#
# `absent` DOES NOT DEGRADE. It is a stated fact about the machine -- no Docker, no systemd
# bus -- not a branch that failed to look, and the two need different responses: one is
# nothing to do, the other is someone's morning. Rolling them together makes the first token
# fire on every machine that simply does not run Docker, which is the fastest way to teach a
# fleet to ignore it. A branch that could not look still says so in its own token, so
# narrowing to `like "containers="` loses nothing.
STATUS=""
# One process per branch, for --write only. The composed artifact exists to save process
# startup when something is WAITING; a scheduled writer has nobody waiting, and paying ~0.5s
# per branch buys the reliability below. MEASURED, and it is not a small difference:
#
#   one branch's table unavailable, as on an older osquery:
#     composed, one statement ..... 0 rows, exit 1, stdout "[\n\n]" -- five bytes of
#                                   VALID EMPTY JSON. One missing table and the machine
#                                   reports as though nothing is installed on it.
#     independent ................. 6 of 6 branches, 757 rows, the bad one contributes 0
#
# Every query declares `min-osquery: 5.10.0`, so a table being absent is a real deployment
# state and not a hypothetical. Independent execution also gives each branch its own bound
# and its own scan row, so "we could not look" is per branch instead of all-or-nothing.
# Overridable so test/docker_states.sh can hand this a pack containing a deliberately
# broken query and assert the other branches still report. That assertion is the whole
# justification for running branches apart, and it should not live only in CI.
PACK="${DISCOVER_PACK:-$ROOT/dist/ai-discovery.conf}"
# An array, so a socket path with a space in it survives. Appended to with `+=` and
# expanded with the ${a[@]+...} guard, because bash 3.2 -- the only bash a managed Mac has
# -- treats "${a[@]}" on an EMPTY array as an unbound variable under `set -u`. It is fixed
# in bash 4.4, which is why this is a bug that works on a dev Mac and fails on a fleet.
#
# Sourcing bin/container-scan shares its namespace with this script, so nothing here may
# reuse what it defines. Both halves of the list, because a function collides as silently as
# a variable does:
#
#   globals    DEEP ROOT TIMEOUT SCAN_TIMEOUT SOCKET MARKER CACHE DOCKER_CODE DOCKER_SECS
#              BOUNDED_OUT BOUNDED_ERR BOUNDED_SIGNAL RUNTIME_NAME RUNTIME_AT RUN_DIR TMPDIR
#   functions  need_arg json_str json_row osq_run json_concat pack_branches merge_sql
#              merge_branches osqueryi_path docker_socket_path _runtime_seen other_runtime
#              docker_probe docker_healthy docker_state own_run_dir run_bounded marker
#              no_docker cache_write container_scan scan_and_cache
#
# And what THIS script defines, so the next addition to either file can check both lists:
#
#   globals    PLATFORM BASE DEEP_SCAN CACHE_PATH WRITE STATUS PACK SCAN_ARGS NAMES SIGS
#              SUMMARY_NAMES SUMMARY_STATUS
#   functions  usage need_osqi payload_cte status_sql names_sql summary names_line
#
# Two globals have already bitten: DEEP was this script's --deep flag until it silently
# became a file path, and CACHE was --cache's path until sourcing reset it to empty and every
# --cache run quietly did a live scan instead. Hence CACHE_PATH. DOCKER_SECS is the one this
# list was missing -- added to container-scan and never written down here, which is how the
# next collision would have arrived.
SCAN_ARGS=()
# --names: print one readable line instead of the payload. An output mode, so it composes
# with --deep, --cache and --write rather than being a fifth way to run the queries.
NAMES=0
SIGS="${DISCOVER_SIGS:-$ROOT/dist/signatures.sql}"

usage() { sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; }

while [ $# -gt 0 ]; do
  case "$1" in
    --deep)         DEEP_SCAN=1; shift ;;
    --names)        NAMES=1; shift ;;
    # --cache implies --deep. Asking for the cached container data and then not including
    # it would be a foot-gun with no legitimate use.
    --cache)        [ -n "${2:-}" ] || { echo "discover: --cache needs a path" >&2; exit 1; }
                    CACHE_PATH="$2"; shift 2 ;;
    --write)        [ -n "${2:-}" ] || { echo "discover: --write needs a path" >&2; exit 1; }
                    WRITE="$2"; shift 2 ;;
    --status)       [ -n "${2:-}" ] || { echo "discover: --status needs a path" >&2; exit 1; }
                    STATUS="$2"; shift 2 ;;
    --scan-timeout) [ -n "${2:-}" ] || { echo "discover: --scan-timeout needs a value" >&2; exit 1; }
                    SCAN_ARGS+=(--scan-timeout "$2"); shift 2 ;;
    --socket)       [ -n "${2:-}" ] || { echo "discover: --socket needs a value" >&2; exit 1; }
                    SCAN_ARGS+=(--socket "$2"); shift 2 ;;
    -h|--help)      usage; exit 0 ;;
    *) echo "discover: unknown argument: $1" >&2; exit 1 ;;
  esac
done
# An argument error, so it is raised where arguments are. It sat inside the --cache block,
# which made it reachable only once that mode had already been entered.
[ -z "$CACHE_PATH" ] || [ -z "$WRITE" ] || {
  echo "discover: --cache and --write are opposite ends of the same pipe; pick one" >&2; exit 1; }

# bin/container-scan owns every candidate path; ask it rather than keeping a second list.
# It lends the JSON helpers too -- json_str, json_row and osq_run. The name understates the
# file: it is this repo's shell library. A third file would have to be added to release.yml's
# asset list and to every deployment's install step, for isolation we already have.
# shellcheck source=container-scan
. "$ROOT/bin/container-scan"

need_osqi() {
  osqi="$(osqueryi_path)" || { echo "discover: no osqueryi found (set OSQI)" >&2; exit 1; }
  # Here because every mode that runs osqueryi calls this first, so what osqueryi leaves
  # behind lands in the run directory. A missing --cache answers before it, needing neither.
  own_run_dir discover || { echo "discover: cannot create a working directory" >&2; exit 1; }
}

# --- the status line and the names line, from ONE statement over ONE document -----------
#
# A payload file as an `ev` CTE of six-column rows -- the shape bin/classify matches and the
# shape dist/signatures.sql is held to agree with. test/test_match_sql.py asserts these five
# column expressions appear here verbatim, so the wrapper under test is the wrapper that runs.
# The document arrives through sed and never through a printf format: a row holding `%s`
# would otherwise be read as a conversion and vanish. coalesce on id, ver and loc, because
# a missing key is NULL and `NULL = 'x'` is NULL rather than false; `extra` stays raw because
# the status test below is `IS NOT 'ok'`, which was written for NULL.
payload_cte() {
  printf "WITH d(doc) AS (VALUES ('"
  sed "s/'/''/g" "$1"
  printf "')), ev AS (SELECT json_extract(e.value,'\$.kind') AS kind,"
  printf " coalesce(json_extract(e.value,'\$.id'),'') AS id,"
  printf " coalesce(json_extract(e.value,'\$.ver'),'') AS ver,"
  printf " coalesce(json_extract(e.value,'\$.loc'),'') AS loc,"
  printf " json_extract(e.value,'\$.extra') AS extra"
  printf " FROM d, json_each(d.doc) e)\n"
}

# The status expression, over `ev`. COALESCE is load-bearing: group_concat over no rows
# returns NULL, and concatenating NULL in SQLite yields NULL -- so a payload with no scan
# rows would blank the WHOLE line rather than shortening it, and a status that says nothing
# is a machine that looks fine. rtrim for the same case: with no markers the join leaves a
# trailing space, which the base path (no markers) would otherwise print.
#
# The ORDER BY sits in a subquery because SQLite gained ORDER BY inside an aggregate only in
# 3.44 and osquery 5.23.1 ships 3.42. Nothing consumes the token order.
status_sql() {
  printf "rtrim((CASE WHEN EXISTS (SELECT 1 FROM ev WHERE kind='scan'"
  printf " AND extra IS NOT 'ok' AND extra IS NOT 'absent')"
  printf " THEN 'degraded' ELSE 'ok' END)"
  printf " || ' ' || strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ','now')"
  printf " || ' rows=' || (SELECT count(*) FROM ev) || ' ' ||"
  printf " COALESCE((SELECT group_concat(id || '=' || extra, ' ')"
  printf " FROM (SELECT id, extra FROM ev WHERE kind='scan'"
  printf " ORDER BY id, extra)), ''))"
}

# The names expression, over `hit` -- which dist/signatures.sql defines from `ev`. One
# entry per agent, name then a version when any evidence row carried one. `none` when the
# join is empty, which is a value and never the absence of one.
#
# THE VERSION IS ONE OF THOSE SEEN, NOT THE HIGHEST. max() over strings is alphabetical, so
# 1.9.0 beats 1.10.0; comparing versions properly is not a thing this SQL should attempt.
# Container rows are left out of it entirely: queries/containers.sql puts the container's
# STATE in `ver`, so without the guard a running aider container printed `aider running`.
names_sql() {
  printf "(SELECT coalesce(group_concat(name || CASE WHEN ver != '' THEN ' ' || ver"
  printf " ELSE '' END, ', '), 'none') FROM (SELECT name,"
  printf " max(CASE WHEN kind = 'container' THEN '' ELSE ver END) AS ver FROM hit"
  printf " GROUP BY agent ORDER BY name))"
}

# One osqueryi call, both answers, into SUMMARY_STATUS and SUMMARY_NAMES -- bash 3.2 has no
# other way to return two strings. One call rather than two is what makes the status a
# --write writes and the status --names prints beside the names the SAME bytes, timestamp
# included. Empty on failure, and every caller treats empty as "could not summarise" --
# never as `none`, which would be a clean machine manufactured by a broken statement.
#
# --line, not --json: the names come back as a raw value after a fixed `names = ` prefix
# and need no unescaping. Keys are right-aligned to the longest, hence the leading ` *`.
summary() {
  local out
  if [ "$2" = 1 ]; then
    [ -s "$SIGS" ] || { echo "discover: no $SIGS (run tools/render_match.py)" >&2; return 1; }
    out="$({ payload_cte "$1"; cat "$SIGS"
             printf "SELECT "; names_sql; printf " AS names, "; status_sql; printf " AS status;"
           } | osq_run "$osqi" --line)"
  else
    out="$({ payload_cte "$1"
             printf "SELECT '' AS names, "; status_sql; printf " AS status;"
           } | osq_run "$osqi" --line)"
  fi
  [ -n "$out" ] || return 1
  SUMMARY_NAMES="$(printf '%s\n' "$out" | sed -n 's/^ *names = //p')"
  SUMMARY_STATUS="$(printf '%s\n' "$out" | sed -n 's/^ *status = //p')"
  [ -n "$SUMMARY_STATUS" ]
}

# --names over a document held in a variable: materialise it, summarise, print the line.
# Nothing on stdout when the summary fails, and exit 1 -- the one output this mode must
# never produce is `none` for a machine it could not read.
names_line() {
  local tmp rc
  tmp="$(mktemp "${TMPDIR:-/tmp}/discover.names.XXXXXX")" || return 1
  printf '%s' "$1" > "$tmp"
  summary "$tmp" 1; rc=$?
  rm -f "$tmp"
  [ "$rc" = 0 ] || { echo "discover: could not summarise the inventory; printing nothing" \
                          "rather than 'none'" >&2; return 1; }
  printf '%s; %s\n' "$SUMMARY_NAMES" "$SUMMARY_STATUS"
}

# --cache: emit and stop. SPLIT IN TWO, because the halves need different things.
#
# A MISSING CACHE MUST ANSWER ON A MACHINE WITH NOTHING INSTALLED. That is the whole reason
# the row exists, so that half builds one known row with the shell and runs before the
# osqueryi lookup and before the artifact check -- it needs neither.
#
# A PRESENT cache is parsed rather than echoed, and the parser is osqueryi. The comment here
# used to claim this path had to work where osquery was not installed at all. It never did:
# the lookup sat above it and exited 1 first, and the parse was python3 besides. A reader
# that genuinely has no osquery should use `cat`, which is what docs/deployment.md tells it
# to use and what the payload is shaped for.
if [ -n "$CACHE_PATH" ]; then
  if [ ! -r "$CACHE_PATH" ]; then
    # A missing cache is a fact about the deployment, not about this machine. Saying nothing
    # would let it read as "nothing installed here".
    echo "discover: no readable cache at $CACHE_PATH -- is the scheduled writer installed?" >&2
    if [ "$NAMES" = 1 ]; then
      # The same fact in the one-line shape, BUILT BY THE SHELL: this arm runs before the
      # osqueryi lookup on purpose, and a line that needed osqueryi to say "the writer never
      # ran" could be stopped by the thing it reports. `none; degraded` is a value a Smart
      # Group can match on; silence is not.
      printf 'none; degraded %s rows=1 inventory=no-cache\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
      exit 1
    fi
    printf '[%s]\n' "$(json_row scan inventory "$(date +%s)" "$CACHE_PATH" no-cache '')"
    exit 1
  fi
  need_osqi
  # Parsed rather than echoed, so a truncated or half-written file is caught here instead of
  # reaching a collector as "this machine reported nothing". The parser is osqueryi -- the
  # same SQLite that wrote the file.
  #
  # THE VERDICT COMES BACK, NOT THE ROWS. json_valid() and json_type() answer `bad` on a
  # malformed document rather than raising, so a corrupt cache is a value this shell can
  # branch on; feeding the document through json_each() instead would make osqueryi exit 1
  # and print "[\n\n]", and the failure would arrive wearing the shape of a clean machine.
  # osq_run swallowing that is what makes the third arm below distinguishable at all.
  #
  # Then `cat`. The comment above calls this path "cat with a parse check" and it was not
  # one -- it re-encoded, which normalised whatever the writer had chosen to put in the file.
  # Emitting the bytes is what the comment always claimed.
  verdict="$({ printf "WITH d(doc) AS (VALUES ('"
               sed "s/'/''/g" "$CACHE_PATH"
               printf "')) SELECT CASE WHEN json_valid(doc) AND json_type(doc)='array'"
               printf " THEN 'ok' ELSE 'bad' END AS v FROM d;"
             } | osq_run "$osqi" --csv --header=false)"
  case "$verdict" in
    ok)  if [ "$NAMES" = 1 ]; then
           # The cache is the document; no copy, no re-encoding. A failed summary prints
           # nothing and exits 1, for the reason names_line gives.
           summary "$CACHE_PATH" 1 \
             || { echo "discover: could not summarise $CACHE_PATH" >&2; exit 1; }
           printf '%s; %s\n' "$SUMMARY_NAMES" "$SUMMARY_STATUS"
           exit 0
         fi
         cat "$CACHE_PATH"; exit 0 ;;
    bad) echo "discover: $CACHE_PATH is not a JSON array. The writer may have been" \
              "interrupted; it writes by rename, so this should not happen." >&2
         # A corrupt cache said nothing at all before this, which is the same silence a
         # stubbed interpreter produced. `error` is already in the kind=scan vocabulary.
         # With --names the same fact goes out in the one-line shape, like the
         # missing-cache arm above: a JSON row here would be stored as the attribute's
         # value, and a Smart Group matching `degraded` would miss exactly this machine.
         if [ "$NAMES" = 1 ]; then
           printf 'none; degraded %s rows=1 inventory=error\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
           exit 1
         fi
         printf '[%s]\n' "$(json_row scan inventory "$(date +%s)" "$CACHE_PATH" error '')"
         exit 1 ;;
    # A BROKEN VALIDATOR IS NOT A BROKEN CACHE, and reporting one as the other would send
    # someone to look at the file. osq_run prints nothing on a non-zero exit, so this arm
    # is reachable only when osqueryi itself failed to answer.
    *)   echo "discover: could not validate $CACHE_PATH -- osqueryi did not answer." >&2
         exit 1 ;;
  esac
fi

# Every mode below this line runs queries, so both are prerequisites for all of them. The
# artifact check no longer gates --cache, which never reads the artifact.
need_osqi
[ -s "$BASE" ] || { echo "discover: no $BASE (run tools/build.py)" >&2; exit 1; }

# --write: every branch in its own process, then one payload. Runs before anything else
# because it is a different mode rather than an addition to the default one.
if [ -n "$WRITE" ]; then
  [ -s "$PACK" ] || { echo "discover: no $PACK (run tools/build.py)" >&2; exit 1; }
  tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/discover.XXXXXX")" || exit 1
  # The pack is the artifact that carries one named query per file, already flattened to a
  # single argv-safe line. Running it is running what ships.
  # FILTERED BY PLATFORM, or every branch for the other platform runs and fails. The pack
  # carries every query for every platform -- that is what osqueryd's per-query `platform`
  # field is for -- so on Linux the unfiltered list would run `apps`, `homebrew`, `runtime`
  # and `native-messaging`, each dying on a missing table. Each would then write a
  # kind=scan marker reading `error`, so a healthy Linux endpoint would report four gaps
  # per scan and a reader would be told to go and fix a Mac.
  #
  # `posix` covers both, matching catalog/routes.yaml's platform_groups.
  #
  # One osqueryi for the branch list AND every branch's SQL. Lives in bin/container-scan so
  # a test can assert the text arrives byte for byte; see pack_branches.
  pack_lines="$(pack_branches "$osqi" "$PACK" "$PLATFORM")" \
    || { echo "discover: could not read $PACK" >&2; exit 1; }

  # A `while ... done <<EOF` loop runs in THIS shell, so $names survives it. Piping into
  # the loop instead would run it in a subshell and leave $names empty -- with every branch
  # silently unscanned and the payload holding nothing but markers.
  names=""
  while IFS="$(printf '\t')" read -r name qtext; do
    [ -n "$name" ] || continue
    printf '%s\n' "$qtext" > "$tmpdir/$name.sql"
    names="$names $name"
  done <<EOF
$pack_lines
EOF
  [ -n "$names" ] || { echo "discover: no queries in $PACK for platform $PLATFORM" >&2; exit 1; }
  for name in $names; do
    if [ "$name" = containers ]; then
      # The one branch that needs a health guard and a wall clock. Delegated rather than
      # reimplemented, and its own scan row comes back with it.
      "$ROOT/bin/container-scan" ${SCAN_ARGS[@]+"${SCAN_ARGS[@]}"} > "$tmpdir/$name" 2>>"$tmpdir/stderr"
      continue
    fi
    # Bounded too, even though none of these touches a daemon today. A branch that cannot
    # finish must cost one branch, not the whole inventory -- which is the entire point of
    # running them apart.
    #
    # ON STDIN, NOT AS AN ARGUMENT, for the reason stated where the base artifact is run:
    # osqueryi reads an argv query beginning with `--` as a command-line flag and returns no
    # rows. No pack query opens with one today, so this is a hazard removed rather than a bug
    # fixed -- but the base path has been careful about it since it cost a day, and the two
    # paths disagreeing is how the next one arrives.
    if run_bounded 30 "$tmpdir/$name.sql" "$osqi" --docker_socket=/nonexistent/docker.sock --json; then
      printf '%s' "$BOUNDED_OUT" > "$tmpdir/$name"
    else
      branch_rc=$?
      # A SENTINEL, because an empty output file is ambiguous: a branch that ran and matched
      # nothing writes the same zero bytes as a branch that never finished. Without this the
      # merge called both "ok", so a failed branch reported success -- found by the
      # one-broken-branch test, which is the entire reason that test exists.
      : > "$tmpdir/$name"
      : > "$tmpdir/$name.failed"
      # The reason as well as the fact, as container_scan gives it: osqueryi's own stderr,
      # unless the bound fired, when what a killed child printed is not evidence of anything.
      if [ "$branch_rc" = 124 ] && [ -n "$BOUNDED_SIGNAL" ]; then
        echo "discover: branch '$name' did not complete (no answer in 30s)" >> "$tmpdir/stderr"
      else
        echo "discover: branch '$name' did not complete (exit $branch_rc)" >> "$tmpdir/stderr"
        [ -z "$BOUNDED_ERR" ] \
          || printf '%s\n' "$BOUNDED_ERR" | sed 's/^/  osqueryi: /' >> "$tmpdir/stderr"
      fi
    fi
  done

  # Merge by parsing, and give every branch a dated scan row -- present or absent, ok or
  # not. A branch that returned nothing and a branch that never ran are different facts.
  # The statement lives in bin/container-scan, where a test can hand it a broken branch.
  merge_branches "$osqi" "$tmpdir" $names > "$tmpdir/payload"
  [ -s "$tmpdir/payload" ] || { echo "discover: produced no payload; $WRITE left alone" >&2; rm -rf "$tmpdir"; exit 1; }
  # Rename, never write in place: a `cat` that caught a half-written file yields truncated
  # JSON, which a collector reads as a machine that reported nothing.
  mkdir -p "$(dirname "$WRITE")" 2>/dev/null
  mv -f "$tmpdir/payload" "$WRITE" || { echo "discover: could not write $WRITE" >&2; rm -rf "$tmpdir"; exit 1; }
  chmod 644 "$WRITE" 2>/dev/null

  # ONE STATEMENT for the status and, with --names, the names beside it -- and it reads
  # back $WRITE rather than the rows on their way there, so the line describes the file a
  # collector will actually find. The expressions live in status_sql and names_sql above.
  if [ -n "$STATUS" ] || [ "$NAMES" = 1 ]; then
    if summary "$WRITE" "$NAMES"; then
      if [ -n "$STATUS" ]; then
        stmp="$(mktemp "${STATUS}.XXXXXX")" || exit 1
        # printf, so the file ends in exactly one newline whatever osqueryi did with it.
        printf '%s\n' "$SUMMARY_STATUS" > "$stmp"
        mkdir -p "$(dirname "$STATUS")" 2>/dev/null
        mv -f "$stmp" "$STATUS" && chmod 644 "$STATUS" 2>/dev/null
      fi
    else
      echo "discover: could not summarise $WRITE; ${STATUS:-the status} left alone" >&2
      # The payload was written and is good; the LINE is what failed. Printing `none` here
      # would report a clean machine for a broken summary, so --names prints nothing and
      # exits 1 instead. Without --names the payload still goes to stdout as before.
      if [ "$NAMES" = 1 ]; then
        [ -s "$tmpdir/stderr" ] && cat "$tmpdir/stderr" >&2
        rm -rf "$tmpdir"; exit 1
      fi
    fi
  fi

  [ -s "$tmpdir/stderr" ] && cat "$tmpdir/stderr" >&2
  if [ "$NAMES" = 1 ]; then
    printf '%s; %s\n' "$SUMMARY_NAMES" "$SUMMARY_STATUS"
  else
    cat "$WRITE"; echo
  fi
  rm -rf "$tmpdir"
  exit 0
fi

# ON STDIN, NOT AS AN ARGUMENT. The artifact opens with a `--` banner and osqueryi reads an
# argv query beginning with `--` as a command-line flag: it prints "unknown command line
# flag", exits 0, and returns no rows. Silent and total.
#
# Unbounded, deliberately. The base artifact touches no daemon, no socket and no network --
# every branch reads local state and the composed file measures 1.07s loaded, 1.20s on a
# clean runner. There is nothing here for a kill to save us from, and a bound could only
# discard good rows from a slow Mac.
base="$("$osqi" --json < "$BASE")" || { echo "discover: the base query failed" >&2; exit 1; }

if [ "$DEEP_SCAN" = 0 ]; then
  if [ "$NAMES" = 1 ]; then
    # No marker rows on this path -- the composed artifact carries none -- so the status
    # half reads `ok <stamp> rows=N` and nothing more. That is honest: nothing here could
    # fail to look except by failing outright, which exits 1 above.
    names_line "$base" || exit 1
    exit 0
  fi
  printf '%s\n' "$base"
  exit 0
fi

# Rows on stdout, the reason on stderr, the verdict in the exit code. Every outcome except
# 0 also prints a marker row, so `deep` is never empty and never silently absent.
deep="$("$ROOT/bin/container-scan" ${SCAN_ARGS[@]+"${SCAN_ARGS[@]}"})"; deep_rc=$?
case "$deep_rc" in
  0) ;;
  2|3) ;;  # did not scan, or gave up. The marker row carries which, and base survives.
  *) echo "discover: container-scan could not run (exit $deep_rc); reporting base rows only" >&2 ;;
esac

# Join by PARSING both documents, never by splicing brackets -- see json_concat, which
# carries the reasoning and the rejected alternative.
#
# A FAILURE HERE MUST NOT PRINT. osq_run discards osqueryi's output on a non-zero exit, so a
# document that will not parse leaves this empty rather than emitting the "[\n\n]" osqueryi
# prints alongside its error -- and the base rows are reported as missing rather than as
# absent. Exit 1 says the run failed; printing a plausible empty array would not.
joined="$(json_concat "$osqi" "$base" "${deep:-[]}")" || {
  echo "discover: could not join the base and deep rows; reporting nothing rather than" \
       "an empty inventory" >&2; exit 1; }
if [ "$NAMES" = 1 ]; then
  # The container marker row rides in `joined`, so a daemon that would not answer shows up
  # in the status half as containers=timeout:N rather than as a quieter list of names.
  names_line "$joined" || exit 1
  exit 0
fi
printf '%s\n' "$joined"
