← Files NightshiftARCHIVED FILE

runtime/evidence.sh

29.2 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

#!/usr/bin/env bash
# evidence.sh — versioned findings ledger (JSON Lines).
#
#   evidence.sh --project DIR init|validate|append|disposition|render|export-tsv|migrate
#
# Validates records. Does not verify a Nightshift tick or interpret domain meaning.
# Exit: 0 ok · 1 usage · 2 contract failure
#
# The ledger logic is bash. jq (preferred) or python3 covers exactly two jobs: reading the
# JSON inputs (the v1 schema, the --record argument, every ledger line) and writing a record
# back as compact canonical JSON. Every decision — which defaults apply, which contract
# errors fire and in what order, which record a disposition touches, what a render prints —
# happens here. A record travels as its own canonical JSON text, one line each, so the
# ledger on disk and the ledger in hand are the same bytes.
set -u

_here="${BASH_SOURCE[0]%/*}"; [ "$_here" != "${BASH_SOURCE[0]}" ] || _here=.
# shellcheck source=plugins/nightshift/lib/layout.sh
. "$_here/../lib/layout.sh"
SCHEMA_PATH="$_here/../skills/nightshift/references/schemas/v1/finding.json"
EMIT_JQ="$_here/evidence-emit.jq"

NL='
'
TAB=$(printf '\t')
CR=$(printf '\r')
VT=$(printf '\013')
FF=$(printf '\014')
FS=$(printf '\037')
RS=$(printf '\036')

# Field slots in the fact stream. The order is the slot number; evidence-emit.jq is told the
# same list, so both halves agree without either hard-coding the other.
FIELDS="id
severity
confidence
impact
status
ladder
locator
untrusted
promoteBy
source
sourceCommand
sourceClass
sourceTool"

# Keys whose absence — not their emptiness — makes append supply a default.
HKEYS="schemaVersion
firstSeen
lastChecked
digest
action
fix
verificationLocator
disposition
rollback"

# The export column set, in order. The render table draws six of these.
COLS="id
domain
sourceClass
source
scope
severity
confidence
impact
status
ladder
locator
host"

LADDER_NAMES='"declared"
"observed"
"reproduced"
"measured"
"verified-after-change"
"human-accepted"'

usage() {
  printf 'usage: evidence.sh --project DIR {init|validate|append|disposition|render|export-tsv|migrate} ...\n' >&2
  exit 1
}

# ---------------------------------------------------------------- small helpers

# _join DIR NAME -> JOINED. NAME is always a leaf, never a path: an absolute
# NAME stays under DIR. os.path.join's "rooted second argument wins" rule is
# how an evidence id such as /tmp/nightshift-proof escaped .nightshift/.
_join() {
  case "$1" in
    "" | */) JOINED="$1$2" ;;
    *) JOINED="$1/$2" ;;
  esac
}

# Evidence ids: a leading [A-Za-z0-9], then [A-Za-z0-9_-]* only.
_valid_evidence_id() {
  case "$1" in
    '' | *[!A-Za-z0-9_-]*) return 1 ;;
  esac
  case "$1" in
    [A-Za-z0-9]*) return 0 ;;
  esac
  return 1
}

# _contained_raw ID -> RAWPATH, RAWABS under .nightshift/. Resolves the parent
# directory before the caller writes. Rejects anything that would escape.
_contained_raw() {
  local id="$1" parent ns_real dest_parent
  _valid_evidence_id "$id" || return 1
  _join "$EVREL" raw
  _join "$JOINED" "$id.txt"
  RAWPATH="$JOINED"
  case "$RAWPATH" in
    /* | *\\* | *..*) return 1 ;;
  esac
  _join "$NS" "$RAWPATH"
  parent="${JOINED%/*}"
  mkdir -p "$RAWDIR" || return 1
  ns_real="$(cd -P "$NS" && pwd)" || return 1
  dest_parent="$(cd -P "$parent" && pwd)" || return 1
  case "$dest_parent" in
    "$ns_real" | "$ns_real"/*) ;;
    *) return 1 ;;
  esac
  RAWABS="$dest_parent/$id.txt"
}

# _strip_ws TEXT -> STRIPPED, mirroring str.strip() over ASCII whitespace.
_strip_ws() {
  local s="$1"
  while [ -n "$s" ]; do
    case "$s" in
      " "*|"$TAB"*|"$NL"*|"$CR"*|"$VT"*|"$FF"*) s="${s#?}" ;;
      *) break ;;
    esac
  done
  while [ -n "$s" ]; do
    case "$s" in
      *" "|*"$TAB"|*"$NL"|*"$CR"|*"$VT"|*"$FF") s="${s%?}" ;;
      *) break ;;
    esac
  done
  STRIPPED="$s"
}

# _normpath PATH -> NORMPATH, mirroring posixpath.normpath.
_normpath() {
  local path="$1" initial rest comp stack tail joined
  if [ -z "$path" ]; then
    NORMPATH="."
    return 0
  fi
  case "$path" in
    ///*) initial=1 ;;
    //*) initial=2 ;;
    /*) initial=1 ;;
    *) initial=0 ;;
  esac
  stack="$NL"
  rest="$path/"
  while [ -n "$rest" ]; do
    comp="${rest%%/*}"
    rest="${rest#*/}"
    [ -n "$comp" ] || continue
    [ "$comp" = "." ] && continue
    if [ "$comp" != ".." ]; then
      stack="$stack$comp$NL"
      continue
    fi
    if [ "$stack" = "$NL" ]; then
      [ "$initial" -eq 0 ] && stack="$stack..$NL"
      continue
    fi
    tail="${stack%"$NL"}"
    if [ "${tail##*"$NL"}" = ".." ]; then
      stack="$stack..$NL"
    else
      stack="${tail%"$NL"*}$NL"
    fi
  done
  joined=""
  tail="${stack#"$NL"}"
  while [ -n "$tail" ]; do
    comp="${tail%%"$NL"*}"
    tail="${tail#*"$NL"}"
    if [ -z "$joined" ]; then joined="$comp"; else joined="$joined/$comp"; fi
  done
  case "$initial" in
    1) joined="/$joined" ;;
    2) joined="//$joined" ;;
  esac
  [ -n "$joined" ] || joined="."
  NORMPATH="$joined"
}

