#!/usr/bin/env bash
#
# upstream-drift - agora_theme - decision D-063.
#
# THE HALF OF D-028 THAT NOTHING WAS SCHEDULED TO LOOK AT.
#
# tests/bin/shared-invariants answers "has a copy been edited HERE". Its
# `--upstream` mode answers "has the site template changed it since" - but only
# beside a sibling checkout, which a drupalci runner does not have. So that mode
# is a preflight, and the agent who built it wrote down what a preflight is
# worth:
#
#   "No automated actor has both repositories, so the only thing that can notice
#    upstream drift is a person who already suspects it. A preflight nobody is
#    obliged to run has the same detection properties as a comment."
#
# THE EVIDENCE IS NOT A WORRY, IT IS A WEEK. `--upstream` found no-secrets
# +83/-2 behind on its very first run - a `verbatim` record, so unambiguously
# behind - after a day nobody looked. Before that, identity-strings sat 28 days
# and 125 genuinely-absent lines behind its recorded commit while the default
# mode printed CLEAN, correctly, about a different question. Neither finding was
# hard. Nothing was scheduled to look.
#
# This script is what looks, on every push, with nothing checked out but this
# repository.
#
# ---------------------------------------------------------------------------
# HOW IT READS THE OTHER REPOSITORY WITHOUT A CLONE.
#
# Both projects are public on drupalcode, and the GitLab API answers anonymously
# for public projects. Measured before this file was written, not assumed:
#
#   GET /api/v4/projects/project%2Fagora_transparency             -> 200
#   GET .../repository/commits/<sha>                              -> 200
#   GET .../repository/commits/<40 zeroes>                        -> 404
#   GET .../repository/files/<the path, url-encoded>/raw?ref=<sha>  -> 200
#   GET .../repository/merge_base?refs[]=<sha>&refs[]=<branch>    -> 200
#
# and the raw blob at a recorded commit hashes to exactly the `source_sha256`
# the manifest records - checked against four records at three different
# commits before any of this was built. NO TOKEN IS USED OR NEEDED. This
# matters, and it is the reason the API is read instead of a job log: the
# /trace endpoint answers 401 to anonymous requests, so anything built on a
# CI log would have needed the maintainer's token wired into CI.
#
# ---------------------------------------------------------------------------
# BLOCKING ON PROVENANCE, REPORTING ON DELTA. This is the whole shape and it is
# deliberate in both directions.
#
# PROVENANCE BLOCKS. A record whose source_commit does not resolve over there,
# whose source_commit is not an ancestor of the canonical branch, whose path was
# not a blob at that commit, or whose source_sha256 does not match the blob
# there, is a FINDING. In every one of those cases the copy's own claim about
# itself is false, and A COMMIT IN THIS REPOSITORY CAN ALWAYS FIX IT: record a
# commit that exists, or re-derive the hash beside it.
#
# DELTA REPORTS. A record that is merely behind upstream does NOT turn this
# pipeline red. An upstream commit would otherwise make this repository red in a
# way no commit here can fix - somebody else's push, stalling unrelated work -
# and a gate that fails for a reason its author cannot address is the gate
# somebody makes permissive inside a week. That is the failure D-023(5) exists
# to refuse, and a delta is data rather than a verdict for the same reason
# `--upstream` says so: 125 of identity-strings' 132 upstream-added lines must
# NOT come across, so failing on a delta would teach the next reader to silence
# the number rather than read it.
#
# BUT A DELTA MUST NOT BE ABLE TO SIT INDEFINITELY, WHICH IS THE WHOLE
# COMPLAINT. So there is a hard cap on AGE, and past it "behind" becomes a
# finding.
#
# ---------------------------------------------------------------------------
# THE CAP IS 14 DAYS, AND BOTH THE NUMBER AND THE CLOCK ARE ARGUED.
#
# WHAT IS MEASURED IS THE AGE OF THE DRIFT, NOT THE AGE OF THE RECORD. Those
# are different numbers and only one of them means anything. Measured on
# 2026-09-21, in this manifest: no-patches sits at a commit 27 days old and
# no-unstable-deps at one 27 days old, and BOTH ARE BYTE-IDENTICAL TO THE
# BRANCH HEAD. There is nothing to review. A cap on the record's age would have
# turned those two red for having been correct for four weeks, which is the
# quickest possible way to teach someone that this check is noise. So the clock
# starts at the FIRST upstream commit that touched the path after the recorded
# one - the moment the drift began to exist - and not before.
#
# WHY 14 AND NOT ANOTHER NUMBER:
#
#   * IT MUST BE UNDER 28. The one measured blindness in this project is
#     identity-strings at 28 days, and spellcheck's source_commit stale by the
#     same 28. A cap at or above 28 would have permitted the exact failure this
#     script exists to end, which disqualifies "a month" and every number above
#     it. This is the hard bound; the rest is judgement inside it.
#   * IT MUST CLEAR A NORMAL RE-SYNC, or it punishes ordinary work. The re-syncs
#     on record: no-secrets found and answered inside a day; watch-gate re-synced
#     2026-09-02 to 2026-09-20. Every re-sync this project has completed on
#     purpose finished well inside two weeks; the two that did not finish are
#     the two nothing was watching.
#   * DAYS, NOT COMMITS. A commit count ranks a typo fix equal to a 300-line
#     rewrite, and the thing being bounded is how long a question has gone
#     unanswered, which is a duration.
#   * 14 IS HALF OF 28, and that is the sentence to remember: the one failure
#     that actually happened is caught at half the age it reached, with two
#     clear working weeks between an upstream commit landing and this repository
#     being obliged to answer it.
#
# WHAT "ANSWERING IT" MEANS, because a finding with no available remedy is the
# same trap one level down: take the change and regenerate the record, OR decide
# the copy should stay behind and move source_commit forward with the reasoning
# written into the manifest - which is what the `adapted` status is for, and
# exactly what was done for identity-strings. Both are commits in THIS
# repository. That is what makes the cap legitimate as a blocking check while a
# raw delta is not.
#
# THE CAP IS PRINTED ON EVERY RUN AND BOUND BY THE GATE. --max-age-days exists
# so each branch of this script can be falsified, and the gate never passes it:
# gate-a-theme.sh G12 asserts the cap line reads `14 days (default)`, so the
# number cannot be loosened from the call site without moving a check that a
# human has to look at. A cap nobody prints is a cap nobody can audit, and a cap
# an environment variable can raise is a cap that gets raised.
#
# ---------------------------------------------------------------------------
# NO NETWORK IS A THIRD STATE AND IT CAN NEVER READ AS A PASS.
#
# The standard is already in this repository twice - watch-gate's mirror section
# and shared-invariants --upstream's missing-sibling branch - and both negate the
# misreading in their own words rather than leaving a blank for a reader to fill
# in optimistically. This matches them: NOT READ is printed with the sentence
# that says what it is not, the run exits 2, and no per-record table of blanks
# is printed, because a table of blanks is how a scope that collapsed to nothing
# gets read as a clean result (I-007, I-029).
#
# NOT READ DOES NOT FAIL THE GATE, and that is the same argument as the delta,
# applied to the same test: a runner that cannot reach drupalcode cannot be
# fixed by a commit here either. What stops it becoming a silent permanent skip
# is that the denominators are printed and the gate reads them: `compared`,
# `read` and `unread` are three numbers in the job log on every push, the gate
# asserts read + unread == compared, and a run where nothing was read prints
# `read: 0` beside `compared: 8`. A comparison that silently examined nothing
# looks exactly like one that found nothing (I-028); these numbers are what
# makes the two look different.
#
# AN HTTP ANSWER IS NOT A TRANSPORT FAILURE, and keeping them apart is what
# makes the third state honest. A 404 on a recorded commit is the API stating a
# fact - that commit is not in the canonical repository - and it is a FINDING. A
# connection refused, a DNS failure, a 429 or a 5xx is the API saying nothing at
# all, and it is NOT READ. Collapsing the two would either make an outage look
# like corrupt provenance or make corrupt provenance look like an outage.
#
# ---------------------------------------------------------------------------
# WHAT THIS DOES NOT DO.
#
#   * It does not touch tests/bin/shared-invariants. That script and every other
#     invariant in both repositories is OFFLINE, and that property is worth more
#     than this check: an invariant that needs the network can be defeated by
#     unplugging something. The network path is a separate file and it is opt-in;
#     CI is what opts in.
#   * It does not add a CI job. It runs inside agora-invariants, through
#     gate-a-theme.sh, as group G12. This repository declares ten jobs and
#     watch-gate compares the live list against that declaration in both
#     directions; an eleventh would be a decision, not a side effect.
#   * It does not read the site template's working tree, even when one is
#     sitting beside it. The canonical repository is what a reviewer can open,
#     and an uncommitted edit over there is invisible here deliberately - a
#     commit is the only thing this manifest can record.
#   * It does not retire the `adapted` category or merge the copies into a
#     package. Both are real options and both are [andres]'s.
#
# Contract (shared by every check in tests/bin/):
#   scope:    the exact set of records inspected
#   compared: N records offered by the manifest, and N must be > 0 or this fails
#   read:     how many were actually answered for, and how many were not
#   findings: M, each printed as  record:field  reason
#   exit 0 iff M == 0 and every record was read
#   exit 1 if M > 0                     (provenance, or a delta past the cap)
#   exit 2 if any record was NOT READ   (the third state; the gate tolerates it)
#
# House rules for tests/bin/ (T-321(c)): `wc -l` never `grep -c` for a compared
# value; numeric guards defaulted ONLY where zero means FAIL; grep rc >= 2 is an
# error, never "no match"; never `-F` with `-i`.
#
# Usage: tests/bin/upstream-drift [--project <path>] [--branch <name>]
#                                 [--max-age-days <n>] [--manifest <path>]
#        (run from anywhere; it cd's to the repo root)

