#!/usr/bin/env php
<?php
/*
 * ARION AER-1 verifier (independent, PHP port, zero dependencies)
 *
 * A line-for-line port of ../verify.js — the independent reimplementation
 * of the AER-1 receipt/chain/workflow verification rules in
 * draft-zambo-aer1-09 ("AER-1: A Portable Execution Receipt for AI Agent
 * Tool Calls"), written from the draft text. PHP 8.2 CLI, stdlib only
 * (json, hash, pcre, SPL — all core; no composer, no extensions beyond a
 * stock php-cli build, no network).
 *
 * Implements:
 *   - Section 3/4/5/6  : core receipt checks (members, types, provenance,
 *                        strict base64 + strict UTF-8 canonical bytes,
 *                        output commitment recomputation)
 *   - Kit profile      : reference-producer profile (canonical payload is a
 *                        JSON object carrying an "inputs" member)
 *   - Disinterested    : optional anchor tier (log / leaf_hash / anchored_at /
 *                        proof), enforced when an anchor uses that shape
 *   - Section 7.1/7.2  : -07/-08 hash-chained job timelines (canonical-JSON
 *                        entry digest binding seq, job_id, close, id, tool,
 *                        provenance_class, output_hash; genesis, contiguity,
 *                        one-job, closing, and link rules)
 *   - Section 7.4      : the historical -06 construction is implemented as a
 *                        distinct labeled function; -06 timelines MUST NOT
 *                        verify under the -07/-08 rules
 *   - Section 7.3      : checkFinalEntryCommitment() compares a published
 *                        bare-digest commitment against the recomputed
 *                        final entry digest (-08 style); and the -09 chain
 *                        commitment artifact — verifyChainCommitment()
 *                        binds job_id / final_entry_digest / entry_count /
 *                        published_at, mirroring the kit's check verbatim
 *   - Section 8/8.1/8.2: workflow receipts, Merkle root over step receipt_ids
 *                        (leaf = SHA-256(utf8(receipt_id)), odd-level last
 *                        duplication), seq contiguity, duplicate-receipt_id
 *                        rejection
 *
 * CLI:
 *   php aer1verify.php <file-or-dir> [--strict] [--chain06]
 *     <file>  one JSON document; the kind is auto-detected:
 *             - JSON array                        -> Section 7 timeline
 *             - object type verifiable-workflow-receipt -> Section 8 workflow
 *             - object with "timeline"/"entries"  -> wrapped timeline
 *             - otherwise                         -> Section 3 receipt
 *     <dir>   every *.json file inside (recursive, sorted by path),
 *             auto-detected per file
 *     --strict  certifier-strictness profile for receipts: id must be a
 *               UUID v4 with RFC 4122 variant, receipt_schema_version must
 *               be exactly "0.3", numeric offsets are range-checked per
 *               component.  Default mode matches the kit's documented core
 *               check (lowercase UUID, any non-empty schema version).
 *     --chain06 interpret a timeline under the historical -06 construction
 *               (entry digest = SHA-256 of the decoded canonical_bytes).
 *     --profile/--anchor/--chain/--workflow/--receipt/--json as in verify.js
 *   Exit code 0 when every input verifies, 1 otherwise (2 on usage error).
 *   Every FAIL line names the first failing check.
 *
 * Disclosure: this file was written by ARION, an autonomous agent. It is a
 * port of the ARION Node.js verifier (verify.js), itself an independent
 * reimplementation from the draft text; the conformance kit's own runners
 * were consulted ONLY to pin corpus conventions the draft does not
 * specify (see REPORT.md, "Ambiguities and resolutions").
 */

/* ------------------------------------------------------------------ *
 *  Primitives                                                         *
 * ------------------------------------------------------------------ */

function sha256hex($buf) {
    return hash("sha256", $buf);
}

// Strict UTF-8 check (Section 4): non-UTF-8 canonical bytes fail closed.
// preg_match '//u' returns false on any malformed sequence (overlongs,
// lone surrogates, > U+10FFFF) — equivalent to TextDecoder{fatal:true}.
function decodeUtf8Strict($buf) {
    return preg_match("//u", $buf) === 1;
}

// Base64 surface check. The kit convention (documented in the runners) is
// "alphabet plus at most two pad chars, total length a multiple of 4";
// non-canonical trailing pad bits are tolerated (Python's
// base64.b64decode(validate=True) behaves the same). Draft -10 / kit >=
// 7b4d4a5 removed the trailing-"\n" tolerance.
// NOTE: every regex in this file carries the D modifier so PCRE "$" means
// end-of-string — matching JavaScript "$" (no /m) exactly. Without D, PCRE
// would let "...\n" pass and diverge from verify.js on the
// trailing-newline adversarial vectors.
const B64_RE        = "/^[A-Za-z0-9+\/]*={0,2}$/D";
const B64_STRICT_RE = "/^[A-Za-z0-9+\/]*={0,2}$/D";

function decodeBase64Strict($s, $strict) {
    $re = $strict ? B64_STRICT_RE : B64_RE;
    if (!is_string($s) || !preg_match($re, $s) || strlen($s) % 4 !== 0) {
        return null;
    }
    $d = base64_decode($s, true);
    return $d === false ? null : $d;
}

/* ------------------------------------------------------------------ *
 *  Field validators (Section 3)                                       *
 * ------------------------------------------------------------------ */

// Receipt id: stable UUID. Kit convention: lowercase hex. Strict mode:
// UUID v4 with the RFC 4122 variant bits (the certifier convention; the
// adversarial corpus pins it).
const UUID_LOOSE_RE = "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D";
const UUID_V4_RE    = "/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D";

// output_hash / leaf_hash: "sha256:" + 64 lowercase hex chars.
const HASH_RE = "/^sha256:[0-9a-f]{64}$/D";

// Provenance classes (Section 5): the two fixed labels, or
// "EXECUTED BY <SYSTEM>" where <SYSTEM> is the recording system's own
// uppercase name.
const PROV_RE        = "/^(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)$/D";
const PROV_STRICT_RE = "/^(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)$/D";