# _abspath PATH -> ABSPATH, mirroring os.path.abspath.
_abspath() {
  local p="$1" cwd
  case "$p" in
    /*) ;;
    *)
      cwd=$(pwd -P)
      case "$cwd" in
        */) p="$cwd$p" ;;
        *) p="$cwd/$p" ;;
      esac
      ;;
  esac
  _normpath "$p"
  ABSPATH="$NORMPATH"
}

# _has_line LIST ENTRY — exact membership in a newline-terminated list.
_has_line() {
  case "$NL$1" in
    *"$NL$2$NL"*) return 0 ;;
  esac
  return 1
}

# _py_truthy JSONTEXT — the truthiness Python gives the value that text encodes.
_py_truthy() {
  case "$1" in
    null|false|0|-0|0.0|-0.0|'""'|'[]'|'{}') return 1 ;;
  esac
  return 0
}

# _ladder_rank JSONTEXT -> LRANK, -1 when the value is not a rung.
_ladder_rank() {
  local rest name n=0
  LRANK=-1
  rest="$LADDER_NAMES$NL"
  while [ -n "$rest" ]; do
    name="${rest%%"$NL"*}"
    rest="${rest#*"$NL"}"
    [ -n "$name" ] || continue
    if [ "$name" = "$1" ]; then
      LRANK="$n"
      return 0
    fi
    n=$((n + 1))
  done
}

# _ends_nl FILE — str.endswith("\n") over the file, so an empty file is false.
_ends_nl() {
  local last
  last=$(tail -c1 "$1"; printf x)
  case "$last" in
    "$NL"x) return 0 ;;
  esac
  return 1
}

_mktmp() {
  local base="${TMPDIR:-/tmp}" n=0 d
  if command -v mktemp >/dev/null 2>&1; then
    TMPD="$(mktemp -d "${base%/}/ns-evidence.XXXXXX")" || {
      printf 'evidence: cannot create a temporary directory\n' >&2
      exit 2
    }
  else
    case "$base" in
      */) base="${base%/}" ;;
    esac
    while [ "$n" -lt 64 ]; do
      d="$base/ns-evidence-$$-$n"
      if mkdir "$d" 2>/dev/null; then
        TMPD="$d"
        break
      fi
      n=$((n + 1))
    done
    [ -n "${TMPD:-}" ] || {
      printf 'evidence: cannot create a temporary directory\n' >&2
      exit 2
    }
  fi
  chmod 700 "$TMPD" || {
    rm -rf "$TMPD"
    TMPD=""
    printf 'evidence: cannot create a temporary directory\n' >&2
    exit 2
  }
}

_cleanup() { [ -z "${TMPD:-}" ] || rm -rf "$TMPD"; }

# ---------------------------------------------------------------- digests

SHA_TOOL=""

_pick_sha() {
  [ -z "$SHA_TOOL" ] || return 0
  if command -v sha256sum >/dev/null 2>&1; then
    SHA_TOOL=sha256sum
  elif command -v shasum >/dev/null 2>&1; then
    SHA_TOOL=shasum
  elif command -v openssl >/dev/null 2>&1; then
    SHA_TOOL=openssl
  else
    printf 'evidence: sha256sum, shasum or openssl is required to digest a record\n' >&2
    exit 2
  fi
}

# _sha256_file FILE -> DIGEST, lowercase hex.
_sha256_file() {
  local line
  _pick_sha
  case "$SHA_TOOL" in
    sha256sum) line=$(sha256sum <"$1"); DIGEST="${line%% *}" ;;
    shasum) line=$(shasum -a 256 <"$1"); DIGEST="${line%% *}" ;;
    *) line=$(openssl dgst -sha256 <"$1"); DIGEST="${line##* }" ;;
  esac
}

_utcnow() {
  if [ -n "${NIGHTSHIFT_EVIDENCE_NOW:-}" ]; then
    NOW="$NIGHTSHIFT_EVIDENCE_NOW"
    return 0
  fi
  NOW=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
}