set -u
# No `set -e`: a finding must be recorded and counted, not abort the run.

# ---------------------------------------------------------------- repo root --
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
cd "$REPO_ROOT" || { echo "FATAL: cannot cd to repo root $REPO_ROOT"; exit 1; }

# ------------------------------------------------------------------ defaults --
# The canonical repository, named here rather than derived from a git remote.
# This repository's remote points at agora_theme; the subject of every record in
# the manifest is the OTHER project, and that identity is a declaration in the
# same sense as shared-invariants' DECLARED_COMMITS list.
API_HOST='https://git.drupalcode.org'
PROJECT='project/agora_transparency'
BRANCH=''            # empty means "ask the API for the default branch"
MAX_AGE_DAYS=14
MAX_AGE_DEFAULT=14
MANIFEST='tests/bin/shared-invariants.manifest'
BIN_DIR='tests/bin'
# A bound on pagination. A shared script with more than this many commits since
# its recorded one is not a situation to guess about: see the walk below, where
# hitting the bound is reported rather than silently under-counted.
MAX_PAGES=10

while [ "$#" -gt 0 ]; do
  case "$1" in
    --project) shift; [ "$#" -gt 0 ] || { echo "FATAL: --project needs a value"; exit 1; }; PROJECT=$1 ;;
    --branch)  shift; [ "$#" -gt 0 ] || { echo "FATAL: --branch needs a value"; exit 1; }; BRANCH=$1 ;;
    --manifest) shift; [ "$#" -gt 0 ] || { echo "FATAL: --manifest needs a path"; exit 1; }; MANIFEST=$1 ;;
    --max-age-days)
      shift
      [ "$#" -gt 0 ] || { echo "FATAL: --max-age-days needs a number"; exit 1; }
      case "$1" in
        ''|*[!0-9]*) echo "FATAL: --max-age-days wants a non-negative integer, got [$1]"; exit 1 ;;
      esac
      MAX_AGE_DAYS=$1 ;;
    --api-host) shift; [ "$#" -gt 0 ] || { echo "FATAL: --api-host needs a value"; exit 1; }; API_HOST=$1 ;;
    -h|--help)
      sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# \{0,1\}//'
      exit 0 ;;
    *)
      # Unknown options are refused rather than ignored. A check that silently
      # accepts a misspelled flag runs its DEFAULT and prints a clean result,
      # which is indistinguishable from the thing the reader asked for passing.
      echo "FATAL: unknown option [$1]. Run with --help."
      exit 1 ;;
  esac
  shift
