← Files NightshiftARCHIVED FILE

lib/policy.sh

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

↓ Download file

#!/usr/bin/env bash
# Shift policy — the one resolver.
#
# Three files, three jobs. rules.json holds the permanent project boundaries. shift-defaults.json
# holds remembered convenience choices that only prefill the one question a composition step asks;
# it is never the source of an effective value. shift-policy.json is tonight's authoritative
# snapshot, written before the gate is armed and archived at clock-out. Everything that needs a
# policy answer — hardhat, Start, Doctor, Status, the support bundle, both host adapters — reads
# ns_policy_resolve, so no two of them can render a different result.
#
# rules.json is read by the shipped subset reader — no jq or python3. jq (preferred) or python3
# still turns shift-policy.json and shift-defaults.json into a flat, tab-separated fact stream.
# Every decision — validation, precedence, defaults, allowance matching — happens in bash. Raw
# payloads are control-scrubbed before they enter the stream, so a fact line always carries
# exactly one fact, and a scalar's compact JSON is self-describing in its first byte. The
# resolved view reports the files: a session NIGHTSHIFT_* override is one guard's test lever, not
# policy, and never appears as a source.

NS_POLICY_NL='
'
NS_POLICY_TAB=$(printf '\t')

# The jq half sits next to this file, resolved without dirname so a hostile PATH cannot reach it.
NS_POLICY_EMIT_JQ="${BASH_SOURCE[0]%/*}"
[ "$NS_POLICY_EMIT_JQ" != "${BASH_SOURCE[0]}" ] || NS_POLICY_EMIT_JQ=.
NS_POLICY_EMIT_JQ="$NS_POLICY_EMIT_JQ/policy-emit.jq"

NS_POLICY_CATEGORIES="sudo
containers
global-packages
daemons
external-services"

NS_POLICY_SHIFT_FIELDS="schemaVersion
shiftId
createdAt
source
deadlineEpoch
verificationLevel
toolingPolicy
launchScope
launchProvenance
budgets
completionMode
selectedDebt
allowances
gatesDigest
contractDigest
itemsDigest
shift
recovery
handoff
archive
receipts"

NS_POLICY_RULES_STATE=""
NS_POLICY_RULES_VALS=""
NS_POLICY_RULES_ELEV=""
NS_POLICY_RULES_PAT=""
NS_POLICY_SHIFT_STATE=""
NS_POLICY_SHIFT_ERR=""
NS_POLICY_SHIFT_ID=""
NS_POLICY_SHIFT_TYPES=""
NS_POLICY_SHIFT_KEYS=""
NS_POLICY_SHIFT_VALS=""
NS_POLICY_SHIFT_COUNTS=""
NS_POLICY_SHIFT_BUDGETS=""
NS_POLICY_SHIFT_DEBT=""
NS_POLICY_SHIFT_CMDS=""
NS_POLICY_SHIFT_CMDJSON=""
NS_POLICY_SHIFT_SURFACE=""
NS_POLICY_SHIFT_ALLOW=""
NS_POLICY_SHIFT_PLAN=""
NS_POLICY_SHIFT_PLANIDX=""
NS_POLICY_V=""
NS_POLICY_S=""
NS_POLICY_E=""
NS_POLICY_DEF_PROFILE=""
NS_POLICY_DEF_HOURS=""
NS_POLICY_DEF_TOOLING=""
NS_POLICY_DEF_EXECUTION=""
NS_POLICY_DEF_UPDATED=""
NS_PF1=""
NS_PF2=""
NS_PF3=""
NS_PF4=""
NS_PF5=""

# ns_policy_default_pattern <category> — the shipped grep -E for a category, and the list of
# categories that exist. The rules template carries this text byte for byte so the owner can see
# and edit it; this is what applies when the file does not, or when a file predates the elevation
# object. The elevation guard and the permission preflight both come here, so they cannot
# disagree, and a workspace answers the same with or without a JSON parser on PATH.
#
# Elevation gates creating system state, not reading it: a verb-scoped pattern denies `docker run`
# and `brew install` while `docker ps` and `brew list` stay ordinary work. The leading alternation
# admits a path prefix and one layer of quoting, so `/usr/bin/sudo` and an `sh -c` payload land in
# the same category as the bare command.
ns_policy_default_pattern() {
  case "$1" in
    sudo)
      printf '%s' '(^|[;&|(`]|[[:space:]]|'\''|")(/[A-Za-z0-9._-]+)*/*(sudo|d[o]as)([[:space:]]|[;&|)'\''"`]|$)'
      ;;
    containers)
      printf '%s' '(/var/run/docker\.sock|unix://[^ \t]*docker\.sock|DOCKER_HOST=)|(^|[;&|(`]|[[:space:]]|'\''|")(docker-compose)[[:space:]]+(up|run|start|build|down|create)|(^|[;&|(`]|[[:space:]]|'\''|")(docker|podman|nerdctl|colima)[[:space:]]+(run|create|start|build|compose[[:space:]]+(up|run|start|build|down|create))'
      ;;
    global-packages)
      printf '%s' '(^|[;&|(`]|[[:space:]]|'\''|")(brew|apt|apt-get|dnf|yum|pacman|choco|winget|scoop)[[:space:]]+(install|upgrade|uninstall|remove|reinstall)|npm[[:space:]]+(i|install)[[:space:]]+(-g|--global)|pnpm[[:space:]]+add[[:space:]]+-g|yarn[[:space:]]+global|(pip3?|cargo|go)[[:space:]]+install'
      ;;
    daemons)
      printf '%s' '(^|[;&|(`]|[[:space:]]|'\''|")(systemctl|launchctl|service|brew[[:space:]]+services|pg_ctl|redis-server|mongod|mysqld)([[:space:]]|$)'
      ;;
    external-services)
      printf '%s' '(^|[;&|(`]|[[:space:]]|'\''|")(gh[[:space:]]+auth[[:space:]]+login|npm[[:space:]]+login|docker[[:space:]]+login|az[[:space:]]+login|gcloud[[:space:]]+auth|aws[[:space:]]+configure)([[:space:]]|$)'
      ;;
    *) return 1 ;;
  esac
}

# ns_policy_builtin <setting> — the lowest-precedence value, as compact JSON. Nothing decides
# from a constant the resolved view does not print. verificationLevel is the shipped
# fast profile: no gate cadence unless the owner or a profile sets one.
ns_policy_builtin() {
  case "$1" in
    verificationLevel) printf '"none"' ;;
    toolingPolicy) printf '"existing-tools"' ;;
    deadlineEpoch) printf 'null' ;;
    elevation.*) printf '"deny"' ;;
    forbiddenCommands | protectedDirs | neverCommitPatterns | expectedEmail) printf '""' ;;
    stallMax) printf '0' ;;
    watchMinutes) printf '10' ;;
    archive.automatic) printf 'false' ;;
    archive.layout) printf '"date"' ;;
    archive.root) printf '"archive"' ;;
    handoff.detail) printf '"concise"' ;;
    handoff.enabled | receipts.enabled) printf 'true' ;;
    handoff.language) printf '"auto"' ;;
    handoff.sections) printf '[]' ;;
    handoff.view) printf '"owner"' ;;
    recovery.launchScope) printf '"inherit-recorded-scope"' ;;
    receipts.duration) printf '"on"' ;;
    receipts.progressMinutes) printf '20' ;;
    receipts.progressMode) printf '"time"' ;;
    receipts.progressTokens) printf '100000' ;;
    receipts.usage) printf '"when-available"' ;;
    shift.execution) printf '"review-first"' ;;
    shift.hours) printf 'null' ;;
    shift.toolingPolicy) printf '"existing-tools"' ;;
    shift.verificationProfile) printf '"fast"' ;;
    archive.templatePath | handoff.templatePath | receipts.templatePath) printf '""' ;;
    *) return 1 ;;
  esac
}

# ns_policy_settings — every effective setting name, one per line, in byte order.
ns_policy_settings() {
  local c
  {
    printf 'deadlineEpoch\nexpectedEmail\nforbiddenCommands\nneverCommitPatterns\n'
    printf 'protectedDirs\nstallMax\ntoolingPolicy\nverificationLevel\nwatchMinutes\n'
    printf '%s\n' "$NS_POLICY_CATEGORIES" | while IFS= read -r c; do
      [ -n "$c" ] || continue
      printf 'elevation.%s\n' "$c"
    done
    printf '%s\n' "$NS_RULES_GROUP_KEYS"
  } | LC_ALL=C sort
}