# ---------------------------------------------------------------- JSON bridge

JSON_TOOL=""

_pick_json_tool() {
  if command -v jq >/dev/null 2>&1; then
    JSON_TOOL=jq
  elif command -v python3 >/dev/null 2>&1; then
    JSON_TOOL=python3
  else
    printf 'evidence: JSON parser unavailable; write the receipt in the skill\n' >&2
    exit 2
  fi
}

PY='
import json, sys

OP = sys.argv[1]


def canon(o):
    return json.dumps(o, sort_keys=True, separators=(",", ":"))


def names(s):
    return [x for x in s.split("\n") if x]


def tname(v):
    if v is None:
        return "null"
    if v is True or v is False:
        return "boolean"
    if isinstance(v, (int, float)):
        return "number"
    if isinstance(v, str):
        return "string"
    if isinstance(v, list):
        return "array"
    return "object"


def presence(rec, keys):
    return "".join("1" if isinstance(rec, dict) and k in rec else "0" for k in keys)


def field(rec, key):
    return rec.get(key) if isinstance(rec, dict) else None


text = sys.stdin.buffer.read().decode("utf-8", "surrogateescape")
lines = text.split("\n")
if lines and lines[-1] == "":
    lines.pop()
out = []

if OP == "canon":
    try:
        sys.stdout.write(canon(json.loads(text)) + "\n")
    except ValueError:
        sys.exit(1)
elif OP == "check":
    for line in lines:
        try:
            json.loads(line)
            out.append("1")
        except ValueError:
            out.append("0")
elif OP == "canonlines":
    for line in lines:
        out.append(canon(json.loads(line)))
elif OP == "tojson":
    sys.stdout.write(canon(text))
elif OP == "schema":
    doc = json.loads(text)
    for key in doc["required"]:
        out.append("R\t" + key)
    for tag, name in (
        ("S", "severity"),
        ("C", "confidence"),
        ("I", "impact"),
        ("T", "status"),
        ("L", "ladder"),
    ):
        for value in doc[name]:
            out.append(tag + "\t" + canon(value))
elif OP == "facts":
    req, hkeys, fields, sep = (
        names(sys.argv[2]),
        names(sys.argv[3]),
        names(sys.argv[4]),
        sys.argv[5],
    )
    for i, line in enumerate(lines):
        rec = json.loads(line)
        head = sep + str(i) + sep
        out.append("t" + head + tname(rec))
        out.append("q" + head + presence(rec, req))
        out.append("h" + head + presence(rec, hkeys))
        out.append("s" + head + ("1" if field(rec, "schemaVersion") == 1 else "0"))
        for slot, key in enumerate(fields):
            out.append("v" + head + str(slot) + sep + canon(field(rec, key)))
elif OP == "rows":
    cols, sep, term = names(sys.argv[2]), sys.argv[3], sys.argv[4]
    for line in lines:
        rec = json.loads(line)
        row = "".join(str(field(rec, c)) + sep for c in cols)
        sys.stdout.write(row + presence(rec, cols) + term)
elif OP == "edit":
    assign, sep, term = sys.argv[2], sys.argv[3], sys.argv[4]
    rec = json.loads(text)
    for one in assign.split(term):
        if not one:
            continue
        f = one.split(sep)
        if f[1] == "s":
            rec[f[0]] = f[2]
        elif f[1] == "n":
            rec[f[0]] = json.loads(f[2])
        else:
            rec[f[0]] = rec[f[2]]
    sys.stdout.write(canon(rec) + "\n")

if out:
    sys.stdout.write("".join(line + "\n" for line in out))
'

# _jq_args OP [ASSIGNMENTS] -> JQARGS. jq resolves every $name at compile time, so one
# program file means one argument set, whichever operation is being asked for.
_jq_args() {
  JQARGS=(--arg op "$1" --arg req "${REQ_KEYS:-}" --arg hkeys "$HKEYS" \
    --arg fields "$FIELDS" --arg cols "$COLS" --arg assign "${2:-}" \
    --arg T "$TAB" --arg FS "$FS" --arg RS "$RS")
}

# _canon FILE -> CANON: the one JSON value in FILE as compact canonical JSON.
# Returns 1 when the text is not exactly one value, as json.loads would.
_canon() {
  local out
  if [ "$JSON_TOOL" = jq ]; then
    out=$(jq -caS -n --slurpfile r "$1" \
      'if ($r | length) == 1 then $r[0] else error("not one value") end' 2>/dev/null) ||
      return 1
  else
    out=$(python3 -c "$PY" canon <"$1" 2>/dev/null) || return 1
  fi
  [ -n "$out" ] || return 1
  CANON="$out"
}

# _check_lines FILE — "1" or "0" per line, on stdout, saying whether that line is one value.
_check_lines() {
  if [ "$JSON_TOOL" = jq ]; then
    jq -Rr 'if (try (fromjson | true) catch false) then "1" else "0" end' <"$1"
  else
    python3 -c "$PY" check <"$1"
  fi
}