done

C_OK=""; C_BAD=""; C_OFF=""; C_DIM=""
if [ -t 1 ]; then
  C_OK=$'\033[32m'; C_BAD=$'\033[31m'; C_OFF=$'\033[0m'; C_DIM=$'\033[2m'
fi

FINDINGS=0
finding() { # <record> <field> <reason>
  FINDINGS=$((FINDINGS + 1))
  printf '  %s%s:%s%s  %s\n' "$C_BAD" "$1" "$2" "$C_OFF" "$3"
}
note() { printf '  %s%s%s\n' "$C_DIM" "$1" "$C_OFF"; }

fatal() { # <reason> - no result at all, which is not a clean result (I-029).
  printf '\n%sFATAL:%s %s\n' "$C_BAD" "$C_OFF" "$1"
  printf 'This run has NO result, which is not a clean result (I-029).\n'
  exit 1
}

# THE THIRD STATE. Printed in words that negate the misreading, matching
# watch-gate's mirror section and shared-invariants --upstream. Never a pass.
NOT_READ_REASON=''
not_read_banner() { # <reason...>
  printf '\n  %sNOT READ%s - %s\n' "$C_BAD" "$C_OFF" "$1"
  shift
  while [ "$#" -gt 0 ]; do printf '             %s\n' "$1"; shift; done
  printf '             %sThat is not "the copies are up to date"; it is "nothing\n' "$C_BAD"
  printf '             here could reach the site template". A third state, not a pass.%s\n' "$C_OFF"
}

printf '=========================================================================================================\n'
printf 'upstream-drift - agora_theme - the copies against %s, read over the API (D-063)\n' "$PROJECT"
printf 'repo: %s\n' "$REPO_ROOT"
printf 'date: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '=========================================================================================================\n'

# ------------------------------------------------------------- the fetcher ---
# EXERCISED, NOT LOCATED (I-026). On the site template's host `python3` once
# resolved to a Store stub that satisfied `command -v` and then failed at first
# real use. Every tool below is run for real before it is trusted, and the run
# that proves the fetcher is the same call that proves the network, the API and
# the project's identity - one request, four facts.
printf '\ntooling\n'

HASHER=''
for h in sha256sum 'shasum -a 256' 'openssl dgst -sha256'; do
  # shellcheck disable=SC2086
  if printf '' | $h >/dev/null 2>&1; then HASHER=$h; break; fi
done
[ -n "$HASHER" ] || fatal 'no working sha256 tool (tried sha256sum, shasum -a 256, openssl dgst -sha256). Without one, no provenance can be verified, and an unverified provenance is not a verified one.'

sha256_of() { # <file> -> bare lowercase hex digest
  # shellcheck disable=SC2086
  $HASHER < "$1" 2>/dev/null | sed -n 's/^\(SHA2-256(stdin)= \)\{0,1\}\([0-9a-f]\{64\}\).*/\2/p' | head -1
}

# I-028: a hasher that returned the same string for every input would report
# every comparison below as a match - the degenerate case wearing the expected
# value's clothes. 'agora' is five bytes and its digest is fixed for all time.
# ONE work directory, removed on the way out. It is a directory rather than a
# registry of paths because the obvious shape - a helper that makes a temp file and
# appends to a list - DOES NOT WORK: the helper has to be called in a command
# substitution to return the path, that runs in a SUBSHELL, and the append is
# discarded with it. Every run would then leak about thirty temp files while
# looking perfectly healthy. Found by re-reading this file, not by a test.
WORK_DIR=$(mktemp -d -t upstream-drift.XXXXXX) || fatal 'mktemp -d failed - there is nowhere to put the blobs being compared.'
trap 'rm -rf "$WORK_DIR"' EXIT INT TERM
CANARY_FILE="$WORK_DIR/canary"
printf 'agora' > "$CANARY_FILE" || fatal 'could not write the canary file.'
CANARY_EXPECT='f7070d57bbe5496e29249421e91572f46ac4c2b62953b7ea046fa3707b9e6b2a'
CANARY_GOT=$(sha256_of "$CANARY_FILE")
[ "$CANARY_GOT" = "$CANARY_EXPECT" ] || fatal "the sha256 tool ($HASHER) failed its canary: hashed the 5 bytes 'agora' and returned [$CANARY_GOT], expected [$CANARY_EXPECT]."
note "hasher: $HASHER (canary passed)"

# The JSON reader. jq is exercised the same way - gate-a-theme.sh's G0 already
# does this for the gate, but this script must also be right when run alone.
HAVE_JQ=0
if [ "$(jq -n 1 2>/dev/null)" = '1' ]; then HAVE_JQ=1; fi