// RFC 3339 timestamp surface.
const RFC3339_RE        = "/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/D";
const RFC3339_STRICT_RE = "/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/D";
const TS_PARTS_RE = "/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/D";

function isLeapYear($y) {
    return ($y % 4 === 0 && $y % 100 !== 0) || $y % 400 === 0;
}
function daysInMonth($y, $m) {
    if ($m === 2) return isLeapYear($y) ? 29 : 28;
    if ($m === 4 || $m === 6 || $m === 9 || $m === 11) return 30;
    return 31;
}

// Calendar validation (Section 3: "the timestamp MUST denote a valid
// calendar date and time"). Kit mode bounds the numeric UTC offset to
// strictly less than 24h total; strict mode bounds hours <= 23 and
// minutes <= 59 per component (same code path in verify.js).
function isCalendarDateTime($s, $strict) {
    // JS s.replace("Z","+00:00") — first occurrence only.
    $s2 = preg_replace("/Z/", "+00:00", $s, 1);
    if (!preg_match(TS_PARTS_RE, $s2, $m)) return false;
    $year = (int)$m[1]; $mon = (int)$m[2]; $day = (int)$m[3];
    $hh = (int)$m[4]; $mi = (int)$m[5]; $ss = (int)$m[6];
    if ($year < 1 || $year > 9999) return false;
    if ($mon < 1 || $mon > 12) return false;
    if ($day < 1 || $day > daysInMonth($year, $mon)) return false;
    if ($hh > 23 || $mi > 59 || $ss > 59) return false;
    $off = $m[8];
    if ($off !== "Z") {
        $oh = (int)substr($off, 1, 2);
        $om = (int)substr($off, 4, 2);
        if ($oh > 23 || $om > 59) return false;
        $total = ($off[0] === "-" ? -1 : 1) * ($oh * 60 + $om);
        if (!($total > -1440 && $total < 1440)) return false;
    }
    return true;
}

function isRfc3339Format($s, $strict) {
    if (!is_string($s)) return false;
    return preg_match($strict ? RFC3339_STRICT_RE : RFC3339_RE, $s) === 1;
}
function isRfc3339($s, $strict) {
    return isRfc3339Format($s, $strict) && isCalendarDateTime($s, $strict);
}

// JSON values arrive from json_decode() WITHOUT the assoc flag, so JSON
// objects are stdClass and JSON arrays are PHP lists — no {} vs []
// ambiguity. JS isObject(v) === stdClass here.
function isObject($v) {
    return $v instanceof stdClass;
}
function isNonEmptyString($v) {
    return is_string($v) && strlen($v) > 0;
}

// JS truthiness for the `doc.timeline || doc.entries` pattern: arrays and
// objects are ALWAYS truthy in JS (even empty ones — unlike PHP).
function jsTruthy($v) {
    if ($v === null || $v === false) return false;
    if ($v === 0 || $v === 0.0 || $v === "") return false;
    return true;
}

// JS Number.isInteger: PHP ints qualify, PHP floats qualify when
// integer-valued (JSON "2.0" decodes to float 2.0 but parses to the
// integer-valued double 2 in JS).
function isJsInteger($v) {
    return is_int($v) || (is_float($v) && floor($v) === $v);
}

/* ------------------------------------------------------------------ *
 *  ECMA-262 Number::toString — needed wherever verify.js interpolates   *
 *  a number into a failure message or canonical JSON (seq, entry_count).*
 *  PHP's shortest round-trip float printer (serialize_precision=-1)     *
 *  supplies the digits; this reformats per the ECMA rules.              *
 * ------------------------------------------------------------------ */
function jsNumToString($v) {
    if (is_int($v)) {
        // PHP ints are 64-bit exact; JS numbers are doubles. A PHP int
        // beyond 2^53 would print digits JS never produces — reparse it
        // through the double so the string form matches JS (and the
        // Section 7.1 digest stays identical).
        if ($v >= -9007199254740991 && $v <= 9007199254740991) {
            return (string)$v;
        }
        $v = (float)$v;
    }
    if (is_nan($v)) return "NaN";
    if (is_infinite($v)) return $v > 0 ? "Infinity" : "-Infinity";
    if ($v == 0.0) return "0"; // covers -0.0 (JS String(-0) === "0")
    $neg = $v < 0;
    // json_encode uses serialize_precision (-1 => shortest round-trip).
    $s = json_encode(abs($v));
    if (!preg_match('/^(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/', $s, $m)) {
        return ($neg ? "-" : "") . $s; // unreachable for finite doubles
    }
    $int  = $m[1];
    $frac = isset($m[2]) ? $m[2] : "";
    $exp  = (isset($m[3]) && $m[3] !== "") ? (int)$m[3] : 0;
    // value = 0.<all> * 10^pointPos
    $all = $int . $frac;
    $pointPos = strlen($int) + $exp;
    $lead = strspn($all, "0");
    $all = substr($all, $lead);
    $pointPos -= $lead;
    $all = rtrim($all, "0");
    if ($all === "") return "0";
    $k = strlen($all);
    $n = $pointPos;
    if ($k <= $n && $n <= 21) {
        $out = $all . str_repeat("0", $n - $k);
    } elseif (0 < $n && $n <= 21) {
        $out = substr($all, 0, $n) . "." . substr($all, $n);
    } elseif (-6 < $n && $n <= 0) {
        $out = "0." . str_repeat("0", -$n) . $all;
    } elseif ($k === 1) {
        $out = $all . "e" . ($n - 1 >= 0 ? "+" : "-") . abs($n - 1);
    } else {
        $out = $all[0] . "." . substr($all, 1) . "e" .
               ($n - 1 >= 0 ? "+" : "-") . abs($n - 1);
    }
    return ($neg ? "-" : "") . $out;
}

/* ------------------------------------------------------------------ *
 *  Section 3/4/5/6 — core receipt check                               *
 *                                                                     *
 *  Returns a list of failure reasons; [] means conformant.            *
 * ------------------------------------------------------------------ */

const CORE_MEMBERS = ["id", "receipt_schema_version", "created_at", "tool",
                      "provenance_class", "canonical_bytes", "output_hash",
                      "verification_status"];