# _canon_lines FILE — each line rewritten as compact canonical JSON, on stdout.
_canon_lines() {
  if [ "$JSON_TOOL" = jq ]; then
    jq -R -caS 'fromjson' <"$1"
  else
    python3 -c "$PY" canonlines <"$1"
  fi
}

# _json_str TEXT -> JSONSTR, the text as a JSON string, in this backend's own escaping.
_json_str() {
  printf '%s' "$1" >"$TMPD/str"
  if [ "$JSON_TOOL" = jq ]; then
    JSONSTR=$(jq -Rs . <"$TMPD/str")
  else
    JSONSTR=$(python3 -c "$PY" tojson <"$TMPD/str")
  fi
}

# _edit_rec IN OUT ASSIGNMENTS — apply the assignment list, write canonical JSON.
_edit_rec() {
  if [ "$JSON_TOOL" = jq ]; then
    _jq_args edit "$3"
    jq -caS -f "$EMIT_JQ" "${JQARGS[@]}" <"$1" >"$2"
  else
    python3 -c "$PY" edit "$3" "$FS" "$RS" <"$1" >"$2"
  fi
}

# ---------------------------------------------------------------- the schema

REQ_KEYS=""
SEV_LIST=""
CONF_LIST=""
IMP_LIST=""
STAT_LIST=""
LADD_LIST=""

_load_schema() {
  local line kind rest
  if [ "$JSON_TOOL" = jq ]; then
    jq -r '(.required[] | "R\t" + .),
           (.severity[] | "S\t" + tojson),
           (.confidence[] | "C\t" + tojson),
           (.impact[] | "I\t" + tojson),
           (.status[] | "T\t" + tojson),
           (.ladder[] | "L\t" + tojson)' "$SCHEMA_PATH" >"$TMPD/schema" 2>/dev/null
  else
    python3 -c "$PY" schema <"$SCHEMA_PATH" >"$TMPD/schema" 2>/dev/null
  fi || {
    printf 'evidence: cannot read the finding schema at %s\n' "$SCHEMA_PATH" >&2
    exit 2
  }
  while IFS= read -r line; do
    kind="${line%%"$TAB"*}"
    rest="${line#*"$TAB"}"
    case "$kind" in
      R) REQ_KEYS="$REQ_KEYS$rest$NL" ;;
      S) SEV_LIST="$SEV_LIST$rest$NL" ;;
      C) CONF_LIST="$CONF_LIST$rest$NL" ;;
      I) IMP_LIST="$IMP_LIST$rest$NL" ;;
      T) STAT_LIST="$STAT_LIST$rest$NL" ;;
      L) LADD_LIST="$LADD_LIST$rest$NL" ;;
    esac
  done <"$TMPD/schema"
}

# ---------------------------------------------------------------- record store

# Records live as canonical JSON text in REC, their facts in the F_ arrays, their printable
# columns in the P_ arrays. A record the ledger has not accepted yet takes the slot one past
# the end, so validation treats it exactly like a stored one.
NREC=0
REC=()
LNO=()
F_TYPE=()
F_REQ=()
F_HAS=()
F_SV1=()
F_ID=()
F_SEVERITY=()
F_CONFIDENCE=()
F_IMPACT=()
F_STATUS=()
F_LADDER=()
F_LOCATOR=()
F_UNTRUSTED=()
F_PROMOTEBY=()
F_SOURCE=()
F_SOURCECOMMAND=()
F_SOURCECLASS=()
F_SOURCETOOL=()
P_BITS=()
P_ID=()
P_DOMAIN=()
P_SOURCECLASS=()
P_SOURCE=()
P_SCOPE=()
P_SEVERITY=()
P_CONFIDENCE=()
P_IMPACT=()
P_STATUS=()
P_LADDER=()
P_LOCATOR=()
P_HOST=()

# _read_ledger — load findings.jsonl into REC, or exit 1 naming the first malformed line.
_read_ledger() {
  local line="" i=0 flag
  NREC=0
  [ -f "$JSONL" ] || return 0
  {
    while IFS= read -r line || [ -n "$line" ]; do
      i=$((i + 1))
      _strip_ws "$line"
      [ -n "$STRIPPED" ] || continue
      LNO[NREC]="$i"
      NREC=$((NREC + 1))
      printf '%s\n' "$STRIPPED"
    done <"$JSONL"
  } >"$TMPD/lines"
  [ "$NREC" -gt 0 ] || return 0
  _check_lines "$TMPD/lines" >"$TMPD/ok"
  i=0
  while IFS= read -r flag; do
    if [ "$flag" != 1 ]; then
      printf 'evidence: malformed JSON on line %s\n' "${LNO[$i]}" >&2
      exit 1
    fi
    i=$((i + 1))
  done <"$TMPD/ok"
  _canon_lines "$TMPD/lines" >"$TMPD/canon"
  i=0
  while IFS= read -r line; do
    REC[i]="$line"
    i=$((i + 1))
  done <"$TMPD/canon"
}