HAVE_CURL=0
if command -v curl >/dev/null 2>&1 && curl --version >/dev/null 2>&1; then HAVE_CURL=1; fi
note "curl: $(if [ "$HAVE_CURL" -eq 1 ]; then curl --version 2>/dev/null | head -1; else printf 'ABSENT'; fi)"
note "jq:   $(if [ "$HAVE_JQ" -eq 1 ]; then jq --version 2>/dev/null; else printf 'ABSENT'; fi)"

# ------------------------------------------------------------ read manifest --
# Parsed with the same six-field, whitespace-separated reader shared-invariants
# uses, deliberately: two parsers of one file disagree eventually, and would go
# on printing plausible numbers while they did. The OFFLINE checks - status
# against the hashes, the closed world, the declared commit set - belong to
# shared-invariants and are NOT repeated here. This script asks one question
# that one cannot: what does the other repository actually say?
[ -r "$MANIFEST" ] || fatal "$MANIFEST is missing or unreadable."

RECORDS=0
PARSED=''
while IFS= read -r line || [ -n "$line" ]; do
  case "$line" in '#'* | '') continue ;; esac
  # shellcheck disable=SC2086
  set -- $line
  [ "$#" -eq 6 ] || continue
  RECORDS=$((RECORDS + 1))
  PARSED="$PARSED$1 $3 $5 $6
"
done < "$MANIFEST"

# Zero means FAIL, so `:-0` is the safe direction (I-031). An empty manifest
# would otherwise satisfy every check below by construction (I-007).
[ "${RECORDS:-0}" -gt 0 ] || fatal "$MANIFEST parsed to zero records - a walk over nothing is not a walk that found nothing."
note "manifest: $MANIFEST ($RECORDS record(s) offered)"

# -------------------------------------------------------------- the API ------
API="$API_HOST/api/v4/projects/$(printf '%s' "$PROJECT" | sed 's|/|%2F|g')"

# api_get <url> <outfile> -> sets HTTP and TRANSPORT.
#   TRANSPORT != 0  the request never got an answer  -> NOT READ
#   HTTP == 200     an answer                        -> usable
#   HTTP == 404     an answer, and a negative one    -> a FINDING at the caller
#   HTTP 429/5xx    the server declining to answer   -> NOT READ (retried once)
# Keeping these apart is the point: a 404 on a recorded commit is the API
# stating that the commit is not in the canonical repository, which a commit
# here can fix; an outage is not.
HTTP=0
TRANSPORT=0
api_get() { # <url> <outfile>
  _u=$1; _o=$2
  _attempt=0
  while : ; do
    _attempt=$((_attempt + 1))
    HTTP=$(curl -sS --connect-timeout 15 --max-time 120 \
             -H 'Accept: application/json' \
             -o "$_o" -w '%{http_code}' "$_u" 2>/dev/null)
    TRANSPORT=$?
    [ -n "$HTTP" ] || HTTP=0
    if [ "$TRANSPORT" -eq 0 ]; then
      case "$HTTP" in
        429|5??)
          # A single retry, then it is the third state. Rate limiting and a
          # gateway wobble are both "the server said nothing about the
          # question", and this project has seen HTTP 429 from drupalcode.
          if [ "$_attempt" -lt 2 ]; then sleep 5; continue; fi ;;
      esac
    fi
    break
  done
}

# no_answer -> true when the last api_get produced no usable answer AT ALL.
# 200 and 404 are answers; a 404 is a negative one and its meaning belongs to
# the caller. Everything else - a transport failure, a 429, a 5xx, a 403 - is
# the server declining to say anything about the question, and is NOT READ.
no_answer() {
  [ "$TRANSPORT" -ne 0 ] && return 0
  case "$HTTP" in 200|404) return 1 ;; esac
  return 0
}

# ---------------------------------------------------- the preflight request --
# One request that proves four things at once: the network is reachable, the API
# answers anonymously, the project exists, and it is the project this script
# claims to be reading. The last is not decoration - a typo in --project would
# otherwise produce a confident run against something else.
printf '\nreaching %s\n' "$PROJECT"

UNREACHABLE=''
if [ "$HAVE_CURL" -eq 0 ]; then
  UNREACHABLE='curl is not installed, so no request can be made at all.'
elif [ "$HAVE_JQ" -eq 0 ]; then
  UNREACHABLE='jq is not installed, so the API answers cannot be parsed.'
fi

META=''
HEAD_SHA=''
HEAD_DATE=''
if [ -z "$UNREACHABLE" ]; then
  META="$WORK_DIR/project.json"
  api_get "$API" "$META"
  if [ "$TRANSPORT" -ne 0 ]; then
    UNREACHABLE="curl exited $TRANSPORT reaching $API_HOST - the request got no answer at all (DNS, connectivity, proxy or TLS)."
  elif [ "$HTTP" != '200' ]; then
    UNREACHABLE="$API_HOST answered HTTP $HTTP for the project metadata, so nothing below could be read."
  else
    GOT_PATH=$(jq -r '.path_with_namespace // empty' < "$META" 2>/dev/null)
    if [ "$GOT_PATH" != "$PROJECT" ]; then
      UNREACHABLE="the API answered 200 but named the project [$GOT_PATH], not [$PROJECT]. Reading on would measure the wrong repository."
    else
      [ -n "$BRANCH" ] || BRANCH=$(jq -r '.default_branch // empty' < "$META" 2>/dev/null)
      VISIBILITY=$(jq -r '.visibility // empty' < "$META" 2>/dev/null)
      note "project: $GOT_PATH (visibility: $VISIBILITY) - answered anonymously, no token used"
    fi
  fi