# _ns_pf_split <line> — a tab-separated fact line into NS_PF1..NS_PF5. Split by parameter
# expansion, never by read: tab is IFS whitespace, so read would collapse an empty field away.
_ns_pf_split() {
  local rest="$1"
  NS_PF1=""
  NS_PF2=""
  NS_PF3=""
  NS_PF4=""
  NS_PF5=""
  NS_PF1="${rest%%"$NS_POLICY_TAB"*}"
  case "$rest" in *"$NS_POLICY_TAB"*) rest="${rest#*"$NS_POLICY_TAB"}" ;; *) return 0 ;; esac
  NS_PF2="${rest%%"$NS_POLICY_TAB"*}"
  case "$rest" in *"$NS_POLICY_TAB"*) rest="${rest#*"$NS_POLICY_TAB"}" ;; *) return 0 ;; esac
  NS_PF3="${rest%%"$NS_POLICY_TAB"*}"
  case "$rest" in *"$NS_POLICY_TAB"*) rest="${rest#*"$NS_POLICY_TAB"}" ;; *) return 0 ;; esac
  NS_PF4="${rest%%"$NS_POLICY_TAB"*}"
  case "$rest" in *"$NS_POLICY_TAB"*) rest="${rest#*"$NS_POLICY_TAB"}" ;; *) return 0 ;; esac
  NS_PF5="$rest"
}

# _ns_policy_pick <list> <key> — the remainder of the first "key<TAB>rest" line, or status 1.
_ns_policy_pick() {
  local rest="$1" line
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    case "$line" in
      "$2$NS_POLICY_TAB"*)
        printf '%s' "${line#*"$NS_POLICY_TAB"}"
        return 0
        ;;
    esac
  done
  return 1
}

# _ns_policy_has <list> <exact-line> — exact membership in a newline-terminated list.
_ns_policy_has() {
  case "$NS_POLICY_NL$1" in
    *"$NS_POLICY_NL$2$NS_POLICY_NL"*) return 0 ;;
  esac
  return 1
}

# _ns_json_uint <compact-json> — status 0 when the text is a non-negative whole number.
_ns_json_uint() {
  case "$1" in
    '' | *[!0-9]*) return 1 ;;
    *) return 0 ;;
  esac
}

# _ns_json_hex <compact-json> <length> — status 0 for a JSON string of exactly that many
# lowercase hex characters.
_ns_json_hex() {
  local s="$1"
  case "$s" in
    '"'*'"') ;;
    *) return 1 ;;
  esac
  s="${s#\"}"
  s="${s%\"}"
  case "$s" in *[!0-9a-f]*) return 1 ;; esac
  [ "${#s}" -eq "$2" ]
}

# ns_policy_json_tool — jq, else python3, else status 2. One place decides, so every caller
# reports the same prerequisite.
ns_policy_json_tool() {
  if command -v jq >/dev/null 2>&1; then
    printf 'jq'
    return 0
  fi
  if command -v python3 >/dev/null 2>&1; then
    printf 'python3'
    return 0
  fi
  return 2
}

# ns_policy_normalize_command <text> — the normal form both halves of an exact-plan allowance
# agree on: every run of whitespace becomes one space, and the ends are trimmed.
ns_policy_normalize_command() {
  printf '%s' "$1" | tr '\t\n\v\f\r' '     ' | sed -e 's/  */ /g' -e 's/^ //' -e 's/ $//'
}

# ns_policy_json_string <text> — the text as a JSON string, in this backend's own escaping. The
# preimage it goes into is canonicalized before it is digested, so both backends agree there.
ns_policy_json_string() {
  local tool
  tool="$(ns_policy_json_tool)" || return 1
  if [ "$tool" = jq ]; then
    printf '%s' "$1" | jq -Rs . 2>/dev/null || return 1
  else
    printf '%s' "$1" | python3 -c 'import json, sys
sys.stdout.write(json.dumps(sys.stdin.read(), ensure_ascii=False))' 2>/dev/null || return 1
  fi
}

# ns_policy_sha256_text — lowercase hex digest of stdin. Status 1 when the host has no digest
# tool, which fails an exact-plan match closed rather than waving it through.
ns_policy_sha256_text() {
  local line
  if command -v sha256sum >/dev/null 2>&1; then
    line="$(sha256sum)" || return 1
    printf '%s' "${line%% *}"
  elif command -v shasum >/dev/null 2>&1; then
    line="$(shasum -a 256)" || return 1
    printf '%s' "${line%% *}"
  elif command -v openssl >/dev/null 2>&1; then
    line="$(openssl dgst -sha256)" || return 1
    printf '%s' "${line##* }"
  else
    return 1
  fi
}


NS_POLICY_SHIFT_PY='
import json, re, sys

SCALARS = ["schemaVersion", "shiftId", "createdAt", "source", "deadlineEpoch",
           "verificationLevel", "toolingPolicy", "launchScope", "launchProvenance",
           "completionMode", "gatesDigest", "contractDigest", "itemsDigest"]
# The owner preference blocks tonight'"'"'s snapshot freezes. A block the snapshot does not carry
# emits its type and no fields, which is how a policy written before this feature is told apart
# from one whose owner left a block empty.
PREF = [("shift", ["execution", "hours", "toolingPolicy", "verificationProfile"]),
        ("recovery", ["launchScope"]),
        ("handoff", ["detail", "enabled", "language", "sections", "templatePath", "view"]),
        ("archive", ["automatic", "layout", "root", "templatePath"]),
        ("receipts", ["duration", "enabled", "progressMinutes", "progressMode",
                    "progressTokens", "templatePath", "usage"])]
out = []


def enc(v):
    return json.dumps(v, ensure_ascii=False, separators=(",", ":"))


def scrub(s):
    return re.sub(r"[\x00-\x1f\x7f]", " ", s)


def norm(s):
    return re.sub(r"[ \t\n\x0b\f\r]+", " ", s).strip(" ")


def kind(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"
    if isinstance(v, dict):
        return "object"
    return "invalid"


def obj(v):
    return v if isinstance(v, dict) else {}


def arr(v):
    return v if isinstance(v, list) else []


def jn(p, k):
    return k if p == "." else p + "." + k


def ty(p, v):
    out.append("ty\t%s\t%s" % (p, kind(v)))


def ks(p, v):
    if isinstance(v, dict):
        for k in v:
            out.append("k\t%s\t%s" % (p, scrub(k)))


def sc(p, v, names):
    for k in names:
        out.append("j\t%s\t%s" % (jn(p, k), enc(obj(v).get(k))))


def cnt(p, v):
    out.append("n\t%s\t%s" % (p, len(v) if isinstance(v, list) else -1))


try:
    P = json.load(open(sys.argv[1]))
except (OSError, ValueError):
    sys.exit(1)
if not isinstance(P, dict):
    sys.stdout.write("x\t.\tnotobject\n")
    sys.exit(0)
ks(".", P)
sc(".", P, SCALARS)
for block, fields in PREF:
    ty(block, P.get(block))
    if block in P:
        sc(block, P.get(block), fields)
ty("budgets", P.get("budgets"))
for key, value in obj(P.get("budgets")).items():
    out.append("b\t%s\t%s" % (scrub(key), enc(value)))
ty("selectedDebt", P.get("selectedDebt"))
cnt("selectedDebt", P.get("selectedDebt"))
for i, one in enumerate(arr(P.get("selectedDebt"))):
    out.append("s\t%d\t%s\t%s" % (i, "s" if isinstance(one, str) else "x", enc(one)))
ty("allowances", P.get("allowances"))
cnt("allowances", P.get("allowances"))
for i, a in enumerate(arr(P.get("allowances"))):
    ap = "allowances[%d]" % i
    ty(ap, a)
    ks(ap, a)
    sc(ap, a, ["category", "scope", "provenance"])
    plan = obj(a).get("plan")
    ty(jn(ap, "plan"), plan)
    ks(jn(ap, "plan"), obj(plan))
    sc(jn(ap, "plan"), obj(plan), ["workTarget", "digest", "expiry"])
    cs = obj(plan).get("commands")
    ty(jn(ap, "plan.commands"), cs)
    cnt(jn(ap, "plan.commands"), cs)
    for n, one in enumerate(arr(cs)):
        ok = isinstance(one, str)
        out.append("c\t%d\t%d\t%s\t%s" % (i, n, "s" if ok else "x", norm(one) if ok else ""))
        out.append("q\t%d\t%d\t%s" % (i, n, enc(norm(one)) if ok else "null"))
    ws = obj(plan).get("writeSurface")
    ty(jn(ap, "plan.writeSurface"), ws)
    for n, one in enumerate(arr(ws)):
        out.append("w\t%d\t%d\t%s" % (i, n, "s" if isinstance(one, str) else "x"))
sys.stdout.write("".join(line + "\n" for line in out))
'


NS_POLICY_DEFAULTS_PY='
import json, sys

K = ["schemaVersion", "verificationProfile", "hours", "toolingPolicy", "execution", "updatedAt"]
try:
    D = json.load(open(sys.argv[1]))
except (OSError, ValueError):
    sys.exit(1)
if not isinstance(D, dict):
    sys.stdout.write("x\t.\tnotobject\n")
    sys.exit(0)
sys.stdout.write("".join(
    "d\t%s\t%s\t%s\n" % (k, "1" if k in D else "0",
                         json.dumps(D.get(k), ensure_ascii=False, separators=(",", ":")))
    for k in K))
'

# _ns_policy_facts <operation> <python-program> <file> — the fact stream on stdout.
# Status 1 when the file is not JSON, 2 when no parser is installed.
_ns_policy_facts() {
  local tool win bin mode
  tool="$(ns_policy_json_tool)" || {
    # The snapshot carries what the owner chose for tonight — a deadline, a verification
    # level, an elevation allowance. None of that may quietly stop applying because a host
    # has no jq and no python3, so the bounded reader emits the same fact stream.
    case "$1" in
      shift) mode=policy ;;
      defaults) mode=defaults ;;
      *) return 2 ;;
    esac
    bin="$(ns_rules_awk_bin)" || return 2
    "$bin" -v mode="$mode" -f "$_NS_RULES_AWK_FILE" <"$3" 2>/dev/null || return 1
    return 0
  }
  # Bash opens the document. jq -f still needs a path the jq binary can open:
  # Git's jq wants /d/..., native jq.exe wants D:\...
  if [ "$tool" = jq ]; then
    jq -r --arg op "$1" -f "$NS_POLICY_EMIT_JQ" <"$3" 2>/dev/null && return 0
    win="$(ns_native_display_path "$NS_POLICY_EMIT_JQ")"
    jq -r --arg op "$1" -f "$win" <"$3" 2>/dev/null || return 1
    return 0
  fi
  python3 -c "$2" "$3" 2>/dev/null && return 0
  python3 -c "$2" "$(ns_native_display_path "$3")" 2>/dev/null || return 1
}