# _load_facts FILE BASE — read the fact stream for the canonical records in FILE.
_load_facts() {
  local base="$2" line kind idx rest slot
  if [ "$JSON_TOOL" = jq ]; then
    _jq_args facts
    jq -sr -f "$EMIT_JQ" "${JQARGS[@]}" <"$1" >"$TMPD/facts"
  else
    python3 -c "$PY" facts "$REQ_KEYS" "$HKEYS" "$FIELDS" "$TAB" <"$1" >"$TMPD/facts"
  fi
  while IFS= read -r line; do
    kind="${line%%"$TAB"*}"
    rest="${line#*"$TAB"}"
    idx="${rest%%"$TAB"*}"
    rest="${rest#*"$TAB"}"
    idx=$((base + idx))
    case "$kind" in
      t) F_TYPE[idx]="$rest" ;;
      q) F_REQ[idx]="$rest" ;;
      h) F_HAS[idx]="$rest" ;;
      s) F_SV1[idx]="$rest" ;;
      v)
        slot="${rest%%"$TAB"*}"
        rest="${rest#*"$TAB"}"
        case "$slot" in
          0) F_ID[idx]="$rest" ;;
          1) F_SEVERITY[idx]="$rest" ;;
          2) F_CONFIDENCE[idx]="$rest" ;;
          3) F_IMPACT[idx]="$rest" ;;
          4) F_STATUS[idx]="$rest" ;;
          5) F_LADDER[idx]="$rest" ;;
          6) F_LOCATOR[idx]="$rest" ;;
          7) F_UNTRUSTED[idx]="$rest" ;;
          8) F_PROMOTEBY[idx]="$rest" ;;
          9) F_SOURCE[idx]="$rest" ;;
          10) F_SOURCECOMMAND[idx]="$rest" ;;
          11) F_SOURCECLASS[idx]="$rest" ;;
          12) F_SOURCETOOL[idx]="$rest" ;;
        esac
        ;;
    esac
  done <"$TMPD/facts"
}

# _load_rows FILE BASE — read the printable columns for the canonical records in FILE.
_load_rows() {
  local base="$2" i bits c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11
  if [ "$JSON_TOOL" = jq ]; then
    _jq_args rows
    jq -sj -f "$EMIT_JQ" "${JQARGS[@]}" <"$1" >"$TMPD/rows"
  else
    python3 -c "$PY" rows "$COLS" "$FS" "$RS" <"$1" >"$TMPD/rows"
  fi
  i="$base"
  while IFS="$FS" read -r -d "$RS" \
    c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 bits; do
    P_ID[i]="$c0"
    P_DOMAIN[i]="$c1"
    P_SOURCECLASS[i]="$c2"
    P_SOURCE[i]="$c3"
    P_SCOPE[i]="$c4"
    P_SEVERITY[i]="$c5"
    P_CONFIDENCE[i]="$c6"
    P_IMPACT[i]="$c7"
    P_STATUS[i]="$c8"
    P_LADDER[i]="$c9"
    P_LOCATOR[i]="$c10"
    P_HOST[i]="$c11"
    P_BITS[i]="$bits"
    i=$((i + 1))
  done <"$TMPD/rows"
}

# _write_ledger — every record, canonical, one per line, in place by rename.
_write_ledger() {
  local i=0
  {
    while [ "$i" -lt "$NREC" ]; do
      printf '%s\n' "${REC[$i]}"
      i=$((i + 1))
    done
  } >"$JSONL.tmp"
  mv "$JSONL.tmp" "$JSONL"
}

# ---------------------------------------------------------------- validation

# _validate I HASPREV PREVLADDER -> ERRORS, one contract failure per line, in schema order.
_validate() {
  local i="$1" hasprev="$2" prevladder="$3" bits key rest n=0 old new idjson
  ERRORS=""
  if [ "${F_TYPE[$i]}" != object ]; then
    ERRORS="record is not an object$NL"
    return 0
  fi
  bits="${F_REQ[$i]}"
  rest="$REQ_KEYS"
  while [ -n "$rest" ]; do
    key="${rest%%"$NL"*}"
    rest="${rest#*"$NL"}"
    [ -n "$key" ] || continue
    [ "${bits:$n:1}" = 1 ] || ERRORS="${ERRORS}missing $key$NL"
    n=$((n + 1))
  done
  [ "${F_SV1[$i]}" = 1 ] || ERRORS="${ERRORS}unsupported schemaVersion$NL"
  _has_line "$SEV_LIST" "${F_SEVERITY[$i]}" || ERRORS="${ERRORS}invalid severity$NL"
  _has_line "$CONF_LIST" "${F_CONFIDENCE[$i]}" || ERRORS="${ERRORS}invalid confidence$NL"
  _has_line "$IMP_LIST" "${F_IMPACT[$i]}" || ERRORS="${ERRORS}invalid impact$NL"
  _has_line "$STAT_LIST" "${F_STATUS[$i]}" || ERRORS="${ERRORS}invalid status$NL"
  _has_line "$LADD_LIST" "${F_LADDER[$i]}" || ERRORS="${ERRORS}invalid ladder$NL"
  case "${F_ID[$i]}" in
    '"'*)
      idjson="${F_ID[$i]}"
      idjson="${idjson#\"}"
      idjson="${idjson%\"}"
      _valid_evidence_id "$idjson" || ERRORS="${ERRORS}invalid id$NL"
      ;;
  esac
  case "${F_LOCATOR[$i]}" in
    *"://"*)
      _py_truthy "${F_UNTRUSTED[$i]}" ||
        ERRORS="${ERRORS}remote locator requires untrusted=true$NL"
      ;;
  esac
  if [ "$hasprev" = 1 ]; then
    _ladder_rank "$prevladder"
    old="$LRANK"
    _ladder_rank "${F_LADDER[$i]}"
    new="$LRANK"
    if [ "$old" -ge 0 ] && [ "$new" -gt "$old" ] &&
      [ "${F_PROMOTEBY[$i]}" = '"prose"' ]; then
      ERRORS="${ERRORS}ladder must not be promoted by prose$NL"
    fi
  fi
}