fi

if [ -z "$UNREACHABLE" ] && [ -z "$BRANCH" ]; then
  UNREACHABLE='the API named no default branch, so there is no HEAD to measure the recorded commits against.'
fi

# Resolve the branch tip once. Every delta below is measured against THIS sha,
# printed, so the whole table is a statement about one commit rather than about
# whatever the branch happened to be at each request.
if [ -z "$UNREACHABLE" ]; then
  HEAD_JSON="$WORK_DIR/branch.json"
  api_get "$API/repository/commits/$BRANCH" "$HEAD_JSON"
  if [ "$TRANSPORT" -ne 0 ] || [ "$HTTP" != '200' ]; then
    UNREACHABLE="the branch [$BRANCH] could not be resolved (curl $TRANSPORT, HTTP $HTTP)."
  else
    HEAD_SHA=$(jq -r '.id // empty' < "$HEAD_JSON" 2>/dev/null)
    HEAD_DATE=$(jq -r '.committed_date // .created_at // empty' < "$HEAD_JSON" 2>/dev/null)
    [ -n "$HEAD_SHA" ] || UNREACHABLE="the branch [$BRANCH] resolved to no commit id."
  fi
fi

# ---- the third state, taken before any per-record work.
if [ -n "$UNREACHABLE" ]; then
  not_read_banner \
    "$UNREACHABLE" \
    '' \
    'No per-record table is printed, on purpose: a table of blanks is how a' \
    'scope that collapsed to nothing gets read as a clean result (I-007, I-029).' \
    'The denominators below are printed anyway, so the log says how many records' \
    'went unanswered rather than leaving the reader to assume none did.'
  printf '\n=========================================================================================================\n'
  printf 'scope:    %s against %s via %s\n' "$MANIFEST" "$PROJECT" "$API_HOST"
  printf 'cap:      %s\n' "$(if [ "$MAX_AGE_DAYS" -eq "$MAX_AGE_DEFAULT" ]; then printf '%s days (default)' "$MAX_AGE_DAYS"; else printf '%s days (OVERRIDDEN with --max-age-days; the gate never passes it)' "$MAX_AGE_DAYS"; fi)"
  printf 'compared: %s record(s) offered by the manifest\n' "$RECORDS"
  printf 'read:     0 read, %s NOT READ (every record)\n' "$RECORDS"
  printf 'verified: 0 provenance re-derived - nothing was reachable to derive it from\n'
  printf 'behind:   unknown - not measured, which is not the same as zero\n'
  printf 'findings: 0 - and a findings count over an unread scope says nothing (I-028)\n'
  printf 'network:  NOT READ - %s\n' "$UNREACHABLE"
  printf '=========================================================================================================\n'
  printf '%s%s%s\n' "$C_BAD" 'upstream-drift: NOT READ - no answer for any record, which is not a clean answer for them' "$C_OFF"
  exit 2
fi

note "branch: $BRANCH at $HEAD_SHA ($HEAD_DATE)"
note "cap: $MAX_AGE_DAYS day(s) - the age of the DRIFT, measured from the first upstream commit that touched the path after the recorded one"

NOW_EPOCH=$(date -u '+%s' 2>/dev/null)
[ -n "$NOW_EPOCH" ] || fatal 'date -u +%s produced nothing, so no age can be computed and no cap can be applied.'

epoch_of() { # <iso8601> -> epoch seconds, or empty
  [ -n "$1" ] || return 0
  date -u -d "$1" '+%s' 2>/dev/null
}

# --------------------------------------------------------------- the walk ----
printf '\nper-record - provenance over the API, then the delta to %s\n' "$(printf '%s' "$HEAD_SHA" | cut -c1-8)"
printf '  %-22s %-9s %-11s %-6s %s\n' 'script' 'recorded' 'provenance' 'drift' 'delta recorded -> HEAD'
printf '  %s\n' '-------------------------------------------------------------------------------------------------'

U_READ=0
U_UNREAD=0
U_VERIFIED=0
U_BEHIND=0
U_OVER_CAP=0
U_OLDEST=0
U_UNREADABLE=''