# _ns_policy_awk_json <mode> [file] — the bounded reader as the third engine behind the two
# canonical writers. LC_ALL=C so the reader sees bytes, which is what its UTF-8 escaping needs.
_ns_policy_awk_json() {
  local bin mode="$1"
  bin="$(ns_rules_awk_bin)" || return 2
  shift
  if [ "$#" -ge 1 ]; then
    LC_ALL=C "$bin" -v mode="$mode" -f "$_NS_RULES_AWK_FILE" <"$1" 2>/dev/null || return 1
  else
    LC_ALL=C "$bin" -v mode="$mode" -f "$_NS_RULES_AWK_FILE" 2>/dev/null || return 1
  fi
}

# ns_policy_canon_json <file> — the document as compact canonical JSON: sorted keys, \uXXXX
# escaping. The one wire form the bash and PowerShell resolvers both emit.
ns_policy_canon_json() {
  local tool
  tool="$(ns_policy_json_tool)" || {
    _ns_policy_awk_json canon "$1"
    return $?
  }
  if [ "$tool" = jq ]; then
    jq -caS . <"$1" 2>/dev/null || return 1
  else
    python3 -c 'import json, sys
sys.stdout.write(json.dumps(json.load(sys.stdin), sort_keys=True, separators=(",", ":")) + "\n")' \
      <"$1" 2>/dev/null || return 1
  fi
}

# ns_policy_canon_text — compact canonical JSON of the document on stdin.
ns_policy_canon_text() {
  local tool
  tool="$(ns_policy_json_tool)" || {
    _ns_policy_awk_json canon
    return $?
  }
  if [ "$tool" = jq ]; then
    jq -caS . 2>/dev/null || return 1
  else
    python3 -c 'import json, sys
sys.stdout.write(json.dumps(json.load(sys.stdin), sort_keys=True, separators=(",", ":")) + "\n")' \
      2>/dev/null || return 1
  fi
}

# ns_policy_pretty_text — sorted, indented JSON of the document on stdin, for a file the owner
# opens and edits by hand.
ns_policy_pretty_text() {
  local tool
  tool="$(ns_policy_json_tool)" || {
    _ns_policy_awk_json pretty
    return $?
  }
  if [ "$tool" = jq ]; then
    jq -S . 2>/dev/null || return 1
  else
    python3 -c 'import json, sys
sys.stdout.write(json.dumps(json.load(sys.stdin), sort_keys=True, indent=2) + "\n")' \
      2>/dev/null || return 1
  fi
}

# ---------------------------------------------------------------- rules.json

_ns_policy_load_rules() {
  local ws="$1" f facts line rc
  NS_POLICY_RULES_STATE=""
  NS_POLICY_RULES_VALS=""
  NS_POLICY_RULES_ELEV=""
  NS_POLICY_RULES_PAT=""
  ns_layout_set f "$ws/.nightshift" rules
  if [ ! -f "$f" ]; then
    NS_POLICY_RULES_STATE=absent
    return 0
  fi
  facts="$(ns_rules_facts "$f")"
  rc=$?
  if [ "$rc" -ne 0 ]; then
    NS_POLICY_RULES_STATE=malformed
    return 0
  fi
  NS_POLICY_RULES_STATE=ok
  while IFS= read -r line; do
    [ -n "$line" ] || continue
    _ns_pf_split "$line"
    case "$NS_PF1" in
      r | g) NS_POLICY_RULES_VALS="$NS_POLICY_RULES_VALS$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_TAB$NS_PF4$NS_POLICY_NL" ;;
      e) NS_POLICY_RULES_ELEV="$NS_POLICY_RULES_ELEV$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_TAB$NS_PF4$NS_POLICY_NL" ;;
      p) NS_POLICY_RULES_PAT="$NS_POLICY_RULES_PAT$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_NL" ;;
    esac
  done <<EOF
$facts
EOF
}

# _ns_policy_pattern_of <category> — the one place a category's pattern is chosen. The rules must
# already be loaded. Both accessors come through here, so they cannot answer differently.
_ns_policy_pattern_of() {
  local pat
  if [ "$NS_POLICY_RULES_STATE" = ok ]; then
    pat="$(_ns_policy_pick "$NS_POLICY_RULES_PAT" "$1")" || pat=""
    if [ -n "$pat" ]; then
      printf '%s' "$pat"
      return 0
    fi
  fi
  ns_policy_default_pattern "$1"
}

# ns_policy_elevation_pattern <workspace> <category>
# The grep -E the guard and the preflight both match against: the owner's pattern when rules.json
# carries one, the shipped pattern otherwise. Status 1 on an unknown category.
# An unreadable rules file falls back to the shipped pattern; an owner pattern that grep -E will
# not accept comes back verbatim, because whether an invalid pattern denies or warns is the
# caller's decision, not this accessor's.
ns_policy_elevation_pattern() {
  ns_policy_default_pattern "$2" >/dev/null || return 1
  _ns_policy_load_rules "$1"
  _ns_policy_pattern_of "$2"
}

# ns_policy_elevation_patterns <workspace>
# Every category's pattern from ONE parse of the rules: `category<TAB>pattern`, one line each, in
# the fixed category order. A guard that has to test all five categories per command reads this
# instead of asking five times. Patterns are control-scrubbed as they are read, so a line never
# carries a tab or a newline of its own and the split is unambiguous.
ns_policy_elevation_patterns() {
  local category
  _ns_policy_load_rules "$1"
  while IFS= read -r category; do
    [ -n "$category" ] || continue
    printf '%s%s' "$category" "$NS_POLICY_TAB"
    _ns_policy_pattern_of "$category" || return 1
    printf '\n'
  done <<EOF
$NS_POLICY_CATEGORIES
EOF
}

# ---------------------------------------------------------------- shift-policy.json

# _ns_policy_shift_fail <field> <reason> — the first violation, named, and validation stops.
_ns_policy_shift_fail() {
  NS_POLICY_SHIFT_STATE=malformed
  NS_POLICY_SHIFT_ERR="$1 $2"
}

# _ns_policy_keys_ok <path> <allowed-list> — every key at that path is one of the allowed names.
_ns_policy_keys_ok() {
  local path="$1" allowed="$2" rest line key
  rest="$NS_POLICY_SHIFT_KEYS"
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    case "$line" in
      "$path$NS_POLICY_TAB"*)
        key="${line#*"$NS_POLICY_TAB"}"
        _ns_policy_has "$allowed$NS_POLICY_NL" "$key" || {
          NS_POLICY_SHIFT_ERR="$key"
          return 1
        }
        ;;
    esac
  done
  return 0
}