function checkReceipt($receipt, $opts) {
    $strict = !empty($opts["strict"]);
    $failures = [];
    if (!isObject($receipt)) return ["receipt is not a JSON object"];

    foreach (CORE_MEMBERS as $field) {
        if (!property_exists($receipt, $field)) {
            $failures[] = "missing required member: " . $field;
        }
    }
    if (count($failures)) return $failures;

    // id: stable UUID. Default: lowercase UUID shape (kit rule). Strict:
    // UUID v4 with RFC 4122 variant (certifier convention).
    if (!is_string($receipt->id) ||
        !preg_match($strict ? UUID_V4_RE : UUID_LOOSE_RE, $receipt->id)) {
        $failures[] = $strict ? "id is not a UUID v4"
                              : "id is not a lowercase UUID";
    }

    // receipt_schema_version: core = non-empty string; strict pins "0.3".
    if ($strict) {
        if ($receipt->receipt_schema_version !== "0.3") {
            $failures[] = "receipt_schema_version is not 0.3";
        }
    } elseif (!isNonEmptyString($receipt->receipt_schema_version)) {
        $failures[] = "receipt_schema_version is not a non-empty string";
    }

    if (!is_string($receipt->created_at) ||
        !isRfc3339Format($receipt->created_at, $strict)) {
        $failures[] = "created_at is not RFC 3339";
    } elseif (!isCalendarDateTime($receipt->created_at, $strict)) {
        // RFC 3339 shape alone is not enough: "2026-02-30" matches the
        // pattern but is not a real calendar date (Section 3). Fail closed.
        $failures[] = "created_at is not a valid calendar date/time";
    }

    $tool = $receipt->tool;
    $toolOk = isObject($tool);
    if ($toolOk) {
        foreach (["name", "version", "scope"] as $k) {
            $v = $tool->$k ?? null;
            if (!is_string($v) || strlen($v) === 0) { $toolOk = false; break; }
        }
    }
    if (!$toolOk) {
        $failures[] = "tool is not an object with name/version/scope strings";
    }

    $p = $receipt->provenance_class;
    if (!is_string($p) ||
        !preg_match($strict ? PROV_STRICT_RE : PROV_RE, $p)) {
        $failures[] = "provenance_class is not a known class";
    }

    // canonical_bytes: strict base64, then strict UTF-8 (Section 4).
    $raw = decodeBase64Strict($receipt->canonical_bytes, $strict);
    if ($raw === null) {
        $failures[] = "canonical_bytes is not valid base64";
    } elseif (!decodeUtf8Strict($raw)) {
        $failures[] = "canonical_bytes is not valid UTF-8";
        $raw = null;
    }

    // output_hash: format, then recomputation (Section 6 step 3).
    $oh = $receipt->output_hash;
    if (!is_string($oh) || !preg_match(HASH_RE, $oh)) {
        $failures[] = "output_hash is not sha256: + lowercase hex digest";
    } elseif ($raw !== null) {
        $want = "sha256:" . sha256hex($raw);
        if ($oh !== $want) {
            $failures[] = "output_hash does not match sha256(canonical_bytes)";
        }
    }

    if ($receipt->verification_status !== "verified") {
        $failures[] = "verification_status is not verified";
    }
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Reference-producer profile (kit convention): the canonical payload   *
 *  must be a JSON object carrying an "inputs" member. Core-valid        *
 *  receipts may still fail this; the boundary is intentional.           *
 * ------------------------------------------------------------------ */

function checkReferenceProfile($receipt, $opts) {
    $strict = !empty($opts["strict"]);
    if (!isObject($receipt)) return ["receipt is not a JSON object"];
    $raw = decodeBase64Strict($receipt->canonical_bytes ?? null, $strict);
    if ($raw === null) return ["canonical_bytes is not valid base64"];
    if (!decodeUtf8Strict($raw)) return ["canonical_bytes is not valid UTF-8"];
    $payload = json_decode($raw);
    if (json_last_error() !== JSON_ERROR_NONE) {
        return ["canonical payload is not JSON"];
    }
    if (!isObject($payload)) return ["canonical payload is not a JSON object"];
    if (!property_exists($payload, "inputs")) {
        return ["reference-producer payload omits the 'inputs' member"];
    }
    return [];
}

/* ------------------------------------------------------------------ *
 *  Disinterested-tier anchor check (kit convention for anchored/).      *
 *                                                                     *
 *  The draft's Section 10 anchor is opaque and additive; the kit's        *
 *  disinterested tier defines a concrete shape: anchor members exactly    *
 *  {log, leaf_hash, anchored_at, proof}, leaf_hash = "sha256:" + SHA-256  *
 *  of the raw canonical bytes. This check runs only when the receipt's    *
 *  anchor uses the tier's vocabulary (any of those member names present); *
 *  other anchor envelopes are opaque and out of scope for offline         *
 *  verification.                                                        *
 * ------------------------------------------------------------------ */

const TIER_MEMBERS = ["anchored_at", "leaf_hash", "log", "proof"];

function anchorUsesTierShape($receipt) {
    $a = isObject($receipt) ? ($receipt->anchor ?? null) : null;
    if (!isObject($a)) return false;
    foreach (TIER_MEMBERS as $k) {
        if (property_exists($a, $k)) return true;
    }
    return false;
}

function checkDisinterestedTier($receipt, $opts) {
    $strict = !empty($opts["strict"]);
    $failures = [];
    // -09: the strict id profile applies wherever the disinterested tier is
    // claimed — id MUST be a UUID v4 with RFC 4122 variant bits.
    $rid = isObject($receipt) ? ($receipt->id ?? null) : null;
    if (!is_string($rid) || !preg_match(UUID_V4_RE, $rid)) {
        $failures[] = "id is not a UUID v4 (strict profile)";
    }
    $anchor = isObject($receipt) ? ($receipt->anchor ?? null) : null;
    if (!isObject($anchor)) return ["anchor is not an object"];
    $keys = array_keys(get_object_vars($anchor));
    usort($keys, "strcmp");
    $tier = TIER_MEMBERS;
    usort($tier, "strcmp");
    if ($keys !== $tier) {
        $failures[] = "anchor has members other than log/leaf_hash/anchored_at/proof";
    }
    if (!isNonEmptyString($anchor->log ?? null)) {
        $failures[] = "anchor.log is not a non-empty string";
    }
    $leaf = $anchor->leaf_hash ?? null;
    if (!is_string($leaf) || !preg_match(HASH_RE, $leaf)) {
        $failures[] = "anchor.leaf_hash is not sha256: + lowercase hex digest";
    } else {
        $raw = decodeBase64Strict($receipt->canonical_bytes ?? null, $strict);
        if ($raw === null) {
            $failures[] = "canonical_bytes is not valid base64";
        } elseif (!decodeUtf8Strict($raw)) {
            $failures[] = "canonical_bytes is not valid UTF-8 base64";
        } else {
            $want = "sha256:" . sha256hex($raw);
            if ($leaf !== $want) {
                $failures[] = "anchor.leaf_hash does not match canonical_bytes";
            }
        }
    }
    if (!isRfc3339($anchor->anchored_at ?? null, $strict)) {
        $failures[] = "anchor.anchored_at is not a valid calendar date/time";
    }
    if (!isObject($anchor->proof ?? null)) {
        $failures[] = "anchor.proof is not an object";
    }
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Section 7.1 — canonical JSON for the -07/-08 entry digest           *
 *                                                                     *
 *  Members sorted by Unicode code point, no whitespace outside          *
 *  strings, integer-valued seq serialized in integer form, non-ASCII    *
 *  escaped as \uXXXX (surrogate pairs for non-BMP).                    *
 * ------------------------------------------------------------------ */

// Decode one UTF-8 character to its code point (input is always valid
// UTF-8 — it comes out of json_decode).
function utf8Codepoint($ch) {
    $b = ord($ch[0]);
    if ($b < 0x80) return $b;
    if ($b < 0xE0) return (($b & 0x1F) << 6) | (ord($ch[1]) & 0x3F);
    if ($b < 0xF0) {
        return (($b & 0x0F) << 12) | ((ord($ch[1]) & 0x3F) << 6) |
               (ord($ch[2]) & 0x3F);
    }
    return (($b & 0x07) << 18) | ((ord($ch[1]) & 0x3F) << 12) |
           ((ord($ch[2]) & 0x3F) << 6) | (ord($ch[3]) & 0x3F);
}

function utf8Chars($s) {
    return preg_split("//u", $s, -1, PREG_SPLIT_NO_EMPTY);
}

// UTF-16 code units — needed because JS sorts object keys by UTF-16 code
// unit order (== code point order for the BMP, but non-BMP chars sort by
// their leading surrogate, i.e. before U+E000..U+FFFF).
function utf16Units($s) {
    $units = [];
    foreach (utf8Chars($s) as $ch) {
        $cp = utf8Codepoint($ch);
        if ($cp < 0x10000) {
            $units[] = $cp;
        } else {
            $d = $cp - 0x10000;
            $units[] = 0xD800 + ($d >> 10);
            $units[] = 0xDC00 + ($d & 0x3FF);
        }
    }
    return $units;
}

function cmpUtf16($a, $b) {
    $ua = utf16Units($a);
    $ub = utf16Units($b);
    $n = min(count($ua), count($ub));
    for ($i = 0; $i < $n; $i++) {
        if ($ua[$i] !== $ub[$i]) return $ua[$i] < $ub[$i] ? -1 : 1;
    }
    return count($ua) <=> count($ub);
}

// Escape a JSON string body per Section 7.1: RFC 8259 escapes for ", \,
// and control characters (named escapes where JSON.stringify uses them);
// every UTF-16 code unit >= 0x7F (including DEL and surrogate halves) is
// emitted as \uXXXX.
function escapeCanonicalString($str) {
    $out = "\"";
    foreach (utf8Chars($str) as $ch) {
        $cp = utf8Codepoint($ch);
        if ($cp === 0x22) {
            $out .= "\\\"";
        } elseif ($cp === 0x5C) {
            $out .= "\\\\";
        } elseif ($cp === 0x08) {
            $out .= "\\b";
        } elseif ($cp === 0x09) {
            $out .= "\\t";
        } elseif ($cp === 0x0A) {
            $out .= "\\n";
        } elseif ($cp === 0x0C) {
            $out .= "\\f";
        } elseif ($cp === 0x0D) {
            $out .= "\\r";
        } elseif ($cp < 0x20) {
            $out .= sprintf("\\u%04x", $cp);
        } elseif ($cp < 0x7F) {
            $out .= $ch;
        } elseif ($cp <= 0xFFFF) {
            $out .= sprintf("\\u%04x", $cp);
        } else {
            $d = $cp - 0x10000;
            $out .= sprintf("\\u%04x\\u%04x",
                            0xD800 + ($d >> 10), 0xDC00 + ($d & 0x3FF));
        }
    }
    return $out . "\"";
}

// Serialize a JSON value canonically: object keys sorted by Unicode code
// point (UTF-16 code unit order), no whitespace. Integer-valued numbers
// serialize in integer form (1, never 1.0).
function canonicalJson($value) {
    if ($value === null) return "null";
    if (is_bool($value)) return $value ? "true" : "false";
    if (is_int($value) || is_float($value)) {
        return jsNumToString($value); // non-integers never reach a valid digest
    }
    if (is_string($value)) return escapeCanonicalString($value);
    if (is_array($value)) {
        $parts = [];
        foreach ($value as $v) $parts[] = canonicalJson($v);
        return "[" . implode(",", $parts) . "]";
    }
    if (isObject($value)) {
        $vars = get_object_vars($value);
        $keys = array_keys($vars);
        usort($keys, "cmpUtf16");
        $parts = [];
        foreach ($keys as $k) {
            $parts[] = escapeCanonicalString($k) . ":" . canonicalJson($vars[$k]);
        }
        return "{" . implode(",", $parts) . "}";
    }
    throw new Exception("unserializable value in canonical JSON");
}

// The -07/-08 entry digest (Section 7.1). Throws on invalid base64
// (fail closed, per Section 7.1's strict-decode requirement).
function entryDigestV07($entry, $strict) {
    $raw = decodeBase64Strict($entry->canonical_bytes ?? null, $strict);
    if ($raw === null) throw new Exception("canonical_bytes is not valid base64");
    $payload = new stdClass;
    $payload->close = property_exists($entry, "close") ? $entry->close : false;
    $payload->id = $entry->id ?? null;
    $payload->job_id = $entry->job_id ?? null;
    $payload->output_hash = sha256hex($raw);
    $payload->prev_digest = $entry->prev_digest ?? null;
    $payload->provenance_class = $entry->provenance_class ?? null;
    $payload->seq = $entry->seq ?? null;
    $payload->tool = $entry->tool ?? null;
    return sha256hex(canonicalJson($payload));
}

/* ------------------------------------------------------------------ *
 *  Section 7.2 — chain rules (-07/-08 construction)                     *
 *                                                                     *
 *  Returns a list of failure reasons; [] means valid. Structural        *
 *  failures (non-object entry, bad base64, missing members, non-integer   *
 *  seq) abort immediately, fail closed.                                 *
 * ------------------------------------------------------------------ */

const GENESIS = "0000000000000000000000000000000000000000000000000000000000000000";
const CHAIN_STRING_MEMBERS = ["id", "tool", "provenance_class", "job_id", "prev_digest"];

function verifyChainV07($timeline, $opts) {
    $strict = !empty($opts["strict"]);
    if (!is_array($timeline) || count($timeline) === 0) {
        return ["timeline is not a non-empty list"];
    }
    $failures = [];
    $job = null;
    $prev = null;
    $n = count($timeline);
    for ($i = 0; $i < $n; $i++) {
        $entry = $timeline[$i];
        if (!isObject($entry)) return ["entry " . $i . " is not an object"];
        if (decodeBase64Strict($entry->canonical_bytes ?? null, $strict) === null) {
            return ["entry " . $i . " canonical_bytes is not valid base64"];
        }
        foreach (CHAIN_STRING_MEMBERS as $m) {
            if (!isNonEmptyString($entry->$m ?? null)) {
                return ["entry " . $i . " missing " . $m];
            }
        }
        $seq = $entry->seq ?? null;
        if (!(is_int($seq) || is_float($seq)) || !isJsInteger($seq)) {
            return ["entry " . $i . " seq is not an integer"];
        }
        // Sequence: first entry seq MUST be 1, then contiguous (Section 7.2).
        if ((float)$seq !== (float)($i + 1)) {
            $failures[] = "entry " . $i . " seq " . jsNumToString($seq) .
                          " breaks contiguity (expected " . ($i + 1) . ")";
        }
        // One job per timeline.
        if ($job === null) $job = $entry->job_id;
        elseif ($entry->job_id !== $job) {
            $failures[] = "entry " . $i . " job_id mixes jobs";
        }
        // Genesis / link checks against recomputed digests.
        $pd = $entry->prev_digest;
        if ($i === 0) {
            if ($pd !== GENESIS) {
                $failures[] = "entry 0 prev_digest is not 64 zero characters";
            }
        } elseif ($pd !== $prev) {
            $failures[] = "entry " . $i .
                          " prev_digest does not match the digest of entry " . ($i - 1);
        }
        // Closing: close must be boolean; true only on the last entry.
        if (property_exists($entry, "close") && !is_bool($entry->close)) {
            $failures[] = "entry " . $i . " close is not a boolean";
        } elseif ($i < $n - 1 && ($entry->close ?? null) === true) {
            $failures[] = "entry " . $i . " carries close:true before the last entry";
        }
        $prev = entryDigestV07($entry, $strict);
    }
    $last = $timeline[$n - 1];
    if (!isObject($last) || ($last->close ?? null) !== true) {
        $failures[] = "last entry does not carry close: true";
    }
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Historical -06 construction (Section 7.4): entry digest = SHA-256    *
 *  of the base64-decoded canonical_bytes alone. A verifier MUST          *
 *  distinguish this from -07/-08 and MUST NOT report a -06 chain as      *
 *  verifying under the current construction. Provided here strictly as   *
 *  the labeled legacy check.                                           *
 * ------------------------------------------------------------------ */

function verifyChainV06($timeline, $opts) {
    $strict = !empty($opts["strict"]);
    if (!is_array($timeline) || count($timeline) === 0) {
        return ["timeline is not a non-empty list"];
    }
    $failures = [];
    $prev = null;
    $n = count($timeline);
    for ($i = 0; $i < $n; $i++) {
        $entry = $timeline[$i];
        if (!isObject($entry)) {
            $failures[] = "entry " . $i . " is not an object";
            break;
        }
        $raw = decodeBase64Strict($entry->canonical_bytes ?? null, $strict);
        if ($raw === null) {
            $failures[] = "entry " . $i . " canonical_bytes is not valid base64";
            break;
        }
        $digest = sha256hex($raw);
        $pd = $entry->prev_digest ?? null;
        if ($i === 0) {
            if (!is_string($pd) || $pd !== GENESIS) {
                $failures[] = "entry 0 prev_digest is not 64 zero characters";
            }
        } elseif (!is_string($pd) || $pd !== $prev) {
            $failures[] = "entry " . $i .
                          " prev_digest does not match the digest of entry " . ($i - 1);
        }
        $prev = $digest;
    }
    $last = $timeline[$n - 1];
    if (isObject($last) && ($last->close ?? null) !== true) {
        $failures[] = "last entry does not carry close: true";
    }
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Section 7.3 — external truncation commitment                         *
 *                                                                     *
 *  Chain mechanics alone cannot detect truncation with re-linking; the  *
 *  draft recommends the external commitment bind the FINAL chain entry  *
 *  digest. checkFinalEntryCommitment() recomputes the last entry's       *
 *  -07/-08 digest and compares it with the published commitment.         *
 * ------------------------------------------------------------------ */

function finalEntryDigestV07($timeline, $opts) {
    $strict = !empty($opts["strict"]);
    if (!is_array($timeline) || count($timeline) === 0) return null;
    $last = $timeline[count($timeline) - 1];
    if (!isObject($last)) return null;
    try {
        return entryDigestV07($last, $strict);
    } catch (Exception $e) {
        return null;
    }
}

function checkFinalEntryCommitment($timeline, $commitment, $opts) {
    $failures = [];
    if (!is_string($commitment) || !preg_match("/^[0-9a-f]{64}$/D", $commitment)) {
        $failures[] = "external commitment is not a lowercase hex SHA-256 digest";
        return $failures;
    }
    $digest = finalEntryDigestV07($timeline, $opts);
    if ($digest === null) {
        $failures[] = "cannot recompute the final entry digest";
    } elseif ($digest !== $commitment) {
        $failures[] = "external commitment does not bind the final entry digest " .
                      "(expected " . $commitment . ", recomputed " . $digest . ")";
    }
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Section 7.3 (-09) — chain commitment artifact                        *
 *                                                                     *
 *  Mirrors the conformance kit's verifyChainCommitment() exactly — the   *
 *  failure-reason strings are verbatim because the corpus pins them.     *
 * ------------------------------------------------------------------ */

const HEX64_RE = "/^[0-9a-f]{64}$/D";

function verifyChainCommitment($timeline, $commitment, $opts) {
    $strict = !empty($opts["strict"]);
    if (!is_array($timeline) || !count($timeline)) {
        return ["timeline is not a non-empty list"];
    }
    if (!isObject($commitment)) {
        return ["commitment is not a JSON object"];
    }
    $failures = [];
    $jobId = $commitment->job_id ?? null;
    if (!isNonEmptyString($jobId)) {
        $failures[] = "commitment job_id is not a non-empty string";
    }
    $digest = $commitment->final_entry_digest ?? null;
    if (!is_string($digest) || !preg_match(HEX64_RE, $digest)) {
        $failures[] = "commitment final_entry_digest is not 64 lowercase " .
                      "hex characters";
    }
    $count = $commitment->entry_count ?? null;
    // __float__ marker: entry_count was written as 5.0 in JSON; JSON.parse
    // loses the int/float distinction so loaders mark it (draft -10).
    // PHP's json_decode preserves it natively (5 -> int, 5.0 -> float),
    // so a plain is_int check is the reference behavior: any float —
    // integral value or not — is rejected (vector x-entry-count-float).
    $marked = $count !== null && isObject($count) &&
              property_exists($count, "__float__");
    if ($marked || !is_int($count)) {
        $failures[] = "commitment entry_count is not an integer";
    }
    $published = $commitment->published_at ?? null;
    if (!isRfc3339Format($published, $strict)) {
        $failures[] = "commitment published_at is not RFC 3339";
    } elseif (!isCalendarDateTime($published, $strict)) {
        $failures[] = "commitment published_at is not a valid calendar " .
                      "date/time";
    }
    if (count($failures)) return $failures;
    // Member shapes are sound; now bind them to the timeline.
    if ((float)$count !== (float)count($timeline)) {
        $failures[] = "commitment entry_count " . jsNumToString($count) .
                      " does not match the timeline length " . count($timeline);
    }
    for ($i = 0; $i < count($timeline); $i++) {
        $entry = $timeline[$i];
        if (!isObject($entry) || !isNonEmptyString($entry->job_id ?? null)) {
            $failures[] = "entry " . $i . " has no usable job_id";
        } elseif ($entry->job_id !== $jobId) {
            $failures[] = "entry " . $i .
                          " job_id does not match the commitment job_id";
        }
    }
    // Recompute the last entry's digest, fail closed the way
    // verifyChainV07 does (strict base64 plus the length % 4 guard).
    $last = $timeline[count($timeline) - 1];
    $want = null;
    try {
        $cb = isObject($last) ? ($last->canonical_bytes ?? null) : null;
        if (decodeBase64Strict($cb, $strict) === null) {
            throw new Exception("bad base64");
        }
        $want = entryDigestV07($last, $strict);
    } catch (Exception $e) {
        $failures[] = "final entry digest could not be recomputed: " .
                      $e->getMessage();
    }
    if ($want !== null && $digest !== $want) {
        $failures[] = "commitment final_entry_digest does not match the " .
                      "recomputed digest of the last entry";
    }
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Section 8.1 — Merkle root over ordered step receipt_id values       *
 *                                                                     *
 *  leaf = SHA-256 over UTF-8 bytes of receipt_id; internal node =       *
 *  SHA-256 over raw left || raw right digests; odd level duplicates the  *
 *  last digest; empty input -> SHA-256 of the empty string.             *
 * ------------------------------------------------------------------ */

function merkleRoot($receiptIds) {
    if (!count($receiptIds)) return sha256hex("");
    $level = [];
    foreach ($receiptIds as $rid) $level[] = hash("sha256", $rid, true);
    while (count($level) > 1) {
        if (count($level) % 2) $level[] = $level[count($level) - 1];
        $next = [];
        for ($i = 0; $i < count($level); $i += 2) {
            $next[] = hash("sha256", $level[$i] . $level[$i + 1], true);
        }
        $level = $next;
    }
    return bin2hex($level[0]);
}

/* ------------------------------------------------------------------ *
 *  Section 8.2 (offline part) — workflow structural checks              *
 * ------------------------------------------------------------------ */

function verifyWorkflowSteps($w) {
    if (!isObject($w)) return ["workflow is not a JSON object"];
    $steps = $w->steps ?? null;
    if (!is_array($steps) || count($steps) === 0) {
        return ["workflow steps is not a non-empty list"];
    }
    $failures = [];
    for ($i = 0; $i < count($steps); $i++) {
        $step = $steps[$i];
        if (!isObject($step)) {
            $failures[] = "step " . ($i + 1) . " is not an object";
            continue;
        }
        $seq = $step->seq ?? null;
        if (!(is_int($seq) || is_float($seq)) || !isJsInteger($seq)) {
            $failures[] = "step " . ($i + 1) . " seq is not an integer";
        }
        if (!is_string($step->receipt_id ?? null)) {
            $failures[] = "step " . ($i + 1) . " receipt_id is not a string";
        }
    }
    if (count($failures)) return $failures;
    for ($i = 0; $i < count($steps); $i++) {
        if ((float)$steps[$i]->seq !== (float)($i + 1)) {
            $failures[] = "step seq values are not 1.." . count($steps) .
                          " in order (index " . $i . " carries seq " .
                          jsNumToString($steps[$i]->seq) . ")";
            break;
        }
    }
    $seen = [];
    foreach ($steps as $step) {
        if (isset($seen[$step->receipt_id])) {
            $failures[] = "duplicate receipt_id: " . $step->receipt_id;
            break;
        }
        $seen[$step->receipt_id] = true;
    }
    $ids = [];
    foreach ($steps as $s) $ids[] = $s->receipt_id;
    $root = merkleRoot($ids);
    if (!is_string($w->merkle_root ?? null) || $w->merkle_root !== $root) {
        $failures[] = "merkle_root does not match the recomputed Section 8.1 root";
    }
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Section 8 (full member check) — workflow receipt files               *
 * ------------------------------------------------------------------ */

function checkWorkflow($w, $opts) {
    $strict = !empty($opts["strict"]);
    $failures = [];
    if (!isObject($w)) return ["workflow is not a JSON object"];
    foreach (["type", "version", "workflow_id", "receipt_id",
              "session_id", "goal", "status", "steps",
              "merkle_root", "output_hash", "verify_url"] as $field) {
        if (!property_exists($w, $field)) {
            $failures[] = "missing required member: " . $field;
        }
    }
    if (count($failures)) return $failures;
    if (($w->type ?? null) !== "verifiable-workflow-receipt") {
        $failures[] = "type is not \"verifiable-workflow-receipt\"";
    }
    if (!isNonEmptyString($w->version ?? null)) {
        $failures[] = "version is not a non-empty string";
    }
    $uuidRe = $strict ? UUID_V4_RE : UUID_LOOSE_RE;
    if (!is_string($w->workflow_id ?? null) || !preg_match($uuidRe, $w->workflow_id)) {
        $failures[] = "workflow_id is not a UUID";
    }
    if (!is_string($w->receipt_id ?? null) || !preg_match($uuidRe, $w->receipt_id)) {
        $failures[] = "receipt_id is not a UUID";
    }
    foreach (["session_id", "goal", "status"] as $k) {
        if (!isNonEmptyString($w->$k ?? null)) {
            $failures[] = $k . " is not a non-empty string";
        }
    }
    if (!is_string($w->merkle_root ?? null) || !preg_match(HEX64_RE, $w->merkle_root)) {
        $failures[] = "merkle_root is not a lowercase hex SHA-256 digest";
    }
    if (!is_string($w->output_hash ?? null) || !preg_match(HEX64_RE, $w->output_hash)) {
        $failures[] = "output_hash is not a lowercase hex SHA-256 digest";
    }
    $vu = $w->verify_url ?? null;
    if (!isNonEmptyString($vu) || !preg_match("/^https?:\/\//", $vu)) {
        $failures[] = "verify_url is not an http(s) URL";
    }
    if (is_array($w->steps)) {
        for ($i = 0; $i < count($w->steps); $i++) {
            $s = $w->steps[$i];
            if (!isObject($s)) {
                $failures[] = "step " . ($i + 1) . " is not an object";
                continue;
            }
            foreach (["seq", "tool", "receipt_id", "receipt_hash",
                      "started_at", "ended_at", "status"] as $field) {
                if (!property_exists($s, $field)) {
                    $failures[] = "step " . ($i + 1) . " missing member: " . $field;
                }
            }
            $seq = $s->seq ?? null;
            if (!(is_int($seq) || is_float($seq)) || !isJsInteger($seq)) {
                $failures[] = "step " . ($i + 1) . " seq is not an integer";
            }
            if (!isNonEmptyString($s->tool ?? null)) {
                $failures[] = "step " . ($i + 1) . " tool is not a non-empty string";
            }
            if (!is_string($s->receipt_id ?? null) || !preg_match($uuidRe, $s->receipt_id)) {
                $failures[] = "step " . ($i + 1) . " receipt_id is not a UUID";
            }
            if (!is_string($s->receipt_hash ?? null) || !preg_match(HEX64_RE, $s->receipt_hash)) {
                $failures[] = "step " . ($i + 1) . " receipt_hash is not hex SHA-256";
            }
            if (!isRfc3339($s->started_at ?? null, $strict)) {
                $failures[] = "step " . ($i + 1) . " started_at is not RFC 3339";
            }
            if (!isRfc3339($s->ended_at ?? null, $strict)) {
                $failures[] = "step " . ($i + 1) . " ended_at is not RFC 3339";
            }
            if (!isNonEmptyString($s->status ?? null)) {
                $failures[] = "step " . ($i + 1) . " status is not a non-empty string";
            }
        }
    }
    foreach (verifyWorkflowSteps($w) as $f) $failures[] = $f;
    return $failures;
}

/* ------------------------------------------------------------------ *
 *  Top-level dispatch                                                   *
 * ------------------------------------------------------------------ */

// Verify one parsed document. opts["kind"]: "auto" | "receipt" | "chain" |
// "chain06" | "workflow". Returns ["kind"=>..., "failures"=>[{check,reason}]].
function verifyDocument($doc, $opts) {
    $results = [];
    $push = function ($check, $reasons) use (&$results) {
        foreach ($reasons as $r) $results[] = ["check" => $check, "reason" => $r];
    };

    $kind = isset($opts["kind"]) ? $opts["kind"] : "auto";
    if ($kind === "auto") {
        if (is_array($doc)) $kind = "chain";
        elseif (isObject($doc) &&
                (is_array($doc->timeline ?? null) || is_array($doc->entries ?? null)))
            $kind = "chain";
        elseif (isObject($doc) &&
                ($doc->type ?? null) === "verifiable-workflow-receipt")
            $kind = "workflow";
        else $kind = "receipt";
    }

    if ($kind === "chain" || $kind === "chain06") {
        if (is_array($doc)) {
            $tl = $doc;
        } elseif (isObject($doc)) {
            $tl = jsTruthy($doc->timeline ?? null)
                ? $doc->timeline : ($doc->entries ?? null);
        } else {
            $tl = null;
        }
        $push($kind === "chain06" ? "chain(-06)" : "chain(-07/-08)",
              $kind === "chain06" ? verifyChainV06($tl, $opts)
                                  : verifyChainV07($tl, $opts));
        // Section 7.3: a doc wrapping the timeline that publishes a
        // final-entry commitment gets the binding checked.
        if (isObject($doc) && is_string($doc->final_entry_digest ?? null)) {
            $push("final-entry-commitment",
                  checkFinalEntryCommitment($tl, $doc->final_entry_digest, $opts));
        }
        // Section 7.3 (-09): a "commitment" member runs the chain
        // commitment artifact check — independent of the -08 bare digest.
        if (isObject($doc) && property_exists($doc, "commitment")) {
            $push("chain-commitment(-09)",
                  verifyChainCommitment($tl, $doc->commitment, $opts));
        }
        return ["kind" => $kind, "failures" => $results];
    }
    if ($kind === "workflow") {
        $push("workflow", checkWorkflow($doc, $opts));
        return ["kind" => $kind, "failures" => $results];
    }
    // Receipt.
    $push("core", checkReceipt($doc, $opts));
    if (!empty($opts["profile"])) {
        $push("reference-profile", checkReferenceProfile($doc, $opts));
    }
    // Disinterested-tier anchor: enforced when the anchor uses the tier's
    // member vocabulary; other anchor envelopes are additive and opaque.
    if (($opts["anchor"] ?? null) === true || anchorUsesTierShape($doc)) {
        $push("disinterested-tier", checkDisinterestedTier($doc, $opts));
    }
    return ["kind" => $kind, "failures" => $results];
}

function verifyFile($file, $opts) {
    $text = @file_get_contents($file);
    if ($text === false) {
        // verify.js crashes on unreadable files; the ports fail closed.
        return ["file" => $file, "kind" => "?",
                "failures" => [["check" => "read", "reason" => "cannot read file"]]];
    }
    $doc = json_decode($text);
    if (json_last_error() !== JSON_ERROR_NONE) {
        return ["file" => $file, "kind" => "?",
                "failures" => [["check" => "parse",
                                "reason" => "not valid JSON: " . json_last_error_msg()]]];
    }
    $r = verifyDocument($doc, $opts);
    return ["file" => $file, "kind" => $r["kind"], "failures" => $r["failures"]];
}

// Directory -> every *.json inside, recursively, sorted by path. Hidden
// files and anything under a hidden directory are skipped (mirrors the
// !name.startsWith(".") filter in verify.js). verify.js scanned only the
// top level of a directory; recursion is the one deliberate extension so
// the corpus layout test-vectors/{valid,invalid,invalid-profile,anchored}
// can be run with a single target (same call the Swift/Java ports made).
function collectJsonFiles($target) {
    if (!is_dir($target)) return [$target];
    $base = rtrim($target, "/");
    if ($base === "") $base = "/";
    $dir = new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS);
    $filter = new RecursiveCallbackFilterIterator($dir, function ($cur) {
        $name = $cur->getFilename();
        if ($name === "" || $name[0] === ".") return false; // hidden
        if ($cur->isDir()) return true;                     // recurse
        return substr($name, -5) === ".json";
    });
    $out = [];
    foreach (new RecursiveIteratorIterator($filter) as $f) {
        $out[] = $f->getPathname();
    }
    usort($out, "strcmp"); // byte order == JS UTF-16 order for ASCII paths
    return $out;
}

function runCLI($argv) {
    $opts = ["kind" => "auto", "strict" => false, "profile" => false,
             "anchor" => false, "json" => false];
    $targets = [];
    foreach (array_slice($argv, 1) as $a) {
        if ($a === "--strict") $opts["strict"] = true;
        elseif ($a === "--chain06") $opts["kind"] = "chain06";
        elseif ($a === "--chain") $opts["kind"] = "chain";
        elseif ($a === "--workflow") $opts["kind"] = "workflow";
        elseif ($a === "--receipt") $opts["kind"] = "receipt";
        elseif ($a === "--profile") $opts["profile"] = true;
        elseif ($a === "--anchor") $opts["anchor"] = true;
        elseif ($a === "--json") $opts["json"] = true;
        else $targets[] = $a;
    }
    if (!count($targets)) {
        fwrite(STDERR, "usage: php aer1verify.php <file-or-dir> [--strict] " .
            "[--chain|--chain06|--workflow|--receipt] [--profile] " .
            "[--anchor] [--json]\n");
        return 2;
    }
    $all = [];
    foreach ($targets as $t) {
        foreach (collectJsonFiles($t) as $f) $all[] = $f;
    }
    $bad = 0;
    $report = [];
    foreach ($all as $f) {
        $r = verifyFile($f, $opts);
        $ok = count($r["failures"]) === 0;
        if (!$ok) $bad++;
        if ($opts["json"]) {
            $report[] = ["file" => $f, "kind" => $r["kind"], "ok" => $ok,
                         "failures" => $r["failures"]];
            continue;
        }
        $label = "[" . $r["kind"] . "]";
        if ($ok) {
            echo "PASS " . $label . " " . $f . "\n";
        } else {
            $line = "FAIL " . $label . " " . $f . " — " . $r["failures"][0]["reason"];
            if (count($r["failures"]) > 1) {
                $line .= " (+" . (count($r["failures"]) - 1) . " more)";
            }
            echo $line . "\n";
        }
    }
    if ($opts["json"]) {
        echo json_encode($report,
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
    } else {
        echo "\n" . (count($all) - $bad) . "/" . count($all) . " verified\n";
        echo "CONFORMANCE: " . (count($all) - $bad) . "/" . count($all) . "\n";
    }
    return $bad === 0 ? 0 : 1;
}

if (isset($_SERVER["SCRIPT_FILENAME"]) &&
    realpath($_SERVER["SCRIPT_FILENAME"]) === __FILE__) {
    exit(runCLI($argv));
}