# _print_errors PREFIX — the collected failures, one line each, on stderr.
_print_errors() {
  local rest line
  rest="$ERRORS"
  while [ -n "$rest" ]; do
    line="${rest%%"$NL"*}"
    rest="${rest#*"$NL"}"
    [ -n "$line" ] || continue
    printf 'evidence: %s%s\n' "$1" "$line" >&2
  done
}

# ---------------------------------------------------------------- commands

# _cmd_init QUIET — returns 1 when there is no workspace to hold a ledger.
_cmd_init() {
  if [ ! -d "$NS" ]; then
    printf 'evidence: no .nightshift/ at %s\n' "$PROJECT_ARG" >&2
    return 1
  fi
  mkdir -p "$RAWDIR" || return 1
  [ -f "$JSONL" ] || : >"$JSONL"
  [ -f "$VERFILE" ] || printf '1\n' >"$VERFILE"
  [ "$1" = 1 ] || printf '%s\n' "$JSONL"
  return 0
}

_cmd_validate() {
  local rc=0 i=0 j prev hasprev label
  if [ ! -f "$JSONL" ]; then
    printf 'evidence: no ledger (valid empty workspace)\n'
    exit 0
  fi
  _load_schema
  _read_ledger
  [ "$NREC" -gt 0 ] || exit 0
  _load_facts "$TMPD/canon" 0
  _load_rows "$TMPD/canon" 0
  while [ "$i" -lt "$NREC" ]; do
    hasprev=0
    prev=""
    j=0
    while [ "$j" -lt "$i" ]; do
      if [ "${F_ID[$j]}" = "${F_ID[$i]}" ] && _py_truthy "${F_ID[$j]}"; then
        hasprev=1
        prev="${F_LADDER[$j]}"
      fi
      j=$((j + 1))
    done
    _validate "$i" "$hasprev" "$prev"
    if [ -n "$ERRORS" ]; then
      rc=2
      label="?"
      if _py_truthy "${F_ID[$i]}"; then label="${P_ID[$i]}"; fi
      _print_errors "$label: "
    fi
    i=$((i + 1))
  done
  exit "$rc"
}