_ns_policy_check_plan() {
  local i="$1" ap="$2" rest line count seen
  _ns_policy_keys_ok "$ap.plan" "commands
workTarget
digest
writeSurface
expiry" || {
    _ns_policy_shift_fail "$ap.plan" "has an unknown field: $NS_POLICY_SHIFT_ERR"
    return 1
  }
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.plan.workTarget")" in
    '""' | '"'*'"') ;;
    *)
      _ns_policy_shift_fail "$ap.plan.workTarget" "must be the resolved work target path"
      return 1
      ;;
  esac
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.plan.workTarget")" in
    '""')
      _ns_policy_shift_fail "$ap.plan.workTarget" "must be the resolved work target path"
      return 1
      ;;
  esac
  _ns_json_hex "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.plan.digest")" 64 || {
    _ns_policy_shift_fail "$ap.plan.digest" "must be 64 lowercase hex characters"
    return 1
  }
  line="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.plan.expiry")"
  if [ "$line" != null ] && ! _ns_json_uint "$line"; then
    _ns_policy_shift_fail "$ap.plan.expiry" "must be a UNIX epoch or null"
    return 1
  fi
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" "$ap.plan.writeSurface")" in
    null | array) ;;
    *)
      _ns_policy_shift_fail "$ap.plan.writeSurface" "must be an array of paths"
      return 1
      ;;
  esac
  rest="$NS_POLICY_SHIFT_SURFACE"
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    _ns_pf_split "$line"
    [ "$NS_PF1" = "$i" ] || continue
    [ "$NS_PF3" = s ] || {
      _ns_policy_shift_fail "$ap.plan.writeSurface" "must be an array of paths"
      return 1
    }
  done
  [ "$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" "$ap.plan.commands")" = array ] || {
    _ns_policy_shift_fail "$ap.plan.commands" "must be an array of commands"
    return 1
  }
  count="$(_ns_policy_pick "$NS_POLICY_SHIFT_COUNTS" "$ap.plan.commands")" || count=-1
  case "$count" in '' | *[!0-9]*) count=-1 ;; esac
  [ "$count" -ge 1 ] || {
    _ns_policy_shift_fail "$ap.plan.commands" "must list at least one command"
    return 1
  }
  seen=0
  rest="$NS_POLICY_SHIFT_CMDS"
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    _ns_pf_split "$line"
    [ "$NS_PF1" = "$i" ] || continue
    if [ "$NS_PF3" != s ] || [ -z "$NS_PF4" ]; then
      _ns_policy_shift_fail "$ap.plan.commands" "must hold non-empty command strings"
      return 1
    fi
    seen=$((seen + 1))
  done
  [ "$seen" -eq "$count" ] || {
    _ns_policy_shift_fail "$ap.plan.commands" "must hold non-empty command strings"
    return 1
  }
  return 0
}

_ns_policy_check_allowance() {
  local i="$1" category scope provenance plan
  local ap="allowances[$i]"
  [ "$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" "$ap")" = object ] || {
    _ns_policy_shift_fail "$ap" "must be an object"
    return 1
  }
  _ns_policy_keys_ok "$ap" "category
scope
provenance
plan" || {
    _ns_policy_shift_fail "$ap" "has an unknown field: $NS_POLICY_SHIFT_ERR"
    return 1
  }
  category="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.category")"
  case "$category" in
    '"sudo"' | '"containers"' | '"global-packages"' | '"daemons"' | '"external-services"') ;;
    *)
      _ns_policy_shift_fail "$ap.category" \
        "must be sudo, containers, global-packages, daemons, or external-services"
      return 1
      ;;
  esac
  category="${category#\"}"
  category="${category%\"}"
  scope="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.scope")"
  case "$scope" in
    '"category"' | '"exact-plan"') ;;
    *)
      _ns_policy_shift_fail "$ap.scope" "must be category or exact-plan"
      return 1
      ;;
  esac
  provenance="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.provenance")"
  case "$provenance" in
    '"rules"' | '"one-shift"') ;;
    *)
      _ns_policy_shift_fail "$ap.provenance" "must be rules or one-shift"
      return 1
      ;;
  esac
  provenance="${provenance#\"}"
  provenance="${provenance%\"}"
  plan="$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" "$ap.plan")"
  if [ "$scope" = '"category"' ]; then
    [ "$plan" = null ] || {
      _ns_policy_shift_fail "$ap.plan" "belongs only to an exact-plan allowance"
      return 1
    }
    NS_POLICY_SHIFT_ALLOW="$NS_POLICY_SHIFT_ALLOW$category$NS_POLICY_TAB$provenance$NS_POLICY_NL"
    return 0
  fi
  [ "$plan" = object ] || {
    _ns_policy_shift_fail "$ap.plan" "is required by an exact-plan allowance"
    return 1
  }
  _ns_policy_check_plan "$i" "$ap" || return 1
  NS_POLICY_SHIFT_PLAN="$NS_POLICY_SHIFT_PLAN$category$NS_POLICY_TAB$provenance$NS_POLICY_NL"
  NS_POLICY_SHIFT_PLANIDX="$NS_POLICY_SHIFT_PLANIDX$category$NS_POLICY_TAB$i$NS_POLICY_NL"
  return 0
}

_ns_policy_validate_shift() {
  local rest line key val count i
  _ns_policy_keys_ok . "$NS_POLICY_SHIFT_FIELDS" || {
    _ns_policy_shift_fail "$NS_POLICY_SHIFT_ERR" "is not a shift-policy field"
    return 1
  }
  for key in schemaVersion shiftId createdAt source verificationLevel toolingPolicy; do
    _ns_policy_has "$NS_POLICY_SHIFT_KEYS" ".$NS_POLICY_TAB$key" || {
      _ns_policy_shift_fail "$key" "is missing"
      return 1
    }
  done
  [ "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" schemaVersion)" = 1 ] || {
    _ns_policy_shift_fail schemaVersion "must be 1"
    return 1
  }
  val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" shiftId)"
  case "$val" in
    '"'*'"')
      val="${val#\"}"
      val="${val%\"}"
      ;;
    *) val="" ;;
  esac
  case "$val" in *[!0-9a-f-]*) val="" ;; esac
  case "${#val}" in
    16 | 36) ;;
    *)
      _ns_policy_shift_fail shiftId "must be 16 lowercase hex characters or a UUID"
      return 1
      ;;
  esac
  NS_POLICY_SHIFT_ID="$val"
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" createdAt)" in
    '""' | '"'*'"') ;;
    *)
      _ns_policy_shift_fail createdAt "must be a UTC timestamp"
      return 1
      ;;
  esac
  [ "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" createdAt)" != '""' ] || {
    _ns_policy_shift_fail createdAt "must be a UTC timestamp"
    return 1
  }
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" source)" in
    '"composition"' | '"start-defaults"') ;;
    *)
      _ns_policy_shift_fail source "must be composition or start-defaults"
      return 1
      ;;
  esac
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" verificationLevel)" in
    '"none"' | '"final"' | '"per-item"' | '"custom"') ;;
    *)
      _ns_policy_shift_fail verificationLevel "must be none, final, per-item, or custom"
      return 1
      ;;
  esac
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" toolingPolicy)" in
    '"existing-tools"' | '"review-missing"' | '"auto-add"') ;;
    *)
      _ns_policy_shift_fail toolingPolicy "must be existing-tools, review-missing, or auto-add"
      return 1
      ;;
  esac
  # The launch record is optional — a snapshot written before it existed is still valid — but a
  # value that is there has to be readable, because a revival inherits from it.
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" launchScope)" in
    null | '"'*'"') ;;
    *)
      _ns_policy_shift_fail launchScope "must be the host's own name for the scope, as a string"
      return 1
      ;;
  esac
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" launchProvenance)" in
    null | '"observed"' | '"unavailable"') ;;
    *)
      _ns_policy_shift_fail launchProvenance "must be observed or unavailable"
      return 1
      ;;
  esac
  val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" deadlineEpoch)"
  if [ "$val" != null ] && ! _ns_json_uint "$val"; then
    _ns_policy_shift_fail deadlineEpoch "must be a UNIX epoch or null"
    return 1
  fi
  val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" gatesDigest)"
  if [ "$val" != null ] && [ "$val" != '""' ] && ! _ns_json_hex "$val" 64; then
    _ns_policy_shift_fail gatesDigest "must be 64 lowercase hex characters"
    return 1
  fi
  val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" contractDigest)"
  if [ "$val" != null ] && [ "$val" != '""' ] && ! _ns_json_hex "$val" 64; then
    _ns_policy_shift_fail contractDigest "must be 64 lowercase hex characters"
    return 1
  fi
  val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" itemsDigest)"
  if [ "$val" != null ] && [ "$val" != '""' ] && ! _ns_json_hex "$val" 64; then
    _ns_policy_shift_fail itemsDigest "must be 64 lowercase hex characters"
    return 1
  fi
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" budgets)" in
    null | object) ;;
    *)
      _ns_policy_shift_fail budgets "must be an object of whole numbers"
      return 1
      ;;
  esac
  rest="$NS_POLICY_SHIFT_BUDGETS"
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    _ns_pf_split "$line"
    case "$NS_PF1" in
      '' | *[!A-Za-z0-9_-]*)
        _ns_policy_shift_fail budgets "has an unusable name: $NS_PF1"
        return 1
        ;;
    esac
    _ns_json_uint "$NS_PF2" || {
      _ns_policy_shift_fail "budgets.$NS_PF1" "must be a whole number"
      return 1
    }
  done
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" completionMode)" in
    null | '"clear-all"' | '"no-regression-plus-selected-debt"') ;;
    *)
      _ns_policy_shift_fail completionMode \
        "must be clear-all or no-regression-plus-selected-debt"
      return 1
      ;;
  esac
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" selectedDebt)" in
    null | array) ;;
    *)
      _ns_policy_shift_fail selectedDebt "must be an array of finding ids"
      return 1
      ;;
  esac
  rest="$NS_POLICY_SHIFT_DEBT"
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    _ns_pf_split "$line"
    if [ "$NS_PF2" != s ] || [ "$NS_PF3" = '""' ]; then
      _ns_policy_shift_fail "selectedDebt[$NS_PF1]" "must be a finding id"
      return 1
    fi
  done
  case "$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" allowances)" in
    null | array) ;;
    *)
      _ns_policy_shift_fail allowances "must be an array of allowances"
      return 1
      ;;
  esac
  count="$(_ns_policy_pick "$NS_POLICY_SHIFT_COUNTS" allowances)" || count=0
  case "$count" in '' | -* | *[!0-9]*) count=0 ;; esac
  i=0
  while [ "$i" -lt "$count" ]; do
    _ns_policy_check_allowance "$i" || return 1
    i=$((i + 1))
  done
  return 0
}