while IFS= read -r rec; do
  [ -n "$rec" ] || continue
  # shellcheck disable=SC2086
  set -- $rec
  P_SCRIPT=$1; P_STATUS=$2; P_SOURCE=$3; P_COMMIT=$4
  P_PATH="$BIN_DIR/$P_SCRIPT"
  P_ENC=$(printf '%s' "$P_PATH" | sed 's|/|%2F|g')
  P_SHORT=$(printf '%s' "$P_COMMIT" | cut -c1-8)
  R_UNREAD=0

  # ---- 1. does the recorded commit resolve over there?
  CJ="$WORK_DIR/commit.json"
  api_get "$API/repository/commits/$P_COMMIT" "$CJ"
  if no_answer; then
    R_UNREAD=1
    printf '  %-22s %-9s %-11s %-6s %s\n' "$P_SCRIPT" "$P_SHORT" 'NOT READ' '-' "no answer (curl $TRANSPORT, HTTP $HTTP)"
  elif [ "$HTTP" = '404' ]; then
    finding "$P_SCRIPT" 'source_commit' "$API_HOST answered 404 for [$P_COMMIT] in $PROJECT. The manifest names a commit a reviewer cannot open - it was never pushed, or it was typed rather than read with git rev-parse. Provenance that only exists on one machine is not provenance."
    printf '  %-22s %-9s %-11s %-6s %s\n' "$P_SCRIPT" "$P_SHORT" 'MISSING' '-' 'the recorded commit is not in the canonical repository'
    U_READ=$((U_READ + 1))
    continue
  elif [ "$HTTP" != '200' ]; then
    R_UNREAD=1
    printf '  %-22s %-9s %-11s %-6s %s\n' "$P_SCRIPT" "$P_SHORT" 'NOT READ' '-' "unexpected HTTP $HTTP for the commit"
  fi

  if [ "$R_UNREAD" -eq 1 ]; then
    U_UNREAD=$((U_UNREAD + 1))
    U_UNREADABLE="$U_UNREADABLE $P_SCRIPT"
    continue
  fi

  C_DATE=$(jq -r '.committed_date // .created_at // empty' < "$CJ" 2>/dev/null)

  # ---- the same timestamp, encoded for use as a QUERY VALUE.
  # NOT tidiness. committed_date always carries a numeric UTC offset - `+02:00`
  # for this project in summer, `+01:00` in winter - and a raw `+` in a query
  # string is a SPACE by the application/x-www-form-urlencoded convention. The
  # server does not mis-parse the offset, it DISCARDS it and reads the remaining
  # wall clock as UTC, so the effective `since` lands one whole offset LATE.
  # Measured against project/agora_transparency on 2026-09-21, same path, same
  # branch, four spellings of two instants:
  #
  #   since=2026-09-21T09:04:46.000+02:00    -> 3 commits
  #   since=2026-09-21T09:04:46.000%2B02:00  -> 8 commits
  #   since=2026-09-21T09:04:46.000Z         -> 3 commits  <- what a raw + MEANS
  #   since=2026-09-21T07:04:46.000Z         -> 8 commits  <- the instant meant
  #
  # THE FIVE COMMITS IN BETWEEN ARE THE OLDEST ONES, which is what makes this
  # worse than a miscount. GOT arrives newest-first and OLDEST is its tail, so
  # the commits a raw `+` removes are exactly the ones that DATE the drift. The
  # age then comes out too small - under-reporting the very number the 14-day
  # cap exists to catch - and when every drift commit falls inside the discarded
  # window GOT is empty, the drift reports UNDATED, and a delta that should have
  # been a number to read becomes a finding. That is this script's own design
  # inverted: blocking on something no commit in this repository caused.
  #
  # WHY IT SURVIVED, because it is the reusable half. The discarded window is
  # exactly the size of the offset, so the defect does not exist at UTC and does
  # not exist west of Greenwich - `-` is not special in a query string and needs
  # no encoding, which was checked too. Reproducing it needs a POSITIVE offset
  # AND an upstream commit inside that window, and two earlier attempts that
  # picked boundaries outside the window measured no difference at all and were
  # right about the cases they chose.
  #
  # FALSIFIED IN BOTH DIRECTIONS, and the second direction is the one that
  # matters: this must not be a loosening. Pinning spellcheck at 5ea139ac -
  # whose drift begins 16 minutes later at 161eca41, inside the window - and
  # running the same fixture under two caps:
  #
  #                     cap 40 (drift inside)        cap 14 (drift past it)
  #   raw `+`           UNDATED, 0 commits, FINDING  UNDATED, 0 commits, FINDING
  #   encoded           27d, 1 commit, no finding    27d, 1 commit, FINDING
  #
  # The cap still bites in the right-hand column; what changed is that it now
  # bites for a reason with a date on it. The left-hand column is the bug:
  # a delta the design says is "a number to READ, not a failure" was exiting 1.
  C_SINCE=$(jq -rn --arg s "$C_DATE" '$s|@uri' 2>/dev/null)
  # An encoder that produced nothing must not widen the query to "every commit
  # ever": that would date the drift from the repository's first commit and
  # invent an over-cap finding out of thin air. The fallback is the raw value,
  # which is bounded by being exactly what shipped before this line existed.
  [ -n "$C_SINCE" ] || C_SINCE=$C_DATE
  U_READ=$((U_READ + 1))
  R_FIND_BEFORE=$FINDINGS
  PROV="${C_OK}verified${C_OFF}"

  # ---- 2. is it an ancestor of the canonical branch?
  # A commit that resolves but is not reachable from the branch is on an
  # abandoned line of development, and the manifest's claim to have copied from
  # the canonical one is false. merge_base(sha, branch) == sha iff sha is an
  # ancestor. This is a question only the other repository can answer, which is
  # why it lives here and not in the offline script.
  MJ="$WORK_DIR/merge-base.json"
  api_get "$API/repository/merge_base?refs%5B%5D=$P_COMMIT&refs%5B%5D=$BRANCH" "$MJ"
  if [ "$TRANSPORT" -eq 0 ] && [ "$HTTP" = '200' ]; then
    MB=$(jq -r '.id // empty' < "$MJ" 2>/dev/null)
    if [ -n "$MB" ] && [ "$MB" != "$P_COMMIT" ]; then
      PROV="${C_BAD}NOT ON $BRANCH${C_OFF}"
      finding "$P_SCRIPT" 'source_commit' "[$P_COMMIT] resolves in $PROJECT but is NOT an ancestor of $BRANCH (merge base is $MB). The copy was taken from a line of development the canonical branch never took, so the recorded provenance does not describe anything a reviewer reaches from $BRANCH."
    fi
  else
    # 404 here would mean the endpoint itself is unavailable, which is a
    # narrower claim than "the commit is missing" - the commit already
    # resolved above - so it is reported as unread ancestry, not as a finding.
    note "$P_SCRIPT: ancestry NOT READ (curl $TRANSPORT, HTTP $HTTP) - the other provenance checks below still ran"
  fi

  # ---- 3. the path at that commit, and its hash.
  SRC="$WORK_DIR/blob-at-record"
  api_get "$API/repository/files/$P_ENC/raw?ref=$P_COMMIT" "$SRC"
  if no_answer; then
    U_UNREAD=$((U_UNREAD + 1)); U_READ=$((U_READ - 1))
    U_UNREADABLE="$U_UNREADABLE $P_SCRIPT"
    printf '  %-22s %-9s %-11s %-6s %s\n' "$P_SCRIPT" "$P_SHORT" 'NOT READ' '-' "the blob got no answer (curl $TRANSPORT, HTTP $HTTP)"
    continue
  fi
  if [ "$HTTP" = '404' ]; then
    PROV="${C_BAD}NO BLOB${C_OFF}"
    finding "$P_SCRIPT" 'source_commit' "$P_PATH is not a file at [$P_COMMIT] in $PROJECT (HTTP 404). The record names a commit at which this path did not exist, so source_sha256 cannot have been read from it."
    printf '  %-22s %-9s %-11s %-6s %s\n' "$P_SCRIPT" "$P_SHORT" "$PROV" '-' 'nothing to compare'
    continue
  fi

  SRC_HASH=$(sha256_of "$SRC")
  if [ -z "$SRC_HASH" ]; then
    PROV="${C_BAD}NO DIGEST${C_OFF}"
    finding "$P_SCRIPT" 'source_sha256' "$HASHER produced no digest for the blob at [$P_COMMIT] - a file that cannot be hashed is NOT a file that matches."
  elif [ "$SRC_HASH" != "$P_SOURCE" ]; then
    PROV="${C_BAD}MISMATCH${C_OFF}"
    finding "$P_SCRIPT" 'source_sha256' "the blob at [$P_COMMIT] in $PROJECT hashes to [$SRC_HASH]; the manifest records [$P_SOURCE]. The recorded provenance is fiction: either the sha was typed rather than read, or the commit field was moved without re-deriving the hash beside it."
  elif [ "$FINDINGS" -eq "$R_FIND_BEFORE" ]; then
    U_VERIFIED=$((U_VERIFIED + 1))
  fi

  # ---- 4. the delta. Data, never a verdict - until the cap.
  HEAD_FILE="$WORK_DIR/blob-at-head"
  api_get "$API/repository/files/$P_ENC/raw?ref=$BRANCH" "$HEAD_FILE"
  DRIFT='-'
  if no_answer; then
    DELTA='the blob at HEAD got no answer - the delta is UNMEASURED, not zero'
  elif [ "$HTTP" = '404' ]; then
    # The path is gone upstream. That is a real change with a date on it, and the most
    # interesting delta there is, so it is reported as behind rather than as
    # an error: the copy here now has no source at all.
    U_BEHIND=$((U_BEHIND + 1))
    DELTA="${C_BAD}DELETED upstream${C_OFF} - $P_PATH no longer exists on $BRANCH"
  else
    HEAD_HASH=$(sha256_of "$HEAD_FILE")
    if [ -z "$HEAD_HASH" ]; then
      DELTA='the blob at HEAD could not be hashed - UNMEASURED, not zero'
    elif [ "$HEAD_HASH" = "$SRC_HASH" ]; then
      DELTA="${C_OK}+0/-0${C_OFF}   unchanged at HEAD"
    else
      U_BEHIND=$((U_BEHIND + 1))
      DIFF_OUT=$(diff "$SRC" "$HEAD_FILE" 2>/dev/null)
      ADDED=$(printf '%s\n' "$DIFF_OUT" | awk '/^>/ {n++} END {print n+0}')
      DELETED=$(printf '%s\n' "$DIFF_OUT" | awk '/^</ {n++} END {print n+0}')

      # ---- when did this drift start? The clock is the FIRST upstream commit
      # that touched the path after the recorded one, not the recorded one's
      # own age: a record can sit correctly at a four-week-old commit (measured
      # - no-patches and no-unstable-deps both do today).
      OLDEST=''
      N_COMMITS=0
      PAGE=1
      TRUNCATED=0
      while [ "$PAGE" -le "$MAX_PAGES" ]; do
        LJ="$WORK_DIR/commits.json"
        api_get "$API/repository/commits?path=$P_ENC&ref_name=$BRANCH&since=$C_SINCE&per_page=100&page=$PAGE" "$LJ"
        if [ "$TRANSPORT" -ne 0 ] || [ "$HTTP" != '200' ]; then N_COMMITS=-1; break; fi
        # The recorded commit itself is filtered out by id: `since` is
        # inclusive, so a commit at the same second is returned and is not
        # part of the drift.
        GOT=$(jq -r --arg self "$P_COMMIT" '.[] | select(.id != $self) | (.committed_date // .created_at)' < "$LJ" 2>/dev/null)
        PAGE_N=$(jq -r 'length' < "$LJ" 2>/dev/null)
        [ -n "$PAGE_N" ] || PAGE_N=0
        if [ -n "$GOT" ]; then
          N_COMMITS=$((N_COMMITS + $(printf '%s\n' "$GOT" | wc -l | tr -d '[:space:]')))
          OLDEST=$(printf '%s\n' "$GOT" | tail -1)
        fi
        [ "$PAGE_N" -eq 100 ] || break
        PAGE=$((PAGE + 1))
        [ "$PAGE" -gt "$MAX_PAGES" ] && TRUNCATED=1
      done

      AGE_D='?'
      if [ "$N_COMMITS" -lt 0 ]; then
        DRIFT="${C_BAD}?${C_OFF}"
        DELTA="+$ADDED/-$DELETED   behind, and the commit list got no answer - the age is UNMEASURED"
      elif [ -z "$OLDEST" ] || [ "$TRUNCATED" -eq 1 ]; then
        # Behind, but the drift cannot be dated. That can never be waved
        # through: a drift whose age cannot be measured has not been SHOWN to be
        # inside the cap, and the safe direction on a guard is to refuse (I-028).
        DRIFT="${C_BAD}UNDATED${C_OFF}"
        finding "$P_SCRIPT" 'delta' "the blob differs from $BRANCH but the first upstream commit that touched $P_PATH after [$P_COMMIT] could not be identified$(if [ "$TRUNCATED" -eq 1 ]; then printf ' (more than %s pages of commits)' "$MAX_PAGES"; fi). A drift whose age cannot be measured cannot be shown to be inside the $MAX_AGE_DAYS-day cap, and 'unknown' is not 'recent'."
        DELTA="+$ADDED/-$DELETED   across $N_COMMITS commit(s)"
      else
        O_EPOCH=$(epoch_of "$OLDEST")
        if [ -z "$O_EPOCH" ]; then
          DRIFT="${C_BAD}UNDATED${C_OFF}"
          finding "$P_SCRIPT" 'delta' "the first upstream commit after [$P_COMMIT] reports the timestamp [$OLDEST], which this host's date could not parse. The age is unmeasured, and unmeasured is not inside the cap."
          DELTA="+$ADDED/-$DELETED   across $N_COMMITS commit(s)"
        else
          AGE_D=$(( (NOW_EPOCH - O_EPOCH) / 86400 ))
          [ "$AGE_D" -gt "$U_OLDEST" ] 2>/dev/null && U_OLDEST=$AGE_D
          if [ "$AGE_D" -gt "$MAX_AGE_DAYS" ]; then
            U_OVER_CAP=$((U_OVER_CAP + 1))
            DRIFT="${C_BAD}${AGE_D}d${C_OFF}"
            finding "$P_SCRIPT" 'delta' "behind $BRANCH by +$ADDED/-$DELETED across $N_COMMITS commit(s), and the drift has stood for ${AGE_D} days - past the $MAX_AGE_DAYS-day cap. Being behind is not itself wrong; being behind unanswered for this long is. Answer it in ONE of two ways, both of them commits in THIS repository: take the change and regenerate the record, or decide the copy stays behind and move source_commit forward with the reasoning written into the manifest, which is what the 'adapted' status is for."
          else
            DRIFT="${AGE_D}d"
          fi
          DELTA="+$ADDED/-$DELETED   across $N_COMMITS commit(s) - a number to READ, not a failure"
        fi
      fi
    fi
  fi

  printf '  %-22s %-9s %-11s %-6s %s\n' "$P_SCRIPT" "$P_SHORT" "$PROV" "$DRIFT" "$DELTA"