_cmd_append() {
  local hbits stype src srccmd srccls srctool ops i prev hasprev idstr line
  _cmd_init 1 || :
  _load_schema
  printf '%s' "$RECORD" >"$TMPD/in"
  if ! _canon "$TMPD/in"; then
    printf 'evidence: --record is not one JSON value\n' >&2
    exit 1
  fi
  printf '%s\n' "$CANON" >"$TMPD/one"
  _load_facts "$TMPD/one" 0
  stype="${F_TYPE[0]}"
  hbits="${F_HAS[0]}"
  src="${F_SOURCE[0]}"
  srccmd="${F_SOURCECOMMAND[0]}"
  srccls="${F_SOURCECLASS[0]}"
  srctool="${F_SOURCETOOL[0]}"
  if [ "$stype" != object ]; then
    printf 'evidence: record is not an object\n' >&2
    exit 1
  fi
  _utcnow
  ops=""
  [ "${hbits:0:1}" = 1 ] || ops="$ops${RS}schemaVersion${FS}n${FS}1"
  [ "${hbits:1:1}" = 1 ] || ops="$ops${RS}firstSeen${FS}s${FS}$NOW"
  [ "${hbits:2:1}" = 1 ] || ops="$ops${RS}lastChecked${FS}k${FS}firstSeen"
  _edit_rec "$TMPD/one" "$TMPD/two" "$ops"
  ops=""
  if [ "${hbits:3:1}" != 1 ]; then
    IFS= read -r line <"$TMPD/two"
    printf '%s' "$line" >"$TMPD/digest"
    _sha256_file "$TMPD/digest"
    ops="$ops${RS}digest${FS}s${FS}$DIGEST"
  fi
  [ "${hbits:4:1}" = 1 ] || ops="$ops${RS}action${FS}s${FS}"
  [ "${hbits:5:1}" = 1 ] || ops="$ops${RS}fix${FS}s${FS}"
  [ "${hbits:6:1}" = 1 ] || ops="$ops${RS}verificationLocator${FS}s${FS}"
  [ "${hbits:7:1}" = 1 ] || ops="$ops${RS}disposition${FS}s${FS}"
  [ "${hbits:8:1}" = 1 ] || ops="$ops${RS}rollback${FS}s${FS}"
  if ! _py_truthy "$src"; then
    if _py_truthy "$srccmd"; then
      ops="$ops${RS}source${FS}k${FS}sourceCommand"
    else
      ops="$ops${RS}source${FS}s${FS}"
    fi
  fi
  if ! _py_truthy "$srccls"; then
    if _py_truthy "$srctool"; then
      ops="$ops${RS}sourceClass${FS}k${FS}sourceTool"
    else
      ops="$ops${RS}sourceClass${FS}s${FS}unknown"
    fi
  fi
  _edit_rec "$TMPD/two" "$TMPD/three" "$ops"
  _read_ledger
  if [ "$NREC" -gt 0 ]; then
    _load_facts "$TMPD/canon" 0
  fi
  _load_facts "$TMPD/three" "$NREC"
  _load_rows "$TMPD/three" "$NREC"
  IFS= read -r line <"$TMPD/three"
  REC[NREC]="$line"
  hasprev=0
  prev=""
  i=0
  while [ "$i" -lt "$NREC" ]; do
    if [ "${F_ID[$i]}" = "${F_ID[$NREC]}" ]; then
      hasprev=1
      prev="${F_LADDER[$i]}"
      break
    fi
    i=$((i + 1))
  done
  _validate "$NREC" "$hasprev" "$prev"
  if [ -n "$ERRORS" ]; then
    _print_errors ""
    exit 2
  fi
  if [ -n "$RAW" ]; then
    case "${F_ID[$NREC]}" in
      '"'*) ;;
      *)
        printf 'evidence: raw output needs a string id\n' >&2
        exit 1
        ;;
    esac
    idstr="${P_ID[$NREC]}"
    if ! _contained_raw "$idstr"; then
      printf 'evidence: invalid id\n' >&2
      exit 2
    fi
    printf '%s' "$RAW" >"$TMPD/raw"
    cp "$TMPD/raw" "$RAWABS.tmp"
    _ends_nl "$TMPD/raw" || printf '\n' >>"$RAWABS.tmp"
    mv "$RAWABS.tmp" "$RAWABS"
    _sha256_file "$TMPD/raw"
    ops="${RS}rawPath${FS}s${FS}$RAWPATH${RS}rawDigest${FS}s${FS}$DIGEST"
    _edit_rec "$TMPD/three" "$TMPD/four" "$ops"
    IFS= read -r line <"$TMPD/four"
    REC[NREC]="$line"
  fi
  NREC=$((NREC + 1))
  _write_ledger
  printf '%s\n' "${P_ID[$((NREC - 1))]}"
  exit 0
}

_cmd_disposition() {
  local i=0 found=0 ops oldladder line
  _load_schema
  _read_ledger
  if [ "$NREC" -gt 0 ]; then
    _load_facts "$TMPD/canon" 0
  fi
  _json_str "$DISP_ID"
  _utcnow
  while [ "$i" -lt "$NREC" ]; do
    if [ "${F_ID[$i]}" != "$JSONSTR" ]; then
      i=$((i + 1))
      continue
    fi
    found=1
    oldladder="${F_LADDER[$i]}"
    ops="${RS}disposition${FS}s${FS}$DISP${RS}lastChecked${FS}s${FS}$NOW"
    [ -z "$LADDER" ] || ops="$ops${RS}ladder${FS}s${FS}$LADDER"
    printf '%s\n' "${REC[$i]}" >"$TMPD/one"
    _edit_rec "$TMPD/one" "$TMPD/two" "$ops"
    IFS= read -r line <"$TMPD/two"
    REC[i]="$line"
    _load_facts "$TMPD/two" "$i"
    _validate "$i" 1 "$oldladder"
    if [ -n "$ERRORS" ]; then
      _print_errors ""
      exit 2
    fi
    i=$((i + 1))
  done
  if [ "$found" != 1 ]; then
    printf 'evidence: unknown id %s\n' "$DISP_ID" >&2
    exit 2
  fi
  _write_ledger
  exit 0
}

_cmd_render() {
  local md i=0
  if [ -f "$JSONL" ]; then
    _read_ledger
    [ "$NREC" -eq 0 ] || _load_rows "$TMPD/canon" 0
  fi
  md="# Evidence ledger$NL"
  md="$md$NL"
  md="${md}Machine source: \`evidence/findings.jsonl\`. Helpers validate records; they do not$NL"
  md="${md}verify a Nightshift tick or interpret domain meaning.$NL"
  md="$md$NL"
  md="$md| ID | Domain | Severity | Ladder | Status | Locator |$NL"
  md="$md| --- | --- | --- | --- | --- | --- |$NL"
  while [ "$i" -lt "$NREC" ]; do
    md="$md| ${P_ID[$i]} | ${P_DOMAIN[$i]} | ${P_SEVERITY[$i]} | ${P_LADDER[$i]} |"
    md="$md ${P_STATUS[$i]} | ${P_LOCATOR[$i]} |$NL"
    i=$((i + 1))
  done
  [ "$NREC" -gt 0 ] || md="$md| — | — | — | — | — | empty |$NL"
  mkdir -p "$EVDIR"
  printf '%s' "$md" >"$MDFILE.tmp"
  mv "$MDFILE.tmp" "$MDFILE"
  printf '%s' "$md"
  exit 0
}