_ns_policy_load_shift_file() {
  local f="$1" facts line rc
  NS_POLICY_SHIFT_STATE=""
  NS_POLICY_SHIFT_ERR=""
  NS_POLICY_SHIFT_ID=""
  NS_POLICY_SHIFT_TYPES=""
  NS_POLICY_SHIFT_KEYS=""
  NS_POLICY_SHIFT_VALS=""
  NS_POLICY_SHIFT_COUNTS=""
  NS_POLICY_SHIFT_BUDGETS=""
  NS_POLICY_SHIFT_DEBT=""
  NS_POLICY_SHIFT_CMDS=""
  NS_POLICY_SHIFT_CMDJSON=""
  NS_POLICY_SHIFT_SURFACE=""
  NS_POLICY_SHIFT_ALLOW=""
  NS_POLICY_SHIFT_PLAN=""
  NS_POLICY_SHIFT_PLANIDX=""
  if [ ! -f "$f" ]; then
    NS_POLICY_SHIFT_STATE=absent
    return 0
  fi
  facts="$(_ns_policy_facts shift "$NS_POLICY_SHIFT_PY" "$f")"
  rc=$?
  facts="$(printf '%s' "$facts" | tr -d '\r')"
  if [ "$rc" -eq 2 ]; then
    NS_POLICY_SHIFT_STATE=noparser
    NS_POLICY_SHIFT_ERR="JSON parser unavailable; composition writes shift-policy.json and Start already has rules.json"
    return 0
  fi
  if [ "$rc" -ne 0 ]; then
    NS_POLICY_SHIFT_STATE=malformed
    NS_POLICY_SHIFT_ERR="the document is not JSON"
    return 0
  fi
  while IFS= read -r line; do
    [ -n "$line" ] || continue
    _ns_pf_split "$line"
    case "$NS_PF1" in
      x)
        NS_POLICY_SHIFT_STATE=malformed
        NS_POLICY_SHIFT_ERR="the document is not a JSON object"
        return 0
        ;;
      ty) NS_POLICY_SHIFT_TYPES="$NS_POLICY_SHIFT_TYPES$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_NL" ;;
      k) NS_POLICY_SHIFT_KEYS="$NS_POLICY_SHIFT_KEYS$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_NL" ;;
      j) NS_POLICY_SHIFT_VALS="$NS_POLICY_SHIFT_VALS$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_NL" ;;
      n) NS_POLICY_SHIFT_COUNTS="$NS_POLICY_SHIFT_COUNTS$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_NL" ;;
      b) NS_POLICY_SHIFT_BUDGETS="$NS_POLICY_SHIFT_BUDGETS$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_NL" ;;
      s) NS_POLICY_SHIFT_DEBT="$NS_POLICY_SHIFT_DEBT$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_TAB$NS_PF4$NS_POLICY_NL" ;;
      c) NS_POLICY_SHIFT_CMDS="$NS_POLICY_SHIFT_CMDS$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_TAB$NS_PF4$NS_POLICY_TAB$NS_PF5$NS_POLICY_NL" ;;
      q) NS_POLICY_SHIFT_CMDJSON="$NS_POLICY_SHIFT_CMDJSON$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_TAB$NS_PF4$NS_POLICY_NL" ;;
      w) NS_POLICY_SHIFT_SURFACE="$NS_POLICY_SHIFT_SURFACE$NS_PF2$NS_POLICY_TAB$NS_PF3$NS_POLICY_TAB$NS_PF4$NS_POLICY_NL" ;;
    esac
  done <<EOF
$facts
EOF
  NS_POLICY_SHIFT_STATE=ok
  _ns_policy_validate_shift || :
  return 0
}

_ns_policy_load_shift() {
  _ns_policy_load_shift_file "$(ns_layout_path "$1/.nightshift" shift-policy)"
}

# ns_policy_launch <workspace> <scope|provenance> — what the snapshot recorded about the scope the
# shift was started under, or empty when it recorded nothing.
ns_policy_launch() {
  local raw
  _ns_policy_load_shift "$1"
  [ "$NS_POLICY_SHIFT_STATE" = ok ] || return 1
  case "$2" in
    scope) raw="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" launchScope)" ;;
    provenance) raw="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" launchProvenance)" ;;
    *) return 1 ;;
  esac
  case "$raw" in
    '"'*'"')
      raw="${raw#\"}"
      printf '%s' "${raw%\"}"
      ;;
    *) return 1 ;;
  esac
}

# ns_policy_freeze_pref <workspace> <block> — the block as it applies tonight, compact JSON, ready
# to be written into the snapshot. Every supported field is present: the owner's value where their
# file states one, the shipped default where it does not, so the frozen block answers on its own
# and a later edit to the owner's file cannot change what tonight resolved to.
ns_policy_freeze_pref() {
  local f facts line name val out="" first=1
  ns_layout_set f "$1/.nightshift" rules
  facts=""
  if [ -f "$f" ]; then
    facts="$(ns_rules_facts "$f" 2>/dev/null)" || facts=""
  fi
  while IFS= read -r name; do
    [ -n "$name" ] || continue
    case "$name" in "$2".*) ;; *) continue ;; esac
    val=""
    while IFS= read -r line; do
      case "$line" in
        "g${NS_POLICY_TAB}${name}${NS_POLICY_TAB}1${NS_POLICY_TAB}"*)
          val="${line#*"$NS_POLICY_TAB"*"$NS_POLICY_TAB"1"$NS_POLICY_TAB"}"
          break
          ;;
      esac
    done <<FACTS
$facts
FACTS
    [ -n "$val" ] || val="$(ns_policy_builtin "$name")" || val=null
    [ "$first" -eq 1 ] || out="$out,"
    first=0
    out="$out\"${name#*.}\":$val"
  done <<KEYS
$NS_RULES_GROUP_KEYS
KEYS
  [ "$first" -eq 0 ] || return 1
  printf '{%s}' "$out"
}