done <<EOF
$PARSED
EOF

# A walk that considered nothing is not a walk that found nothing (I-007).
if [ "$((U_READ + U_UNREAD))" -eq 0 ]; then
  not_read_banner 'the record walk considered 0 records, so nothing above was measured.'
  exit 2
fi

printf '\n=========================================================================================================\n'
printf 'scope:    %s against %s via %s\n' "$MANIFEST" "$PROJECT" "$API_HOST"
printf 'branch:   %s at %s (%s)\n' "$BRANCH" "$HEAD_SHA" "$HEAD_DATE"
printf 'cap:      %s\n' "$(if [ "$MAX_AGE_DAYS" -eq "$MAX_AGE_DEFAULT" ]; then printf '%s days (default)' "$MAX_AGE_DAYS"; else printf '%s days (OVERRIDDEN with --max-age-days; the gate never passes it)' "$MAX_AGE_DAYS"; fi)"
printf 'compared: %s record(s) offered by the manifest\n' "$RECORDS"
printf 'read:     %s read, %s NOT READ%s\n' \
  "$U_READ" "$U_UNREAD" "$(if [ -n "$U_UNREADABLE" ]; then printf ':%s' "$U_UNREADABLE"; fi)"
printf 'verified: %s provenance re-derived from the recorded commit and matching\n' "$U_VERIFIED"
printf 'behind:   %s record(s) behind %s; oldest drift %s day(s); %s past the cap\n' \
  "$U_BEHIND" "$BRANCH" "$U_OLDEST" "$U_OVER_CAP"