# _tsv_cell VALUE PRESENT -> CELL: an absent column exports as empty, a tab inside a value
# becomes a space so the column count survives.
_tsv_cell() {
  if [ "$2" = 1 ]; then
    CELL="${1//$TAB/ }"
  else
    CELL=""
  fi
}

_cmd_export_tsv() {
  local i=0 row bits
  if [ -f "$JSONL" ]; then
    _read_ledger
    [ "$NREC" -eq 0 ] || _load_rows "$TMPD/canon" 0
  fi
  printf 'id%sdomain%ssourceClass%ssource%sscope%sseverity%sconfidence%simpact%sstatus%sladder%slocator%shost\n' \
    "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB"
  while [ "$i" -lt "$NREC" ]; do
    bits="${P_BITS[$i]}"
    _tsv_cell "${P_ID[$i]}" "${bits:0:1}"
    row="$CELL"
    _tsv_cell "${P_DOMAIN[$i]}" "${bits:1:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_SOURCECLASS[$i]}" "${bits:2:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_SOURCE[$i]}" "${bits:3:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_SCOPE[$i]}" "${bits:4:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_SEVERITY[$i]}" "${bits:5:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_CONFIDENCE[$i]}" "${bits:6:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_IMPACT[$i]}" "${bits:7:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_STATUS[$i]}" "${bits:8:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_LADDER[$i]}" "${bits:9:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_LOCATOR[$i]}" "${bits:10:1}"
    row="$row$TAB$CELL"
    _tsv_cell "${P_HOST[$i]}" "${bits:11:1}"
    row="$row$TAB$CELL"
    printf '%s\n' "$row"
    i=$((i + 1))
  done
  exit 0
}

_cmd_migrate() {
  local version
  if [ ! -d "$EVDIR" ] && [ ! -f "$JSONL" ]; then
    printf 'evidence: nothing to migrate\n'
    exit 0
  fi
  version=0
  if [ -f "$VERFILE" ]; then
    version=$(cat "$VERFILE")
    _strip_ws "$version"
    version="$STRIPPED"
    [ -n "$version" ] || version=0
  fi
  case "$version" in
    0 | 1)
      mkdir -p "$RAWDIR"
      printf '1\n' >"$VERFILE"
      [ -f "$JSONL" ] || : >"$JSONL"
      printf 'evidence: schema-version 1\n'
      exit 0
      ;;
  esac
  printf 'evidence: unsupported evidence schema-version %s\n' "$version" >&2
  exit 2
}

# ---------------------------------------------------------------- entry point

PROJECT_ARG=""
while [ $# -gt 0 ]; do
  [ "$1" = "--project" ] || break
  [ $# -ge 2 ] || usage
  PROJECT_ARG="$2"
  shift 2
done
[ -n "$PROJECT_ARG" ] || usage
[ $# -gt 0 ] || usage

CMD="$1"
shift
RECORD=""
RAW=""
DISP_ID=""
DISP=""
LADDER=""
case "$CMD" in
  init | validate | render | export-tsv | migrate) ;;
  append)
    while [ $# -gt 0 ]; do
      case "$1" in
        --record)
          [ $# -ge 2 ] || usage
          RECORD="$2"
          shift 2
          ;;
        --raw)
          [ $# -ge 2 ] || usage
          RAW="$2"
          shift 2
          ;;
        *) usage ;;
      esac
    done
    [ -n "$RECORD" ] || usage
    ;;
  disposition)
    [ $# -ge 2 ] || usage
    DISP_ID="$1"
    DISP="$2"
    [ $# -lt 3 ] || LADDER="$3"
    ;;
  *) usage ;;
esac

_abspath "$PROJECT_ARG"
PROJECT="$ABSPATH"
_join "$PROJECT" .nightshift
NS="$JOINED"
declare EVDIR EVREL
ns_layout_set EVDIR "$NS" evidence
ns_layout_rel_set EVREL "$NS" evidence
_join "$EVDIR" findings.jsonl
JSONL="$JOINED"
_join "$EVDIR" findings.md
MDFILE="$JOINED"
_join "$EVDIR" raw
RAWDIR="$JOINED"
_join "$EVDIR" schema-version
VERFILE="$JOINED"

_pick_json_tool
TMPD=""
trap _cleanup EXIT
_mktmp

case "$CMD" in
  init)
    _cmd_init 0 || exit 1
    exit 0
    ;;
  validate) _cmd_validate ;;
  append) _cmd_append ;;
  disposition) _cmd_disposition ;;
  render) _cmd_render ;;
  export-tsv) _cmd_export_tsv ;;
  migrate) _cmd_migrate ;;
esac

SHA-256: e0ed2e49f3f20aa71b59c1ce848fa55702eb659767cd24c46e8623113defef39