# ns_policy_pref <workspace> <block> <field> — one owner preference, as it applies right now.
#
# The owner's file is where a preference is written; tonight's snapshot is where it is fixed. Once
# a shift has a policy, that policy answers: an edit to rules.json mid-shift changes the next
# shift, not the one running, so a report cadence or an archive destination cannot move under a
# night that already started. Composition is the authorized update path, and it writes a new
# snapshot.
#
# Three cases, and none of them is a silent fallback:
#
#   the snapshot froze the block   its value, whatever the owner has since written
#   the snapshot predates this     the owner's file, because there is nothing frozen to honour
#   the snapshot cannot be read    empty, so the caller takes its own built-in default — never
#                                  the mutable file the unreadable snapshot was supposed to fix
#
# With no snapshot at all there is no shift to fix anything for, and the owner's file answers.
ns_policy_pref() {
  local kind raw f ended
  _ns_policy_load_shift "$1"
  case "$NS_POLICY_SHIFT_STATE" in
    ok)
      kind="$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" "$2")" || kind=""
      if [ "$kind" = object ]; then
        raw="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$2.$3")" || raw=null
        [ "$raw" = null ] && return 0
        ns_json_text "$raw"
        return 0
      fi
      ;;
    malformed | noparser) return 0 ;;
    absent)
      # The shift ended and its policy was archived. Where it files is still its own decision, so
      # the ending marker answers for the two settings a later Archive needs.
      ended=""
      case "$2.$3" in
        archive.root) ended="$(ns_ended_field "$1" archiveRoot)" ;;
        archive.layout) ended="$(ns_ended_field "$1" archiveLayout)" ;;
      esac
      if [ -n "$ended" ]; then
        printf '%s' "$ended"
        return 0
      fi
      ;;
  esac
  ns_layout_set f "$1/.nightshift" rules
  [ -f "$f" ] || return 0
  ns_rules_get_in "$f" "$2" "$3"
}

# ns_policy_validate_shift_file <file>
# Status 0 the document is a valid shift policy · 2 prints one diagnostic naming the offending
# field · 3 the file is absent · 4 no JSON parser is installed. Composition validates a candidate
# before it becomes tonight's policy; Doctor validates the file already on disk.
ns_policy_validate_shift_file() {
  _ns_policy_load_shift_file "$1"
  case "$NS_POLICY_SHIFT_STATE" in
    absent) return 3 ;;
    noparser) return 4 ;;
    malformed)
      printf '%s\n' "$NS_POLICY_SHIFT_ERR"
      return 2
      ;;
  esac
  return 0
}

# ns_policy_read_shift <workspace>
# Status 0 prints the policy as compact canonical JSON · 2 prints one diagnostic naming the
# offending field · 3 the file is absent · 4 no JSON parser is installed.
ns_policy_read_shift() {
  _ns_policy_load_shift "$1"
  case "$NS_POLICY_SHIFT_STATE" in
    absent) return 3 ;;
    noparser) return 4 ;;
    malformed)
      printf '%s\n' "$NS_POLICY_SHIFT_ERR"
      return 2
      ;;
  esac
  ns_policy_canon_json "$(ns_layout_path "$1/.nightshift" shift-policy)" || return 2
}

# ns_policy_deadline_epoch <workspace>
# The shift policy's quitting time. Status 1 when the policy carries none: the .nightshift/run/deadline
# file is a projection of this value, never a second authority.
ns_policy_deadline_epoch() {
  local val
  _ns_policy_load_shift "$1"
  [ "$NS_POLICY_SHIFT_STATE" = ok ] || return 1
  val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" deadlineEpoch)" || return 1
  _ns_json_uint "$val" || return 1
  printf '%s' "$val"
}

# ns_policy_shift_field <workspace> <name> — one recorded string field of tonight's snapshot, or
# nothing. Used for the digests the gate compares the punch list against; a policy that predates a
# field simply has nothing to say about it, which is not the same as a mismatch.
ns_policy_shift_field() {
  local raw
  _ns_policy_load_shift "$1"
  [ "$NS_POLICY_SHIFT_STATE" = ok ] || return 1
  raw="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$2")" || return 1
  case "$raw" in
    '"'*'"')
      raw="${raw#\"}"
      printf '%s' "${raw%\"}"
      ;;
    *) return 1 ;;
  esac
}

# ns_policy_shift_id <workspace> — the identity this shift's allowances are bound to.
ns_policy_shift_id() {
  _ns_policy_load_shift "$1"
  [ "$NS_POLICY_SHIFT_STATE" = ok ] || return 1
  [ -n "$NS_POLICY_SHIFT_ID" ] || return 1
  printf '%s' "$NS_POLICY_SHIFT_ID"
}

# ns_policy_replayed <workspace> — the archived copy of tonight's snapshot, when the archive has
# already filed a shift under its id: the policy in the folder that shift claimed, or a
# `shift-policy-<id>.json` an earlier version filed anywhere under the archive root. Status 1,
# printing nothing, when the snapshot is unreadable, carries no id, or has never run.
ns_policy_replayed() {
  local id found
  id="$(ns_policy_shift_id "$1")" || return 1
  # A 16-hex token or a UUID, as the schema allows; nothing that could reach a pattern.
  case "$id" in *[!0-9a-f-]*) return 1 ;; esac
  found="$(ns_archived_policy "$1" "$id")"
  [ -n "$found" ] || return 1
  printf '%s' "$found"
}

# ns_policy_completion_mode <workspace>
# How the evidence comparison scores tonight: clear-all, or no-regression-plus-selected-debt.
# An absent, unreadable or silent policy is clear-all — the strict mode is the floor, and a
# broken file never widens it. The mode is not a resolved setting: it scores findings, it grants
# nothing, so the resolved view does not report it.
ns_policy_completion_mode() {
  local val
  _ns_policy_load_shift "$1"
  if [ "$NS_POLICY_SHIFT_STATE" = ok ]; then
    val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" completionMode)" || val=null
    if [ "$val" = '"no-regression-plus-selected-debt"' ]; then
      printf 'no-regression-plus-selected-debt'
      return 0
    fi
  fi
  printf 'clear-all'
}

# ns_policy_selected_debt <workspace>
# The finding ids the owner accepted as tonight's debt, one compact JSON string per line, in the
# order the policy lists them. A JSON string carries no raw newline, so the list stays line-safe
# and a caller matches an id without unescaping it. Empty when the policy names none.
ns_policy_selected_debt() {
  local rest line
  _ns_policy_load_shift "$1"
  [ "$NS_POLICY_SHIFT_STATE" = ok ] || return 0
  rest="$NS_POLICY_SHIFT_DEBT"
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    [ -n "$line" ] || continue
    _ns_pf_split "$line"
    [ "$NS_PF2" = s ] || continue
    printf '%s\n' "$NS_PF3"
  done
}

# ---------------------------------------------------------------- shift-defaults.json

# ns_policy_read_defaults <workspace>
# The effective remembered choices as compact canonical JSON. Status 0 when the file is absent or
# valid, 1 when it is malformed — either way the built-in defaults are printed, because these
# choices only prefill a question and must never decide anything.
ns_policy_read_defaults() {
  local ws="$1" f facts line rc bad=0 canon v
  local profile='"fast"' hours=null tooling='"existing-tools"'
  local execution='"review-first"' updated=null
  ns_layout_set f "$ws/.nightshift" shift-defaults
  if [ -f "$f" ]; then
    facts="$(_ns_policy_facts defaults "$NS_POLICY_DEFAULTS_PY" "$f")"
    rc=$?
    facts="$(printf '%s' "$facts" | tr -d '\r')"
    if [ "$rc" -ne 0 ]; then
      bad=1
    else
      while IFS= read -r line; do
        [ -n "$line" ] || continue
        _ns_pf_split "$line"
        [ "$NS_PF1" = d ] || bad=1
        [ "$NS_PF3" = 1 ] || continue
        case "$NS_PF2" in
          schemaVersion) [ "$NS_PF4" = 1 ] || bad=1 ;;
          verificationProfile)
            case "$NS_PF4" in
              '"fast"' | '"balanced"' | '"strict"' | '"custom"') profile="$NS_PF4" ;;
              *) bad=1 ;;
            esac
            ;;
          hours)
            if [ "$NS_PF4" = null ]; then
              hours=null
            elif _ns_json_uint "$NS_PF4"; then
              hours="$NS_PF4"
            else
              bad=1
            fi
            ;;
          toolingPolicy)
            case "$NS_PF4" in
              '"existing-tools"' | '"review-missing"' | '"auto-add"') tooling="$NS_PF4" ;;
              *) bad=1 ;;
            esac
            ;;
          execution)
            case "$NS_PF4" in
              '"review-first"' | '"run-direct"') execution="$NS_PF4" ;;
              *) bad=1 ;;
            esac
            ;;
          updatedAt)
            case "$NS_PF4" in
              '"'*'"') updated="$NS_PF4" ;;
              *) bad=1 ;;
            esac
            ;;
        esac
      done <<EOF