printf 'findings: %s  (a delta under the cap is never one - only broken provenance, and a delta left standing too long)\n' "$FINDINGS"
printf 'network:  READ - %s answered anonymously for %s record(s); no token was used\n' "$API_HOST" "$U_READ"
printf '=========================================================================================================\n'

if [ "$FINDINGS" -ne 0 ]; then
  printf '%s%s%s\n' "$C_BAD" "upstream-drift: $FINDINGS finding(s) - each one fixable by a commit in THIS repository" "$C_OFF"
  exit 1
fi
if [ "$U_UNREAD" -ne 0 ]; then
  printf '%s%s%s\n' "$C_BAD" "upstream-drift: $U_UNREAD of $RECORDS record(s) NOT READ - this run has no answer for them, which is not a clean answer for them" "$C_OFF"
  exit 2
fi
if [ "$U_BEHIND" -ne 0 ]; then
  printf '%s%s%s\n' "$C_DIM" "upstream-drift: every record read and every provenance verified; $U_BEHIND of $RECORDS sit behind $BRANCH, none past the $MAX_AGE_DAYS-day cap. Read the deltas - a copy that SHOULD stay behind is the normal case, and the manifest's own block for each record is where that argument belongs." "$C_OFF"
  exit 0
fi
printf '%s%s%s\n' "$C_OK" 'upstream-drift: every record read, every provenance verified, no source file changed since its recorded commit' "$C_OFF"
exit 0