$facts
EOF
    fi
  fi
  if [ "$bad" -ne 0 ]; then
    profile='"fast"'
    hours=null
    tooling='"existing-tools"'
    execution='"review-first"'
    updated=null
  fi
  # The shift block of the owner file is where these live now. A value stated there is the
  # owner's answer and wins over the older file, which stays readable only so a workspace that
  # has not migrated yet still reports the choice it remembers.
  ns_layout_set canon "$ws/.nightshift" rules
  if [ -f "$canon" ]; then
    v="$(ns_rules_get_in "$canon" shift verificationProfile)"
    case "$v" in
      fast | balanced | strict | custom) profile="\"$v\"" ;;
    esac
    v="$(ns_rules_get_in "$canon" shift hours)"
    case "$v" in
      null) hours=null ;;
      '' | *[!0-9]*) ;;
      *) hours="$v" ;;
    esac
    v="$(ns_rules_get_in "$canon" shift toolingPolicy)"
    case "$v" in
      existing-tools | review-missing | auto-add) tooling="\"$v\"" ;;
    esac
    v="$(ns_rules_get_in "$canon" shift execution)"
    case "$v" in
      review-first | run-direct) execution="\"$v\"" ;;
    esac
  fi
  NS_POLICY_DEF_PROFILE="$profile"
  NS_POLICY_DEF_HOURS="$hours"
  NS_POLICY_DEF_TOOLING="$tooling"
  NS_POLICY_DEF_EXECUTION="$execution"
  NS_POLICY_DEF_UPDATED="$updated"
  ns_policy_defaults_json || return 2
  [ "$bad" -eq 0 ]
}

# ns_policy_defaults_stated <workspace> <field> — the value the legacy file explicitly states
# for that field, as compact JSON. Status 1 when the file says nothing about it, which is what
# separates a remembered choice from a built-in default during a migration.
ns_policy_defaults_stated() {
  local f facts line
  ns_layout_set f "$1/.nightshift" shift-defaults
  [ -f "$f" ] || return 1
  facts="$(_ns_policy_facts defaults "$NS_POLICY_DEFAULTS_PY" "$f")" || return 1
  facts="$(printf '%s' "$facts" | tr -d '\r')"
  while IFS= read -r line; do
    [ -n "$line" ] || continue
    _ns_pf_split "$line"
    [ "$NS_PF1" = d ] || continue
    [ "$NS_PF2" = "$2" ] || continue
    [ "$NS_PF3" = 1 ] || return 1
    printf '%s' "$NS_PF4"
    return 0
  done <<EOF
$facts
EOF
  return 1
}

# ns_policy_defaults_json — the NS_POLICY_DEF_* fields as compact canonical JSON.
ns_policy_defaults_json() {
  printf '{"execution":%s,"hours":%s,"schemaVersion":1,"toolingPolicy":%s,"updatedAt":%s,"verificationProfile":%s}\n' \
    "$NS_POLICY_DEF_EXECUTION" "$NS_POLICY_DEF_HOURS" "$NS_POLICY_DEF_TOOLING" \
    "$NS_POLICY_DEF_UPDATED" "$NS_POLICY_DEF_PROFILE" | ns_policy_canon_text
}

# ---------------------------------------------------------------- the resolver

# _ns_policy_setting <name> — the effective value, source, and expiry into NS_POLICY_V/S/E.
_ns_policy_setting() {
  local name="$1" category present val row
  NS_POLICY_V="$(ns_policy_builtin "$name")" || return 1
  NS_POLICY_S=built-in
  NS_POLICY_E='-'
  case "$name" in
    verificationLevel | toolingPolicy | deadlineEpoch)
      if [ "$NS_POLICY_SHIFT_STATE" = ok ]; then
        val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$name")" || val=""
        if [ -n "$val" ]; then
          NS_POLICY_V="$val"
          NS_POLICY_S=one-shift
          NS_POLICY_E='shift'
        fi
      fi
      return 0
      ;;
    elevation.*)
      category="${name#elevation.}"
      row="$(_ns_policy_pick "$NS_POLICY_SHIFT_ALLOW" "$category")" || row=""
      if [ -n "$row" ]; then
        NS_POLICY_V='"allow"'
        NS_POLICY_S="$row"
        if [ "$row" = rules ]; then NS_POLICY_E=permanent; else NS_POLICY_E='shift'; fi
        return 0
      fi
      row="$(_ns_policy_pick "$NS_POLICY_SHIFT_PLAN" "$category")" || row=""
      if [ -n "$row" ]; then
        NS_POLICY_V='"exact-plan"'
        NS_POLICY_S=exact-plan
        NS_POLICY_E='shift'
        return 0
      fi
      [ "$NS_POLICY_RULES_STATE" = ok ] || return 0
      row="$(_ns_policy_pick "$NS_POLICY_RULES_ELEV" "$category")" || return 0
      present="${row%%"$NS_POLICY_TAB"*}"
      [ "$present" = 1 ] || return 0
      val="${row#*"$NS_POLICY_TAB"}"
      NS_POLICY_S=rules
      NS_POLICY_E=permanent
      [ "$val" = allow ] || return 0
      NS_POLICY_V='"allow"'
      return 0
      ;;
  esac
  # A preference tonight's policy froze is what tonight uses, whatever the owner has since
  # written. The view says so: the source is this shift, and it expires with it.
  case "$name" in
    *.*)
      if [ "$NS_POLICY_SHIFT_STATE" = ok ]; then
        category="${name%%.*}"
        case "$category" in
          shift | recovery | handoff | archive | receipts)
            if [ "$(_ns_policy_pick "$NS_POLICY_SHIFT_TYPES" "$category")" = object ]; then
              val="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$name")" || val=""
              if [ -n "$val" ] && [ "$val" != null ]; then
                NS_POLICY_V="$val"
                NS_POLICY_S=one-shift
                NS_POLICY_E='shift'
                return 0
              fi
            fi
            ;;
        esac
      fi
      ;;
  esac
  [ "$NS_POLICY_RULES_STATE" = ok ] || return 0
  row="$(_ns_policy_pick "$NS_POLICY_RULES_VALS" "$name")" || return 0
  present="${row%%"$NS_POLICY_TAB"*}"
  val="${row#*"$NS_POLICY_TAB"}"
  [ "$present" = 1 ] || return 0
  NS_POLICY_V="$val"
  NS_POLICY_S=rules
  NS_POLICY_E=permanent
}

# ns_policy_resolve <workspace>
# The one resolved view, as compact canonical JSON:
#   {"schemaVersion":1,"settings":{"<name>":{"value":…,"source":…,"expiry":…}}}
# Precedence, row by row:
#   1. protected paths, never-commit patterns, the expected commit identity and forbiddenCommands
#      come from rules.json alone; no allowance lifts them.
#   2. an elevation category: a shift-policy allowance (rules or one-shift provenance) beats
#      rules.elevation, which beats the built-in deny. Either allowance alone lifts the deny.
#   3. an exact-plan allowance permits only its listed commands; a category allowance permits the
#      whole category; with both present the category applies.
#   4. shift-defaults.json is never the source of an effective value.
#   5. an owner preference the shift-policy froze is this shift's answer, reported one-shift and
#      expiring with the shift; the owner's file answers only where nothing was frozen.
#   6. a malformed shift-policy.json grants nothing. Elevation falls to built-in plus rules, and
#      a frozen preference falls to its built-in rather than to the file it was fixed against.
#      Either way the caller names the field.
# A key the owner wrote is the owner's answer: a setting present in rules.json reports source
# rules and expiry permanent even when its value is an empty string or a zero, and a category
# present under rules.elevation reports rules whichever way its policy points. built-in and `-`
# mean the file says nothing at all.
# Status 2 when the resolved JSON cannot be canonicalized and no fallback table applies.
ns_policy_resolve() {
  local ws="$1" out name first=1
  _ns_policy_load_rules "$ws"
  _ns_policy_load_shift "$ws"
  if [ "$NS_POLICY_SHIFT_STATE" != ok ]; then
    NS_POLICY_SHIFT_ALLOW=""
    NS_POLICY_SHIFT_PLAN=""
    NS_POLICY_SHIFT_PLANIDX=""
  fi
  out='{"schemaVersion":1,"settings":{'
  while IFS= read -r name; do
    [ -n "$name" ] || continue
    _ns_policy_setting "$name" || continue
    [ "$first" -eq 1 ] || out="$out,"
    first=0
    out="$out\"$name\":{\"value\":$NS_POLICY_V,\"source\":\"$NS_POLICY_S\",\"expiry\":\"$NS_POLICY_E\"}"
  done <<EOF
$(ns_policy_settings)
EOF
  out="$out}}"
  printf '%s\n' "$out" | ns_policy_canon_text || return 2
}

# ns_policy_resolve_table <workspace>
# The same resolved view as one line per setting, sorted by name:
#   <name>=<value> (<source>, <expiry>)
# Doctor, Status and the helper all print these lines, so the table can never drift from the JSON.
ns_policy_resolve_table() {
  local ws="$1" name text
  _ns_policy_load_rules "$ws"
  _ns_policy_load_shift "$ws"
  if [ "$NS_POLICY_SHIFT_STATE" != ok ]; then
    NS_POLICY_SHIFT_ALLOW=""
    NS_POLICY_SHIFT_PLAN=""
    NS_POLICY_SHIFT_PLANIDX=""
  fi
  while IFS= read -r name; do
    [ -n "$name" ] || continue
    _ns_policy_setting "$name" || continue
    text="$(ns_json_text "$NS_POLICY_V")"
    printf '%s=%s (%s, %s)\n' "$name" "$text" "$NS_POLICY_S" "$NS_POLICY_E"
  done <<EOF
$(ns_policy_settings)
EOF
}

# ns_new_shift_id <nightshift-dir> — a lowercase 16-hex shift id that appears nowhere under the
# archive yet, so a fresh snapshot can never be mistaken for a night already filed.
ns_new_shift_id() {
  local ns="$1" id try=0 archive
  ns_layout_set archive "$ns" archive
  while [ "$try" -lt 8 ]; do
    try=$((try + 1))
    id="$(od -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')"
    case "$id" in *[!0-9a-f]* | '') continue ;; esac
    [ "${#id}" -eq 16 ] || continue
    if [ -d "$archive" ] && grep -rqsF "$id" "$archive" 2>/dev/null; then continue; fi
    printf '%s' "$id"
    return 0
  done
  return 1
}

# ns_start_snapshot <workspace> <shift-policy-helper> — tonight's snapshot for a Start with no
# composition behind it. A composed shift has its policy written before arming; a plain Start had
# none, so nothing recorded the contract and items it armed with and the gate could not tell a
# deleted item from a finished one. This writes one with source `start-defaults` and the values the
# resolved view already shows without a policy (no gate cadence, existing tools, and the deadline
# file's epoch or none) through the same writer composition uses, which records the digests and
# freezes the owner's preference blocks. Prints the new shift id.
ns_start_snapshot() {
  local ws="$1" helper="$2" ns="$1/.nightshift" id deadline policy file
  ns_layout_set policy "$ns" shift-policy
  ns_layout_set file "$ns" deadline
  [ ! -e "$policy" ] || return 1
  id="$(ns_new_shift_id "$ns")" || return 1
  deadline="null"
  if [ -f "$file" ] && [ ! -L "$file" ]; then
    deadline="$(tr -d '[:space:]' <"$file" 2>/dev/null)"
    case "$deadline" in '' | *[!0-9]*) deadline="null" ;; esac
  fi
  printf '{"schemaVersion":1,"shiftId":"%s","createdAt":"%s","source":"start-defaults","deadlineEpoch":%s,"verificationLevel":"none","toolingPolicy":"existing-tools"}\n' \
    "$id" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$deadline" |
    bash "$helper" --project "$ws" set --from-json - >/dev/null 2>&1 || return 1
  printf '%s' "$id"
}

# _ns_policy_plan_lookup <list> <i> <n> — the value recorded for command n of allowance i.
_ns_policy_plan_lookup() {
  local rest="$1" line
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    case "$line" in
      "$2$NS_POLICY_TAB$3$NS_POLICY_TAB"*)
        printf '%s' "${line#*"$NS_POLICY_TAB"*"$NS_POLICY_TAB"}"
        return 0
        ;;
    esac
  done
  return 1
}

# _ns_policy_plan_binds <workspace> <allowance index> <normalized command>
# The five things an exact-plan allowance is bound to, all of which must still hold: the command
# is one the owner listed, the work target is the one the plan was approved against, the shift has
# not run out, the plan's own clock has not run out, and the digest still covers the same
# commands, shift and target. Any drift is a fresh owner decision, not a permission.
# The digest covers the commands, the shift and the target only — an expiry the owner shortens is
# a narrowing of an approval they already signed, not a different approval.
_ns_policy_plan_binds() {
  local ws="$1" i="$2" norm="$3" ap
  local count n found=0 raw enc cmds="" target target_json stored preimage digest
  local deadline expiry now=""
  ap="allowances[$i]"
  count="$(_ns_policy_pick "$NS_POLICY_SHIFT_COUNTS" "$ap.plan.commands")" || return 1
  _ns_json_uint "$count" || return 1
  n=0
  while [ "$n" -lt "$count" ]; do
    raw="$(_ns_policy_plan_lookup "$NS_POLICY_SHIFT_CMDS" "$i" "$n")" || return 1
    raw="${raw#*"$NS_POLICY_TAB"}"
    enc="$(_ns_policy_plan_lookup "$NS_POLICY_SHIFT_CMDJSON" "$i" "$n")" || return 1
    [ "$raw" != "$norm" ] || found=1
    cmds="$cmds${cmds:+,}$enc"
    n=$((n + 1))
  done
  [ "$found" -eq 1 ] || return 1

  target="$(ns_work_target "$ws")" || return 1
  target="$(cd -P "$target" 2>/dev/null && pwd)" || return 1
  target_json="$(ns_policy_json_string "$target")" || return 1
  stored="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.plan.workTarget")" || return 1
  [ "$target_json" = "$stored" ] || return 1

  deadline="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" deadlineEpoch)" || deadline=null
  expiry="$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.plan.expiry")" || expiry=null
  if [ "$deadline" != null ] || [ "$expiry" != null ]; then
    now="$(date +%s)"
    _ns_json_uint "$now" || return 1
  fi
  if [ "$deadline" != null ]; then
    _ns_json_uint "$deadline" || return 1
    [ "$now" -lt "$deadline" ] || return 1
  fi
  if [ "$expiry" != null ]; then
    _ns_json_uint "$expiry" || return 1
    [ "$now" -le "$expiry" ] || return 1
  fi

  preimage="$(printf '{"commands":[%s],"shiftId":"%s","workTarget":%s}' \
    "$cmds" "$NS_POLICY_SHIFT_ID" "$target_json" | ns_policy_canon_text)" || return 1
  [ -n "$preimage" ] || return 1
  digest="$(printf '%s' "$preimage" | ns_policy_sha256_text)" || return 1
  [ "\"$digest\"" = "$(_ns_policy_pick "$NS_POLICY_SHIFT_VALS" "$ap.plan.digest")" ] || return 1
  return 0
}

# ns_policy_allowed <workspace> <category> <scrubbed-command>
# Status 0 the category is allowed · 1 denied · 2 an exact-plan allowance covers the category but
# this command is not bound by it — the command, the work target, the shift clock, the plan's own
# clock, or the digest has moved. The caller matched the category pattern first; everything the
# plan was approved against is checked here, so no caller can hold half the binding.
ns_policy_allowed() {
  local ws="$1" category="$2" norm rest line
  ns_policy_default_pattern "$category" >/dev/null || return 1
  _ns_policy_load_rules "$ws"
  _ns_policy_load_shift "$ws"
  if [ "$NS_POLICY_SHIFT_STATE" != ok ]; then
    NS_POLICY_SHIFT_ALLOW=""
    NS_POLICY_SHIFT_PLAN=""
    NS_POLICY_SHIFT_PLANIDX=""
  fi
  _ns_policy_setting "elevation.$category" || return 1
  case "$NS_POLICY_V" in
    '"allow"') return 0 ;;
    '"exact-plan"') ;;
    *) return 1 ;;
  esac
  norm="$(ns_policy_normalize_command "${3:-}")"
  [ -n "$norm" ] || return 2
  rest="$NS_POLICY_SHIFT_PLANIDX"
  while [ -n "$rest" ]; do
    line="${rest%%"$NS_POLICY_NL"*}"
    case "$rest" in *"$NS_POLICY_NL"*) rest="${rest#*"$NS_POLICY_NL"}" ;; *) rest="" ;; esac
    _ns_pf_split "$line"
    [ "$NS_PF1" = "$category" ] || continue
    _ns_policy_plan_binds "$ws" "$NS_PF2" "$norm" && return 0
  done
  return 2
}

SHA-256: bef6a27182a2510108c8a34efcbbac318b4393ba4ae19c071d3593b601a52a1c