#!/usr/bin/env lua5.4
--[[
  ARION AER-1 verifier — independent Lua 5.4 implementation (single file,
  standard library only; no luarocks, no C modules, no network).

  Ported check-for-check from verify.js (ARION's independent Node.js
  verifier), itself an independent reimplementation of the AER-1
  receipt/chain/workflow verification rules in draft-zambo-aer1-09+
  written from the draft text.

  Implements:
    - Section 3/4/5/6  : core receipt checks (members, types, provenance,
                         strict base64 + strict UTF-8 canonical bytes,
                         output commitment recomputation)
    - Reference-producer profile (--profile): canonical payload must be a
      JSON object carrying an "inputs" member
    - Disinterested anchor tier (--anchor, or auto when the anchor uses
      the tier vocabulary log/leaf_hash/anchored_at/proof)
    - 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, link rules)
    - Section 7.4      : --chain06 = the historical -06 construction is a
                         distinct labeled check (entry digest = SHA-256 of
                         the decoded canonical_bytes alone); a -06 chain
                         MUST NOT verify under -07/-08 rules
    - Section 7.3      : bare final-entry-digest commitment + the -09
                         chain commitment artifact (job_id /
                         final_entry_digest / entry_count / published_at)
    - Section 8/8.1/8.2: workflow receipts + Merkle root over step
                         receipt_ids (leaf = SHA-256(utf8(receipt_id)),
                         odd-level last duplication)
    - --strict         : certifier-strictness profile (UUID v4 + RFC 4122
                         variant ids, receipt_schema_version pinned to
                         "0.3")

  CLI (parity with verify.js / aer1verify.swift):
    lua5.4 aer1verify.lua <file-or-dir> [--strict]
        [--chain|--chain06|--workflow|--receipt] [--profile] [--anchor]
        [--json] [--selftest]
    <dir> is scanned recursively for *.json files (sorted by path);
    hidden names (leading '.') are skipped. Each file's kind is
    auto-detected: JSON array -> timeline; object type
    "verifiable-workflow-receipt" -> workflow; object with "timeline"/
    "entries" -> wrapped timeline; otherwise -> receipt.
    Prints one PASS/FAIL line per file (FAIL names the first failing
    check), then "CONFORMANCE: <passed>/<total>". Exit 0 iff all pass.

  JSON value model (no stdlib JSON in Lua):
    - objects -> Lua tables with metatable OBJMT (isObject)
    - arrays  -> Lua tables with metatable ARRMT (isArray); holes are
                 impossible by construction
    - null    -> JSON_NULL sentinel (key presence must be detectable, so
                 nil cannot represent it)
    - numbers -> Lua integer for integer lexemes, Lua float for lexemes
                 containing '.', 'e' or 'E' (math.type distinguishes)
    - "entry_count": <digits>.<digits>  is wrapped as {"__float__": n},
                 mirroring the -10 kit vector loader's textual rewrite
                 (entry_count written 5.0 must be rejected; seq 2.0 stays
                 an accepted integer-valued number — see JAVA-PORT.md)

  Disclosure: written by ARION, an autonomous agent (AI-authored). It is
  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.
]]

local OBJMT = {}
local ARRMT = {}
local JSON_NULL = setmetatable({}, { __name = "json.null" })

local function isObject(v)
    return type(v) == "table" and getmetatable(v) == OBJMT
end
local function isArray(v)
    return type(v) == "table" and getmetatable(v) == ARRMT
end
local function isNonEmptyString(v)
    return type(v) == "string" and #v > 0
end

-- JS truthiness: null/undefined/false/0/"" are falsy; objects and arrays
-- are always truthy.
local function jsTruthy(v)
    if v == nil or v == false or v == JSON_NULL then return false end
    if type(v) == "number" then return v ~= 0 end
    if type(v) == "string" then return #v > 0 end
    return true
end

-- JS String(v) for the values that reach diagnostics: strings pass
-- through, null/undefined render as "null".
local function jsStr(v)
    if v == nil or v == JSON_NULL then return "null" end
    if type(v) == "string" then return v end
    return tostring(v)
end

-- JS JSON.stringify(v) for scalars in diagnostic messages.
local function qstr(v)
    if v == nil or v == JSON_NULL then return "null" end
    if type(v) == "string" then return '"' .. v .. '"' end
    if type(v) == "boolean" then return tostring(v) end
    return tostring(v)
end

-- Dotted-version compare: true when a >= b numerically per segment.
local function versionGeq(a, b)
    local pa, pb = {}, {}
    for d in tostring(a):gmatch("[^.]+") do pa[#pa + 1] = tonumber(d) or 0 end
    for d in tostring(b):gmatch("[^.]+") do pb[#pb + 1] = tonumber(d) or 0 end
    local n = math.max(#pa, #pb)
    for i = 1, n do
        local d = (pa[i] or 0) - (pb[i] or 0)
        if d ~= 0 then return d > 0 end
    end
    return true
end

-- JS Number.isInteger: finite and integral (includes integral doubles
-- of any magnitude).
local function isIntegerValued(v)
    if type(v) ~= "number" then return false end
    if v ~= v or v == math.huge or v == -math.huge then return false end
    return math.floor(v) == v
end

-- JS String(number): integer-valued numbers print in integer form.
-- Lua tostring(float) uses %.14g, which diverges from JS shortest-
-- round-trip only for doubles needing >14 significant digits; such
-- values never appear in conformant vectors.
local function jsNum(v)
    if math.type(v) == "integer" then return tostring(v) end
    local d = v
    if d ~= d or d == math.huge or d == -math.huge then return tostring(d) end
    if d == 0 then return "0" end                    -- JS String(-0) === "0"
    if d == math.floor(d) and math.abs(d) < 1e21 then
        return string.format("%.0f", d)
    end
    return tostring(d)
end




-- ------------------------------------------------------------------
--  SHA-256 (pure Lua 5.4, native 64-bit integer bitwise ops)
-- ------------------------------------------------------------------

local SHA256_K = {
    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
    0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
    0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
    0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
    0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
    0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
    0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
    0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
    0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
}

local function rotr32(x, n)
    return ((x >> n) | (x << (32 - n))) & 0xffffffff
end

-- SHA-256 of a byte string -> raw 32-byte digest.
local function sha256bin(msg)
    local ml = #msg
    local pad = (56 - (ml + 1) % 64) % 64
    msg = msg .. "\128" .. string.rep("\0", pad) .. string.pack(">I8", ml * 8)
    local h0, h1, h2, h3 = 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a
    local h4, h5, h6, h7 = 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
    local w = {}
    for base = 1, #msg, 64 do
        for i = 0, 15 do
            w[i] = string.unpack(">I4", msg, base + i * 4)
        end
        for i = 16, 63 do
            local x, y = w[i - 15], w[i - 2]
            local s0 = rotr32(x, 7) ~ rotr32(x, 18) ~ (x >> 3)
            local s1 = rotr32(y, 17) ~ rotr32(y, 19) ~ (y >> 10)
            w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & 0xffffffff
        end
        local a, b, c, d = h0, h1, h2, h3
        local e, f, g, h = h4, h5, h6, h7
        for i = 0, 63 do
            local S1 = rotr32(e, 6) ~ rotr32(e, 11) ~ rotr32(e, 25)
            local ch = (e & f) ~ ((~e) & g)
            local t1 = (h + S1 + ch + SHA256_K[i + 1] + w[i]) & 0xffffffff
            local S0 = rotr32(a, 2) ~ rotr32(a, 13) ~ rotr32(a, 22)
            local maj = (a & b) ~ (a & c) ~ (b & c)
            local t2 = (S0 + maj) & 0xffffffff
            h = g; g = f; f = e
            e = (d + t1) & 0xffffffff
            d = c; c = b; b = a
            a = (t1 + t2) & 0xffffffff
        end
        h0 = (h0 + a) & 0xffffffff; h1 = (h1 + b) & 0xffffffff
        h2 = (h2 + c) & 0xffffffff; h3 = (h3 + d) & 0xffffffff
        h4 = (h4 + e) & 0xffffffff; h5 = (h5 + f) & 0xffffffff
        h6 = (h6 + g) & 0xffffffff; h7 = (h7 + h) & 0xffffffff
    end
    return string.pack(">I4I4I4I4I4I4I4I4",
        h0, h1, h2, h3, h4, h5, h6, h7)
end

local function hexenc(s)
    return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end))
end

local function sha256hex(msg)
    return hexenc(sha256bin(msg))
end

-- ------------------------------------------------------------------
--  Strict UTF-8 validity check (Section 4): non-UTF-8 bytes fail
--  closed. Byte-level; rejects overlongs, surrogates, > U+10FFFF.
-- ------------------------------------------------------------------

local function isCont(b)
    return b ~= nil and b >= 0x80 and b <= 0xbf
end

local function isValidUtf8(s)
    local i, n = 1, #s
    while i <= n do
        local c = s:byte(i)
        if c < 0x80 then
            i = i + 1
        elseif c < 0xc2 then
            return false                    -- 0x80-0xc1: stray cont / overlong
        elseif c < 0xe0 then
            if not isCont(s:byte(i + 1)) then return false end
            i = i + 2
        elseif c < 0xf0 then
            local b2, b3 = s:byte(i + 1), s:byte(i + 2)
            if b2 == nil or b3 == nil then return false end
            if c == 0xe0 then
                if b2 < 0xa0 or b2 > 0xbf then return false end
            elseif c == 0xed then
                if b2 < 0x80 or b2 > 0x9f then return false end  -- surrogates
            elseif not isCont(b2) then
                return false
            end
            if not isCont(b3) then return false end
            i = i + 3
        elseif c <= 0xf4 then
            local b2, b3, b4 = s:byte(i + 1), s:byte(i + 2), s:byte(i + 3)
            if b2 == nil or b3 == nil or b4 == nil then return false end
            if c == 0xf0 then
                if b2 < 0x90 or b2 > 0xbf then return false end
            elseif c == 0xf4 then
                if b2 < 0x80 or b2 > 0x8f then return false end  -- > U+10FFFF
            elseif not isCont(b2) then
                return false
            end
            if not isCont(b3) or not isCont(b4) then return false end
            i = i + 4
        else
            return false                    -- 0xf5-0xff
        end
    end
    return true
end

-- ------------------------------------------------------------------
--  Base64 (kit convention): alphabet plus at most two pad chars at
--  the end, total length a multiple of 4; non-canonical trailing pad
--  bits tolerated at decode (same as Node Buffer.from(s,"base64")).
--  Draft -10 removed the trailing-"\n" tolerance; not reintroduced.
-- ------------------------------------------------------------------

local B64MAP = {}
do
    local a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    for i = 1, #a do B64MAP[a:byte(i)] = i - 1 end
end

local function decodeBase64Strict(v, strict)
    if type(v) ~= "string" then return nil end
    local pads = 0
    for i = 1, #v do
        local c = v:byte(i)
        if c == 61 then                     -- '='
            pads = pads + 1
            if pads > 2 then return nil end
        else
            if pads > 0 then return nil end -- '=' only allowed at the end
            if B64MAP[c] == nil then return nil end
        end
    end
    if #v % 4 ~= 0 then return nil end
    local out = {}
    for i = 1, #v, 4 do
        local c1, c2, c3, c4 = v:byte(i, i + 3)
        local v3 = (c3 == 61) and 0 or B64MAP[c3]
        local v4 = (c4 == 61) and 0 or B64MAP[c4]
        local n = B64MAP[c1] * 262144 + B64MAP[c2] * 4096 + v3 * 64 + v4
        if c3 == 61 then
            out[#out + 1] = string.char((n >> 16) & 0xff)
        elseif c4 == 61 then
            out[#out + 1] = string.char((n >> 16) & 0xff, (n >> 8) & 0xff)
        else
            out[#out + 1] = string.char(
                (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff)
        end
    end
    return table.concat(out)
end

-- ------------------------------------------------------------------
--  JSON parser (RFC 8259, strict). Produces the tagged-table model
--  described in the header. Returns nil on any parse failure.
--
--  "entry_count": <d>.<d> member values are wrapped as
--  {"__float__": n} — the same marker the -10 kit vector loader
--  produces by textual rewrite, so commitment.entry_count written as
--  a float (5.0) is rejected while seq:2.0 remains a plain
--  integer-valued number.
-- ------------------------------------------------------------------

local function parseJson(text)
    local pos, n = 1, #text
    local function err() error("json", 0) end
    local function isDigit(c) return c ~= nil and c >= 48 and c <= 57 end
    local function ws()
        while pos <= n do
            local c = text:byte(pos)
            if c == 32 or c == 9 or c == 10 or c == 13 then
                pos = pos + 1
            else
                break
            end
        end
    end
    local function utf8enc(cp)
        if cp < 0x80 then
            return string.char(cp)
        elseif cp < 0x800 then
            return string.char(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f))
        elseif cp < 0x10000 then
            return string.char(0xe0 | (cp >> 12),
                0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f))
        end
        return string.char(0xf0 | (cp >> 18),
            0x80 | ((cp >> 12) & 0x3f),
            0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f))
    end
    local function parseString()
        pos = pos + 1                       -- opening quote
        local parts, start = {}, pos
        local np = 0
        while true do
            if pos > n then err() end
            local c = text:byte(pos)
            if c == 34 then                 -- '"'
                np = np + 1
                parts[np] = text:sub(start, pos - 1)
                pos = pos + 1
                break
            elseif c == 92 then             -- '\'
                np = np + 1
                parts[np] = text:sub(start, pos - 1)
                np = np + 1                 -- escape lands in the NEXT slot;
                -- writing it at np would overwrite the literal just stored.
                pos = pos + 1
                local e = text:byte(pos)
                if e == nil then err() end
                pos = pos + 1
                if e == 34 then parts[np] = '"'
                elseif e == 92 then parts[np] = '\\'
                elseif e == 47 then parts[np] = '/'
                elseif e == 98 then parts[np] = '\b'
                elseif e == 102 then parts[np] = '\f'
                elseif e == 110 then parts[np] = '\n'
                elseif e == 114 then parts[np] = '\r'
                elseif e == 116 then parts[np] = '\t'
                elseif e == 117 then        -- \uXXXX
                    local h = text:sub(pos, pos + 3)
                    if #h < 4 or not h:match("^[0-9a-fA-F]+$") then err() end
                    pos = pos + 4
                    local cp = tonumber(h, 16)
                    if cp >= 0xd800 and cp <= 0xdbff then
                        -- high surrogate: needs a paired low surrogate
                        local lo = nil
                        if text:byte(pos) == 92 and text:byte(pos + 1) == 117 then
                            local h2 = text:sub(pos + 2, pos + 5)
                            if #h2 == 4 and h2:match("^[0-9a-fA-F]+$") then
                                lo = tonumber(h2, 16)
                            end
                        end
                        if lo ~= nil and lo >= 0xdc00 and lo <= 0xdfff then
                            cp = 0x10000 + (cp - 0xd800) * 0x400 + (lo - 0xdc00)
                            pos = pos + 6
                        else
                            cp = 0xfffd     -- JS keeps the lone surrogate;
                        end                 -- U+FFFD is the closest encoding
                    elseif cp >= 0xdc00 and cp <= 0xdfff then
                        cp = 0xfffd
                    end
                    parts[np] = utf8enc(cp)
                else
                    err()
                end
                start = pos
            elseif c < 0x20 then
                err()                         -- raw control char in string
            else
                pos = pos + 1                 -- raw byte (UTF-8 preserved)
            end
        end
        return table.concat(parts)
    end
    local function isNumCont(c)
        -- chars that would continue a JSON number lexeme; the kit's
        -- textual __float__ rewrite would corrupt such input into
        -- unparseable JSON, so treat it as a parse error (parity).
        return c ~= nil and (c == 46 or c == 69 or c == 101 or
            c == 43 or c == 45 or (c >= 48 and c <= 57))
    end
    local parseValue
    local function parseNumber()
        local start = pos
        if text:byte(pos) == 45 then pos = pos + 1 end        -- '-'
        local c = text:byte(pos)
        if c == 48 then                                       -- '0'
            pos = pos + 1
            if isDigit(text:byte(pos)) then err() end         -- leading zero
        elseif isDigit(c) then
            while isDigit(text:byte(pos)) do pos = pos + 1 end
        else
            err()
        end
        if text:byte(pos) == 46 then                          -- '.'
            pos = pos + 1
            if not isDigit(text:byte(pos)) then err() end
            while isDigit(text:byte(pos)) do pos = pos + 1 end
        end
        c = text:byte(pos)
        if c == 69 or c == 101 then                           -- e/E
            pos = pos + 1
            c = text:byte(pos)
            if c == 43 or c == 45 then pos = pos + 1 end
            if not isDigit(text:byte(pos)) then err() end
            while isDigit(text:byte(pos)) do pos = pos + 1 end
        end
        local v = tonumber(text:sub(start, pos - 1))
        if v == nil then err() end
        return v
    end
    local function parseObject()
        pos = pos + 1                       -- '{'
        local t = setmetatable({}, OBJMT)
        ws()
        if text:byte(pos) == 125 then pos = pos + 1; return t end
        while true do
            ws()
            if text:byte(pos) ~= 34 then err() end
            local key = parseString()
            ws()
            if text:byte(pos) ~= 58 then err() end            -- ':'
            pos = pos + 1
            ws()
            -- Kit loader parity: "entry_count": <digits>.<digits>
            -- becomes {"__float__": n} (marks a float literal that
            -- JSON.parse would silently read as an integer).
            if key == "entry_count" then
                local numtxt = text:match("^(%d+%.%d+)", pos)
                if numtxt ~= nil then
                    local ip = numtxt:match("^(%d+)%.")
                    if #ip > 1 and ip:sub(1, 1) == "0" then err() end
                    pos = pos + #numtxt
                    if isNumCont(text:byte(pos)) then err() end
                    local marked = setmetatable({}, OBJMT)
                    marked.__float__ = tonumber(numtxt)
                    rawset(t, key, marked)
                    goto have_member
                end
            end
            rawset(t, key, parseValue())
            ::have_member::
            ws()
            local c = text:byte(pos)
            if c == 44 then pos = pos + 1                     -- ','
            elseif c == 125 then pos = pos + 1; return t      -- '}'
            else err() end
        end
    end
    local function parseArray()
        pos = pos + 1                       -- '['
        local t = setmetatable({}, ARRMT)
        ws()
        if text:byte(pos) == 93 then pos = pos + 1; return t end
        local i = 0
        while true do
            i = i + 1
            t[i] = parseValue()
            ws()
            local c = text:byte(pos)
            if c == 44 then pos = pos + 1                     -- ','
            elseif c == 93 then pos = pos + 1; return t       -- ']'
            else err() end
        end
    end
    parseValue = function()
        ws()
        local c = text:byte(pos)
        if c == nil then err()
        elseif c == 123 then return parseObject()             -- '{'
        elseif c == 91 then return parseArray()               -- '['
        elseif c == 34 then return parseString()              -- '"'
        elseif c == 116 then                                  -- t
            if text:sub(pos, pos + 3) == "true" then
                pos = pos + 4; return true
            end
            err()
        elseif c == 102 then                                  -- f
            if text:sub(pos, pos + 4) == "false" then
                pos = pos + 5; return false
            end
            err()
        elseif c == 110 then                                  -- n
            if text:sub(pos, pos + 3) == "null" then
                pos = pos + 4; return JSON_NULL
            end
            err()
        else
            return parseNumber()
        end
    end
    local ok, res = pcall(function()
        local v = parseValue()
        ws()
        if pos <= n then err() end                            -- trailing garbage
        return v
    end)
    if ok then return res end
    return nil
end

-- ------------------------------------------------------------------
--  Field validators (Section 3). JS regexes are ported as byte-level
--  matchers; JS ^...$ anchors map to exact full-string checks (JS $
--  is absolute end-of-input; no trailing-newline tolerance).
-- ------------------------------------------------------------------

local function isHexLower(c)
    return (c >= 48 and c <= 57) or (c >= 97 and c <= 102)
end

-- ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$
local function isUuidLoose(s)
    if type(s) ~= "string" or #s ~= 36 then return false end
    for i = 1, 36 do
        local c = s:byte(i)
        if i == 9 or i == 14 or i == 19 or i == 24 then
            if c ~= 45 then return false end
        elseif not isHexLower(c) then
            return false
        end
    end
    return true
end

-- UUID v4 with RFC 4122 variant bits (certifier/disinterested-tier id
-- profile): version nibble '4' at offset 15, variant [89ab] at 20.
local function isUuidV4(s)
    if not isUuidLoose(s) then return false end
    if s:byte(15) ~= 52 then return false end                 -- '4'
    local c = s:byte(20)
    return c == 56 or c == 57 or c == 97 or c == 98           -- 8 9 a b
end

-- "sha256:" + 64 lowercase hex
local function isHashDigest(s)
    if type(s) ~= "string" or #s ~= 71 then return false end
    if s:sub(1, 7) ~= "sha256:" then return false end
    for i = 8, 71 do
        if not isHexLower(s:byte(i)) then return false end
    end
    return true
end

-- 64 lowercase hex
local function isHex64(s)
    if type(s) ~= "string" or #s ~= 64 then return false end
    for i = 1, 64 do
        if not isHexLower(s:byte(i)) then return false end
    end
    return true
end

-- ^(EXECUTED BY [A-Z0-9][A-Z0-9 ._-]*|OBSERVED VIA GATEWAY|LOGGED BY AGENT)$
local function isProvenanceClass(s)
    if type(s) ~= "string" then return false end
    if s == "OBSERVED VIA GATEWAY" or s == "LOGGED BY AGENT" then
        return true
    end
    local rest = s:match("^EXECUTED BY (.+)$")
    if rest == nil then return false end
    return rest:match("^[A-Z0-9][A-Z0-9%._%- ]*$") ~= nil
end

-- ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$
-- Returns {y,mon,day,hh,mi,ss,frac,offset} or nil. offset is "Z" or
-- "+hh:mm"/"-hh:mm" — same shape as the JS regex's group 8.
local function tsParts(s)
    if type(s) ~= "string" then return nil end
    local y, mo, d, hh, mi, ss =
        s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)")
    if y == nil then return nil end
    local rest = s:sub(20)
    local frac = rest:match("^%.%d+")
    if frac ~= nil then rest = rest:sub(#frac + 1) end
    if rest == "Z" then
        return { tonumber(y), tonumber(mo), tonumber(d),
                 tonumber(hh), tonumber(mi), tonumber(ss), frac, "Z" }
    end
    local sign, oh, om = rest:match("^([%+%-])(%d%d):(%d%d)$")
    if sign == nil then return nil end
    return { tonumber(y), tonumber(mo), tonumber(d),
             tonumber(hh), tonumber(mi), tonumber(ss), frac,
             sign .. oh .. ":" .. om }
end

local function isLeapYear(y)
    return (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0
end
local function daysInMonth(y, m)
    if m == 2 then return isLeapYear(y) and 29 or 28 end
    if m == 4 or m == 6 or m == 9 or m == 11 then return 30 end
    return 31
end

-- Section 3: "the timestamp MUST denote a valid calendar date and
-- time". Offset bounds: component hours <= 23, minutes <= 59, signed
-- total strictly inside (-1440, 1440) minutes.
local function isCalendarDateTime(s, strict)
    local t = s:gsub("Z", "+00:00", 1)      -- JS s.replace("Z","+00:00")
    local p = tsParts(t)
    if p == nil then return false end
    local year, mon, day = p[1], p[2], p[3]
    local hh, mi, ss = p[4], p[5], p[6]
    if year < 1 or year > 9999 then return false end
    if mon < 1 or mon > 12 then return false end
    if day < 1 or day > daysInMonth(year, mon) then return false end
    if hh > 23 or mi > 59 or ss > 59 then return false end
    local off = p[8]
    if off ~= "Z" then
        local oh = tonumber(off:sub(2, 3))
        local om = tonumber(off:sub(5, 6))
        if oh > 23 or om > 59 then return false end
        local total = (off:sub(1, 1) == "-" and -1 or 1) * (oh * 60 + om)
        if not (total > -1440 and total < 1440) then return false end
    end
    return true
end

local function isRfc3339Format(s, strict)
    return type(s) == "string" and tsParts(s) ~= nil
end
local function isRfc3339(s, strict)
    return isRfc3339Format(s, strict) and isCalendarDateTime(s, strict)
end

-- ------------------------------------------------------------------
--  Section 3/4/5/6 — core receipt check
-- ------------------------------------------------------------------

local canonicalJson  -- forward declaration; defined in the Section 7.1 block

local CORE_MEMBERS = { "id", "receipt_schema_version", "created_at",
    "tool", "provenance_class", "canonical_bytes", "output_hash",
    "verification_status" }

local function checkReceipt(receipt, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    if not isObject(receipt) then return { "receipt is not a JSON object" } end
    local failures = {}
    for _, field in ipairs(CORE_MEMBERS) do
        if rawget(receipt, field) == nil then
            failures[#failures + 1] = "missing required member: " .. field
        end
    end
    if #failures > 0 then return failures end

    local rid = rawget(receipt, "id")
    local idOk
    if strict then idOk = isUuidV4(rid) else idOk = isUuidLoose(rid) end
    if not idOk then
        failures[#failures + 1] = strict and "id is not a UUID v4"
            or "id is not a lowercase UUID"
    end

    local ver = rawget(receipt, "receipt_schema_version")
    if strict then
        if ver ~= "0.3" then
            failures[#failures + 1] = "receipt_schema_version is not 0.3"
        end
    elseif not isNonEmptyString(ver) then
        failures[#failures + 1] =
            "receipt_schema_version is not a non-empty string"
    end

    local cat = rawget(receipt, "created_at")
    if type(cat) ~= "string" or not isRfc3339Format(cat, strict) then
        failures[#failures + 1] = "created_at is not RFC 3339"
    elseif not isCalendarDateTime(cat, strict) then
        failures[#failures + 1] =
            "created_at is not a valid calendar date/time"
    end

    local tool = rawget(receipt, "tool")
    if not isObject(tool)
        or not (isNonEmptyString(rawget(tool, "name"))
            and isNonEmptyString(rawget(tool, "version"))
            and isNonEmptyString(rawget(tool, "scope"))) then
        failures[#failures + 1] =
            "tool is not an object with name/version/scope strings"
    end

    if not isProvenanceClass(rawget(receipt, "provenance_class")) then
        failures[#failures + 1] = "provenance_class is not a known class"
    end

    local raw = decodeBase64Strict(rawget(receipt, "canonical_bytes"), strict)
    if raw == nil then
        failures[#failures + 1] = "canonical_bytes is not valid base64"
    elseif not isValidUtf8(raw) then
        failures[#failures + 1] = "canonical_bytes is not valid UTF-8"
        raw = nil
    end

    local oh = rawget(receipt, "output_hash")
    if type(oh) ~= "string" or not isHashDigest(oh) then
        failures[#failures + 1] =
            "output_hash is not sha256: + lowercase hex digest"
    elseif raw ~= nil then
        if oh ~= "sha256:" .. sha256hex(raw) then
            failures[#failures + 1] =
                "output_hash does not match sha256(canonical_bytes)"
        end
    end

    if rawget(receipt, "verification_status") ~= "verified" then
        failures[#failures + 1] = "verification_status is not verified"
    end

    -- Version agreement + provenance binding (reference runner checks
    -- 9a/9c, draft -13). Legacy envelopes (outer 0.2/0.3 with no inner
    -- version commitment) pass; otherwise the inner payload MUST commit
    -- a receipt_schema_version equal to the outer one, and a committed
    -- issuer_claims.provenance_class MUST equal the outer value.
    if raw ~= nil then
        local inner = parseJson(raw)
        local innerObj = isObject(inner)
        local innerHasV = innerObj
            and rawget(inner, "receipt_schema_version") ~= nil
        local innerVersion = innerHasV
            and rawget(inner, "receipt_schema_version") or nil
        local outerHasV = rawget(receipt, "receipt_schema_version") ~= nil
        local outerVersion = rawget(receipt, "receipt_schema_version")
        local legacyProbe = innerHasV and innerVersion or outerVersion
        local isLegacy = legacyProbe == "0.2" or legacyProbe == "0.3"
        if not isLegacy then
            if innerHasV and outerHasV then
                if jsStr(innerVersion) ~= jsStr(outerVersion) then
                    failures[#failures + 1] = "receipt_schema_version: outer "
                        .. qstr(outerVersion) .. " does not match committed "
                        .. qstr(innerVersion)
                end
            else
                failures[#failures + 1] = "receipt_schema_version: one side missing (outer="
                    .. qstr(outerVersion) .. " committed=" .. qstr(innerVersion) .. ")"
            end
            if innerObj then
                local icl = rawget(inner, "issuer_claims")
                local hasIc = isObject(icl)
                    and rawget(icl, "provenance_class") ~= nil
                local ic = hasIc and rawget(icl, "provenance_class") or nil
                local opc = rawget(receipt, "provenance_class")
                local opcDefined = rawget(receipt, "provenance_class") ~= nil
                if hasIc and opcDefined then
                    if ic ~= opc then
                        failures[#failures + 1] = "provenance binding: outer "
                            .. qstr(opc) .. " does not match committed " .. qstr(ic)
                    end
                else
                    failures[#failures + 1] = "provenance binding: one side missing (outer="
                        .. qstr(opc) .. " committed=" .. qstr(ic) .. ")"
                end
            else
                failures[#failures + 1] =
                    "provenance binding: canonical payload did not decode to an object"
            end
        end
    end

    -- Section 3.1 metadata_hash (draft -14): REQUIRED when
    -- receipt_schema_version is "0.4" or later; whenever present a
    -- verifier MUST recompute the canonical metadata block and compare.
    if rawget(receipt, "metadata_hash") == nil then
        if type(ver) == "string" and versionGeq(ver, "0.4") then
            failures[#failures + 1] =
                "metadata_hash is required at receipt_schema_version 0.4 or later"
        end
    else
        local mh = rawget(receipt, "metadata_hash")
        if type(mh) ~= "string" or not isHashDigest(mh) then
            failures[#failures + 1] =
                "metadata_hash is not sha256: + lowercase hex digest"
        else
            local metaTool
            if isObject(tool) then
                metaTool = setmetatable({
                    name = rawget(tool, "name"),
                    scope = rawget(tool, "scope"),
                    version = rawget(tool, "version"),
                }, OBJMT)
            else
                metaTool = tool
            end
            local meta = setmetatable({
                created_at = rawget(receipt, "created_at"),
                id = rawget(receipt, "id"),
                output_hash = rawget(receipt, "output_hash"),
                provenance_class = rawget(receipt, "provenance_class"),
                receipt_schema_version = rawget(receipt, "receipt_schema_version"),
                tool = metaTool,
            }, OBJMT)
            local want = "sha256:" .. sha256hex(canonicalJson(meta))
            if mh ~= want then
                failures[#failures + 1] =
                    "metadata_hash does not match sha256(canonical metadata block)"
            end
        end
    end
    return failures
end

local function checkReferenceProfile(receipt, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    if not isObject(receipt) then return { "receipt is not a JSON object" } end
    local raw = decodeBase64Strict(rawget(receipt, "canonical_bytes"), strict)
    if raw == nil then return { "canonical_bytes is not valid base64" } end
    if not isValidUtf8(raw) then
        return { "canonical_bytes is not valid UTF-8" }
    end
    local payload = parseJson(raw)
    if payload == nil then return { "canonical payload is not JSON" } end
    if not isObject(payload) then
        return { "canonical payload is not a JSON object" }
    end
    if rawget(payload, "inputs") == nil then
        return { "reference-producer payload omits the 'inputs' member" }
    end
    return {}
end

-- ------------------------------------------------------------------
--  Disinterested-tier anchor check (kit convention): anchor members
--  exactly {log, leaf_hash, anchored_at, proof}; leaf_hash = "sha256:"
--  + SHA-256 of the raw canonical bytes.
-- ------------------------------------------------------------------

local TIER_MEMBERS = { "anchored_at", "leaf_hash", "log", "proof" }

local function anchorUsesTierShape(receipt)
    if not isObject(receipt) then return false end
    local a = rawget(receipt, "anchor")
    if not isObject(a) then return false end
    for _, k in ipairs(TIER_MEMBERS) do
        if rawget(a, k) ~= nil then return true end
    end
    return false
end

local function checkDisinterestedTier(receipt, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    local failures = {}
    local rid = isObject(receipt) and rawget(receipt, "id") or nil
    if type(rid) ~= "string" or not isUuidV4(rid) then
        failures[#failures + 1] = "id is not a UUID v4 (strict profile)"
    end
    local anchor = isObject(receipt) and rawget(receipt, "anchor") or nil
    if not isObject(anchor) then
        return { "anchor is not an object" }    -- JS: early return, discards
    end
    local keys = {}
    for k in pairs(anchor) do keys[#keys + 1] = k end
    table.sort(keys)
    if table.concat(keys, ",") ~= "anchored_at,leaf_hash,log,proof" then
        failures[#failures + 1] =
            "anchor has members other than log/leaf_hash/anchored_at/proof"
    end
    if not isNonEmptyString(rawget(anchor, "log")) then
        failures[#failures + 1] = "anchor.log is not a non-empty string"
    end
    local leaf = rawget(anchor, "leaf_hash")
    if type(leaf) ~= "string" or not isHashDigest(leaf) then
        failures[#failures + 1] =
            "anchor.leaf_hash is not sha256: + lowercase hex digest"
    else
        local raw = decodeBase64Strict(rawget(receipt, "canonical_bytes"), strict)
        if raw == nil then
            failures[#failures + 1] = "canonical_bytes is not valid base64"
        elseif not isValidUtf8(raw) then
            failures[#failures + 1] = "canonical_bytes is not valid UTF-8 base64"
        elseif leaf ~= "sha256:" .. sha256hex(raw) then
            failures[#failures + 1] =
                "anchor.leaf_hash does not match canonical_bytes"
        end
    end
    if not isRfc3339(rawget(anchor, "anchored_at"), strict) then
        failures[#failures + 1] =
            "anchor.anchored_at is not a valid calendar date/time"
    end
    if not isObject(rawget(anchor, "proof")) then
        failures[#failures + 1] = "anchor.proof is not an object"
    end
    return failures
end

-- ------------------------------------------------------------------
--  Section 7.1 — canonical JSON for the -07/-08 entry digest.
--  Members sorted by code point (UTF-8 byte order == UTF-16 code unit
--  order for valid strings), no whitespace, integer-valued numbers in
--  integer form, every non-ASCII escaped as \uXXXX (surrogate pairs
--  for non-BMP); U+007F is escaped like the reference (Python
--  ensure_ascii) since draft -10.
-- ------------------------------------------------------------------

local function escapeCanonicalString(str)
    local out = { '"' }
    local i, n = 1, #str
    local function cont(k)
        local b = str:byte(k)
        return b ~= nil and b >= 0x80 and b <= 0xbf
    end
    while i <= n do
        local c = str:byte(i)
        local cp, adv
        if c < 0x80 then
            cp, adv = c, 1
        elseif c >= 0xc2 and c < 0xe0 and cont(i + 1) then
            cp, adv = (c & 0x1f) << 6 | (str:byte(i + 1) & 0x3f), 2
        elseif c >= 0xe0 and c < 0xf0 and cont(i + 1) and cont(i + 2) then
            cp, adv = (c & 0x0f) << 12
                | ((str:byte(i + 1) & 0x3f) << 6)
                | (str:byte(i + 2) & 0x3f), 3
        elseif c >= 0xf0 and c <= 0xf4
            and cont(i + 1) and cont(i + 2) and cont(i + 3) then
            cp, adv = (c & 0x07) << 18
                | ((str:byte(i + 1) & 0x3f) << 12)
                | ((str:byte(i + 2) & 0x3f) << 6)
                | (str:byte(i + 3) & 0x3f), 4
        else
            cp, adv = 0xfffd, 1             -- malformed UTF-8 -> U+FFFD
        end
        if cp == 0x22 then out[#out + 1] = '\\"'
        elseif cp == 0x5c then out[#out + 1] = '\\\\'
        elseif cp == 0x08 then out[#out + 1] = '\\b'
        elseif cp == 0x09 then out[#out + 1] = '\\t'
        elseif cp == 0x0a then out[#out + 1] = '\\n'
        elseif cp == 0x0c then out[#out + 1] = '\\f'
        elseif cp == 0x0d then out[#out + 1] = '\\r'
        elseif cp < 0x20 or cp >= 0x7f then
            if cp <= 0xffff then
                out[#out + 1] = string.format('\\u%04x', cp)
            else                            -- non-BMP -> surrogate pair
                local u = cp - 0x10000
                out[#out + 1] = string.format('\\u%04x\\u%04x',
                    0xd800 + (u >> 10), 0xdc00 + (u & 0x3ff))
            end
        else
            out[#out + 1] = string.char(cp)
        end
        i = i + adv
    end
    out[#out + 1] = '"'
    return table.concat(out)
end

canonicalJson = function(v)
    if v == JSON_NULL then return "null" end
    if v == true then return "true" end
    if v == false then return "false" end
    local tv = type(v)
    if tv == "number" then return jsNum(v) end
    if tv == "string" then return escapeCanonicalString(v) end
    if isArray(v) then
        local parts = {}
        for i = 1, #v do parts[i] = canonicalJson(v[i]) end
        return "[" .. table.concat(parts, ",") .. "]"
    end
    if isObject(v) then
        local keys = {}
        for k in pairs(v) do keys[#keys + 1] = k end
        table.sort(keys)                    -- byte order == code point order
        local parts = {}
        for i, k in ipairs(keys) do
            parts[i] = escapeCanonicalString(k) .. ":"
                .. canonicalJson(rawget(v, k))
        end
        return "{" .. table.concat(parts, ",") .. "}"
    end
    error("unserializable value in canonical JSON", 0)
end

-- The -07/-08 entry digest (Section 7.1). Throws on invalid base64
-- (fail closed). The eight payload members are emitted directly in
-- canonical (sorted) key order: close, id, job_id, output_hash,
-- prev_digest, provenance_class, seq, tool.
local function entryDigestV07(entry, strict)
    local raw = decodeBase64Strict(rawget(entry, "canonical_bytes"), strict)
    if raw == nil then error("canonical_bytes is not valid base64", 0) end
    -- JS canonicalJson throws on an undefined member; a missing member
    -- here fails the same way.
    local function member(name)
        local v = rawget(entry, name)
        if v == nil then
            error("unserializable value in canonical JSON", 0)
        end
        return canonicalJson(v)
    end
    local closev = rawget(entry, "close")
    if closev == nil then closev = false end    -- JS: undefined -> false
    local body = '{"close":' .. canonicalJson(closev)
        .. ',"id":' .. member("id")
        .. ',"job_id":' .. member("job_id")
        .. ',"output_hash":' .. escapeCanonicalString(sha256hex(raw))
        .. ',"prev_digest":' .. member("prev_digest")
        .. ',"provenance_class":' .. member("provenance_class")
        .. ',"seq":' .. member("seq")
        .. ',"tool":' .. member("tool")
        .. '}'
    return sha256hex(body)
end

-- ------------------------------------------------------------------
--  Section 7.2 — chain rules (-07/-08 construction). Structural
--  failures abort immediately, fail closed.
-- ------------------------------------------------------------------

local GENESIS = string.rep("0", 64)
local CHAIN_STRING_MEMBERS = { "id", "tool", "provenance_class",
    "job_id", "prev_digest" }

local function verifyChainV07(timeline, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    if not isArray(timeline) or #timeline == 0 then
        return { "timeline is not a non-empty list" }
    end
    local failures = {}
    local job, prev = nil, nil
    for i = 1, #timeline do
        local idx = i - 1                       -- JS loop index is 0-based
        local entry = timeline[i]
        if not isObject(entry) then
            return { "entry " .. idx .. " is not an object" }
        end
        if decodeBase64Strict(rawget(entry, "canonical_bytes"), strict) == nil then
            return { "entry " .. idx .. " canonical_bytes is not valid base64" }
        end
        for _, m in ipairs(CHAIN_STRING_MEMBERS) do
            if not isNonEmptyString(rawget(entry, m)) then
                return { "entry " .. idx .. " missing " .. m }
            end
        end
        local seq = rawget(entry, "seq")
        if not isIntegerValued(seq) then
            return { "entry " .. idx .. " seq is not an integer" }
        end
        -- Sequence: first entry seq MUST be 1, then contiguous.
        if seq ~= i then
            failures[#failures + 1] = "entry " .. idx .. " seq "
                .. jsNum(seq) .. " breaks contiguity (expected " .. i .. ")"
        end
        -- One job per timeline.
        local jid = rawget(entry, "job_id")
        if job == nil then job = jid
        elseif jid ~= job then
            failures[#failures + 1] = "entry " .. idx .. " job_id mixes jobs"
        end
        -- Genesis / link checks against recomputed digests.
        local pd = rawget(entry, "prev_digest")
        if idx == 0 then
            if pd ~= GENESIS then
                failures[#failures + 1] =
                    "entry 0 prev_digest is not 64 zero characters"
            end
        elseif pd ~= prev then
            failures[#failures + 1] = "entry " .. idx
                .. " prev_digest does not match the digest of entry " .. (idx - 1)
        end
        -- Closing: close must be boolean; true only on the last entry.
        local closev = rawget(entry, "close")
        if closev ~= nil and type(closev) ~= "boolean" then
            failures[#failures + 1] = "entry " .. idx .. " close is not a boolean"
        elseif i < #timeline and closev == true then
            failures[#failures + 1] =
                "entry " .. idx .. " carries close:true before the last entry"
        end
        prev = entryDigestV07(entry, strict)    -- cannot throw: prevalidated
    end
    local last = timeline[#timeline]
    if not isObject(last) or rawget(last, "close") ~= true then
        failures[#failures + 1] = "last entry does not carry close: true"
    end
    return failures
end

-- ------------------------------------------------------------------
--  Historical -06 construction (Section 7.4): entry digest = SHA-256
--  of the base64-decoded canonical_bytes alone. Labeled legacy check —
--  a -06 chain MUST NOT be reported as verifying under -07/-08.
-- ------------------------------------------------------------------

local function verifyChainV06(timeline, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    if not isArray(timeline) or #timeline == 0 then
        return { "timeline is not a non-empty list" }
    end
    local failures = {}
    local prev = nil
    for i = 1, #timeline do
        local idx = i - 1
        local entry = timeline[i]
        if not isObject(entry) then
            failures[#failures + 1] = "entry " .. idx .. " is not an object"
            break
        end
        local raw = decodeBase64Strict(rawget(entry, "canonical_bytes"), strict)
        if raw == nil then
            failures[#failures + 1] =
                "entry " .. idx .. " canonical_bytes is not valid base64"
            break
        end
        local digest = sha256hex(raw)
        local pd = rawget(entry, "prev_digest")
        if idx == 0 then
            if type(pd) ~= "string" or pd ~= GENESIS then
                failures[#failures + 1] =
                    "entry 0 prev_digest is not 64 zero characters"
            end
        elseif type(pd) ~= "string" or pd ~= prev then
            failures[#failures + 1] = "entry " .. idx
                .. " prev_digest does not match the digest of entry " .. (idx - 1)
        end
        prev = digest
    end
    local last = timeline[#timeline]
    if isObject(last) and rawget(last, "close") ~= true then
        failures[#failures + 1] = "last entry does not carry close: true"
    end
    return failures
end

-- ------------------------------------------------------------------
--  Section 7.3 — external truncation commitment (bare digest binding
--  the FINAL entry digest) and the -09 chain commitment artifact.
-- ------------------------------------------------------------------

local function finalEntryDigestV07(timeline, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    if not isArray(timeline) or #timeline == 0 then return nil end
    local last = timeline[#timeline]
    if not isObject(last) then return nil end
    local ok, d = pcall(entryDigestV07, last, strict)
    if ok then return d end
    return nil
end

local function checkFinalEntryCommitment(timeline, commitment, opts)
    if type(commitment) ~= "string" or not isHex64(commitment) then
        return { "external commitment is not a lowercase hex SHA-256 digest" }
    end
    local digest = finalEntryDigestV07(timeline, opts)
    if digest == nil then
        return { "cannot recompute the final entry digest" }
    end
    if digest ~= commitment then
        return { "external commitment does not bind the final entry digest "
            .. "(expected " .. commitment .. ", recomputed " .. digest .. ")" }
    end
    return {}
end

local function verifyChainCommitment(timeline, commitment, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    if not isArray(timeline) or #timeline == 0 then
        return { "timeline is not a non-empty list" }
    end
    if not isObject(commitment) then
        return { "commitment is not a JSON object" }
    end
    local failures = {}
    local jobId = rawget(commitment, "job_id")
    if not isNonEmptyString(jobId) then
        failures[#failures + 1] = "commitment job_id is not a non-empty string"
    end
    local digest = rawget(commitment, "final_entry_digest")
    if type(digest) ~= "string" or not isHex64(digest) then
        failures[#failures + 1] =
            "commitment final_entry_digest is not 64 lowercase hex characters"
    end
    local count = rawget(commitment, "entry_count")
    -- __float__ marker: entry_count written as 5.0 must be rejected;
    -- our parser marks it the way the kit loader does.
    if (isObject(count) and rawget(count, "__float__") ~= nil)
        or type(count) ~= "number" or not isIntegerValued(count) then
        failures[#failures + 1] = "commitment entry_count is not an integer"
    end
    local published = rawget(commitment, "published_at")
    if not isRfc3339Format(published, strict) then
        failures[#failures + 1] = "commitment published_at is not RFC 3339"
    elseif not isCalendarDateTime(published, strict) then
        failures[#failures + 1] =
            "commitment published_at is not a valid calendar date/time"
    end
    if #failures > 0 then return failures end
    -- Member shapes are sound; now bind them to the timeline.
    if count ~= #timeline then
        failures[#failures + 1] = "commitment entry_count " .. jsNum(count)
            .. " does not match the timeline length " .. #timeline
    end
    for i = 1, #timeline do
        local e = timeline[i]
        local jid = isObject(e) and rawget(e, "job_id") or nil
        if not isNonEmptyString(jid) then
            failures[#failures + 1] = "entry " .. (i - 1) .. " has no usable job_id"
        elseif jid ~= jobId then
            failures[#failures + 1] = "entry " .. (i - 1)
                .. " job_id does not match the commitment job_id"
        end
    end
    -- Recompute the last entry's digest, fail closed like verifyChainV07.
    local last = timeline[#timeline]
    local want = nil
    local ok, res = pcall(function()
        local cb = isObject(last) and rawget(last, "canonical_bytes") or nil
        if decodeBase64Strict(cb, strict) == nil then
            error("bad base64", 0)
        end
        return entryDigestV07(last, strict)
    end)
    if ok then
        want = res
    else
        failures[#failures + 1] =
            "final entry digest could not be recomputed: " .. res
    end
    if want ~= nil and digest ~= want then
        failures[#failures + 1] =
            "commitment final_entry_digest does not match the "
            .. "recomputed digest of the last entry"
    end
    return failures
end

-- ------------------------------------------------------------------
--  Section 8.1 — Merkle root over ordered step receipt_id values.
-- ------------------------------------------------------------------

local function merkleRoot(receiptIds)
    if #receiptIds == 0 then return sha256hex("") end
    local level = {}
    for i, rid in ipairs(receiptIds) do level[i] = sha256bin(rid) end
    while #level > 1 do
        if #level % 2 == 1 then level[#level + 1] = level[#level] end
        local nxt = {}
        local j = 0
        for i = 1, #level, 2 do
            j = j + 1
            nxt[j] = sha256bin(level[i] .. level[i + 1])
        end
        level = nxt
    end
    return hexenc(level[1])
end

-- ------------------------------------------------------------------
--  Section 8.2 (offline part) — workflow structural checks.
-- ------------------------------------------------------------------

local function verifyWorkflowSteps(w)
    if not isObject(w) then return { "workflow is not a JSON object" } end
    local steps = rawget(w, "steps")
    if not isArray(steps) or #steps == 0 then
        return { "workflow steps is not a non-empty list" }
    end
    local failures = {}
    for i = 1, #steps do
        local s = steps[i]
        if not isObject(s) then
            failures[#failures + 1] = "step " .. i .. " is not an object"
        else
            if not isIntegerValued(rawget(s, "seq")) then
                failures[#failures + 1] = "step " .. i .. " seq is not an integer"
            end
            if type(rawget(s, "receipt_id")) ~= "string" then
                failures[#failures + 1] =
                    "step " .. i .. " receipt_id is not a string"
            end
        end
    end
    if #failures > 0 then return failures end
    for i = 1, #steps do
        local sq = rawget(steps[i], "seq")
        if sq ~= i then
            failures[#failures + 1] = "step seq values are not 1.."
                .. #steps .. " in order (index " .. (i - 1)
                .. " carries seq " .. jsNum(sq) .. ")"
            break
        end
    end
    local seen = {}
    for _, s in ipairs(steps) do
        local rid = rawget(s, "receipt_id")
        if seen[rid] then
            failures[#failures + 1] = "duplicate receipt_id: " .. rid
            break
        end
        seen[rid] = true
    end
    local ids = {}
    for i, s in ipairs(steps) do ids[i] = rawget(s, "receipt_id") end
    if type(rawget(w, "merkle_root")) ~= "string"
        or rawget(w, "merkle_root") ~= merkleRoot(ids) then
        failures[#failures + 1] =
            "merkle_root does not match the recomputed Section 8.1 root"
    end
    return failures
end

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

local function checkWorkflow(w, opts)
    local strict = opts ~= nil and jsTruthy(opts.strict)
    if not isObject(w) then return { "workflow is not a JSON object" } end
    local failures = {}
    for _, field in ipairs({ "type", "version", "workflow_id", "receipt_id",
        "session_id", "goal", "status", "steps", "merkle_root",
        "output_hash", "verify_url" }) do
        if rawget(w, field) == nil then
            failures[#failures + 1] = "missing required member: " .. field
        end
    end
    if #failures > 0 then return failures end
    if rawget(w, "type") ~= "verifiable-workflow-receipt" then
        failures[#failures + 1] = 'type is not "verifiable-workflow-receipt"'
    end
    if not isNonEmptyString(rawget(w, "version")) then
        failures[#failures + 1] = "version is not a non-empty string"
    end
    local uuidOk = strict and isUuidV4 or isUuidLoose
    if not uuidOk(rawget(w, "workflow_id")) then
        failures[#failures + 1] = "workflow_id is not a UUID"
    end
    if not uuidOk(rawget(w, "receipt_id")) then
        failures[#failures + 1] = "receipt_id is not a UUID"
    end
    for _, k in ipairs({ "session_id", "goal", "status" }) do
        if not isNonEmptyString(rawget(w, k)) then
            failures[#failures + 1] = k .. " is not a non-empty string"
        end
    end
    if not isHex64(rawget(w, "merkle_root")) then
        failures[#failures + 1] =
            "merkle_root is not a lowercase hex SHA-256 digest"
    end
    if not isHex64(rawget(w, "output_hash")) then
        failures[#failures + 1] =
            "output_hash is not a lowercase hex SHA-256 digest"
    end
    local vu = rawget(w, "verify_url")
    if not isNonEmptyString(vu)
        or not (vu:sub(1, 7) == "http://" or vu:sub(1, 8) == "https://") then
        failures[#failures + 1] = "verify_url is not an http(s) URL"
    end
    local steps = rawget(w, "steps")
    if isArray(steps) then
        for i = 1, #steps do
            local s = steps[i]
            if not isObject(s) then
                failures[#failures + 1] = "step " .. i .. " is not an object"
            else
                for _, field in ipairs({ "seq", "tool", "receipt_id",
                    "receipt_hash", "started_at", "ended_at", "status" }) do
                    if rawget(s, field) == nil then
                        failures[#failures + 1] =
                            "step " .. i .. " missing member: " .. field
                    end
                end
                if not isIntegerValued(rawget(s, "seq")) then
                    failures[#failures + 1] =
                        "step " .. i .. " seq is not an integer"
                end
                if not isNonEmptyString(rawget(s, "tool")) then
                    failures[#failures + 1] =
                        "step " .. i .. " tool is not a non-empty string"
                end
                if not uuidOk(rawget(s, "receipt_id")) then
                    failures[#failures + 1] =
                        "step " .. i .. " receipt_id is not a UUID"
                end
                if not isHex64(rawget(s, "receipt_hash")) then
                    failures[#failures + 1] =
                        "step " .. i .. " receipt_hash is not hex SHA-256"
                end
                if not isRfc3339(rawget(s, "started_at"), strict) then
                    failures[#failures + 1] =
                        "step " .. i .. " started_at is not RFC 3339"
                end
                if not isRfc3339(rawget(s, "ended_at"), strict) then
                    failures[#failures + 1] =
                        "step " .. i .. " ended_at is not RFC 3339"
                end
                if not isNonEmptyString(rawget(s, "status")) then
                    failures[#failures + 1] =
                        "step " .. i .. " status is not a non-empty string"
                end
            end
        end
    end
    local sub = verifyWorkflowSteps(w)
    for _, f in ipairs(sub) do failures[#failures + 1] = f end
    return failures
end

-- ------------------------------------------------------------------
--  Top-level dispatch
-- ------------------------------------------------------------------

local function verifyDocument(doc, opts)
    opts = opts or {}
    local results = {}
    local function push(check, reasons)
        for _, r in ipairs(reasons) do
            results[#results + 1] = { check = check, reason = r }
        end
    end

    local kind = opts.kind or "auto"
    if kind == "auto" then
        if isArray(doc) then
            kind = "chain"
        elseif isObject(doc)
            and (isArray(rawget(doc, "timeline"))
                or isArray(rawget(doc, "entries"))) then
            kind = "chain"
        elseif isObject(doc)
            and rawget(doc, "type") == "verifiable-workflow-receipt" then
            kind = "workflow"
        else
            kind = "receipt"
        end
    end

    if kind == "chain" or kind == "chain06" then
        local tl
        if isArray(doc) then
            tl = doc
        elseif isObject(doc) then
            local t = rawget(doc, "timeline")
            tl = jsTruthy(t) and t or rawget(doc, "entries")  -- JS a || b
        end
        if kind == "chain06" then
            push("chain(-06)", verifyChainV06(tl, opts))
        else
            push("chain(-07/-08)", verifyChainV07(tl, opts))
        end
        if isObject(doc) and type(rawget(doc, "final_entry_digest")) == "string" then
            push("final-entry-commitment",
                checkFinalEntryCommitment(tl,
                    rawget(doc, "final_entry_digest"), opts))
        end
        if isObject(doc) and rawget(doc, "commitment") ~= nil then
            push("chain-commitment(-09)",
                verifyChainCommitment(tl, rawget(doc, "commitment"), opts))
        end
        return kind, results
    end
    if kind == "workflow" then
        push("workflow", checkWorkflow(doc, opts))
        return kind, results
    end
    -- Receipt.
    push("core", checkReceipt(doc, opts))
    if jsTruthy(opts.profile) then
        push("reference-profile", checkReferenceProfile(doc, opts))
    end
    if opts.anchor == true or anchorUsesTierShape(doc) then
        push("disinterested-tier", checkDisinterestedTier(doc, opts))
    end
    return kind, results
end

local function verifyFile(file, opts)
    local f = io.open(file, "rb")
    if f == nil then
        -- verify.js crashes on an unreadable file; fail closed instead
        return "?", { { check = "parse", reason = "not valid JSON" } }
    end
    local text = f:read("a")
    f:close()
    local doc = parseJson(text)
    if doc == nil then
        return "?", { { check = "parse", reason = "not valid JSON" } }
    end
    return verifyDocument(doc, opts)
end

-- ------------------------------------------------------------------
--  Directory walk (recursive, sorted; pure-Lua apart from `ls`/`test`
--  via the shell — there is no stdlib directory API in Lua).
-- ------------------------------------------------------------------

local function shq(p)                         -- shell-quote a path
    return '"' .. p:gsub('(["\\%$`])', '\\%1') .. '"'
end

local function isDir(p)
    return os.execute("test -d " .. shq(p)) == true
end

local function walkJson(dir, out)
    local h = io.popen("ls -A1 " .. shq(dir) .. " 2>/dev/null")
    if h == nil then return end
    for name in h:lines() do
        if name:sub(1, 1) ~= "." then         -- hidden names skipped
            local full = dir .. "/" .. name
            if isDir(full) then
                walkJson(full, out)
            elseif name:sub(-5) == ".json" then
                out[#out + 1] = full
            end
        end
    end
    h:close()
end

local function collectJsonFiles(target)
    target = target:gsub("/+$", "")
    if target == "" then target = "/" end
    if isDir(target) then
        local out = {}
        walkJson(target, out)
        table.sort(out)                       -- byte-order path sort
        return out
    end
    return { target }
end

-- ------------------------------------------------------------------
--  --json report emitter (field order: file, kind, ok, failures)
-- ------------------------------------------------------------------

local function jsonQuote(s)
    return '"' .. s:gsub('[%c"\\]', function(c)
        if c == '"' then return '\\"' end
        if c == "\\" then return "\\\\" end
        return string.format("\\u%04x", c:byte())
    end) .. '"'
end

local function emitReportJson(report)
    local L = { "[" }
    for i, r in ipairs(report) do
        L[#L + 1] = (i > 1 and " ," or " ") .. "{"
        L[#L + 1] = '  "file": ' .. jsonQuote(r.file) .. ","
        L[#L + 1] = '  "kind": ' .. jsonQuote(r.kind) .. ","
        L[#L + 1] = '  "ok": ' .. (r.ok and "true" or "false") .. ","
        L[#L + 1] = '  "failures": ['
        for j, f in ipairs(r.failures) do
            L[#L + 1] = '   { "check": ' .. jsonQuote(f.check)
                .. ', "reason": ' .. jsonQuote(f.reason) .. " }"
                .. (j < #r.failures and "," or "")
        end
        L[#L + 1] = "  ]"
        L[#L + 1] = " }" .. (i < #report and "," or "")
    end
    L[#L + 1] = "]"
    return table.concat(L, "\n")
end

-- ------------------------------------------------------------------
--  Self-test (--selftest): pinned vectors for the vendored primitives.
-- ------------------------------------------------------------------

local function selfTest()
    local bad = 0
    local function check(name, got, want)
        if got == want then
            print("ok   " .. name)
        else
            bad = bad + 1
            print("FAIL " .. name .. " got=" .. tostring(got)
                .. " want=" .. tostring(want))
        end
    end
    check("sha256('abc')", sha256hex("abc"),
        "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
    check("sha256('')", sha256hex(""),
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
    check("sha256(56B)", sha256hex(string.rep("a", 56)),
        "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a")
    check("sha256(64B)", sha256hex(string.rep("a", 64)),
        "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb")
    check("b64 'SGVsbG8='", decodeBase64Strict("SGVsbG8="), "Hello")
    check("b64 '' decoded", decodeBase64Strict(""), "")
    check("b64 unpadded", decodeBase64Strict("SGVsbG8"), nil)
    check("b64 badchar", decodeBase64Strict("SGVs bG8="), nil)
    check("b64 midpad", decodeBase64Strict("SG=s bG8="), nil)
    check("utf8 ok", isValidUtf8("héllo wörld"), true)
    check("utf8 lone cont", isValidUtf8("a\128b"), false)
    check("utf8 overlong", isValidUtf8("\192\175"), false)
    check("utf8 surrogate", isValidUtf8("\237\160\128"), false)
    check("utf8 trunc", isValidUtf8("abc\226\130"), false)
    check("utf8 4-byte", isValidUtf8("\240\159\146\169"), true)
    check("utf8 >10FFFF", isValidUtf8("\244\144\128\128"), false)
    check("json obj", (function()
        local d = parseJson('{"b":1,"a":[true,null,"x"]}')
        return isObject(d) and isArray(rawget(d, "a"))
            and rawget(d, "a")[2] == JSON_NULL
    end)(), true)
    check("json bad", parseJson("{bad"), nil)
    check("json float mark", (function()
        local d = parseJson('{"entry_count": 5.0}')
        local c = rawget(d, "entry_count")
        return isObject(c) and rawget(c, "__float__") == 5.0
    end)(), true)
    check("json seq float ok", (function()
        local d = parseJson('{"seq": 2.0}')
        return isIntegerValued(rawget(d, "seq"))
    end)(), true)
    check("merkle empty", merkleRoot({}),
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
    print(bad == 0 and "selftest: all ok" or ("selftest: " .. bad .. " failed"))
    return bad == 0 and 0 or 1
end

-- ------------------------------------------------------------------
--  CLI
-- ------------------------------------------------------------------

local USAGE = "usage: lua5.4 aer1verify.lua <file-or-dir> [--strict] "
    .. "[--chain|--chain06|--workflow|--receipt] [--profile] [--anchor] "
    .. "[--json] [--selftest]\n"

local function runCLI(argv)
    local opts = { kind = "auto" }
    local targets = {}
    local selftest = false
    for _, a in ipairs(argv) do
        if a == "--strict" then opts.strict = true
        elseif a == "--chain06" then opts.kind = "chain06"
        elseif a == "--chain" then opts.kind = "chain"
        elseif a == "--workflow" then opts.kind = "workflow"
        elseif a == "--receipt" then opts.kind = "receipt"
        elseif a == "--profile" then opts.profile = true
        elseif a == "--anchor" then opts.anchor = true
        elseif a == "--json" then opts.json = true
        elseif a == "--selftest" then selftest = true
        else targets[#targets + 1] = a
        end
    end
    if selftest then return selfTest() end
    if #targets == 0 then
        io.stderr:write(USAGE)
        return 2
    end
    local all = {}
    for _, t in ipairs(targets) do
        for _, f in ipairs(collectJsonFiles(t)) do all[#all + 1] = f end
    end
    local bad = 0
    local report = {}
    for _, f in ipairs(all) do
        local kind, failures = verifyFile(f, opts)
        local ok = #failures == 0
        if not ok then bad = bad + 1 end
        report[#report + 1] =
            { file = f, kind = kind, ok = ok, failures = failures }
        if not opts.json then
            local label = "[" .. kind .. "]"
            if ok then
                print("PASS " .. label .. " " .. f)
            else
                local line = "FAIL " .. label .. " " .. f .. " — "
                    .. failures[1].reason
                if #failures > 1 then
                    line = line .. " (+" .. (#failures - 1) .. " more)"
                end
                print(line)
            end
        end
    end
    if opts.json then
        print(emitReportJson(report))
    else
        print("\n" .. (#all - bad) .. "/" .. #all .. " verified")
        print("CONFORMANCE: " .. (#all - bad) .. "/" .. #all)
    end
    return bad == 0 and 0 or 1
end

-- ------------------------------------------------------------------
--  Script entry / module export
-- ------------------------------------------------------------------

local M = {
    parseJson = parseJson,
    verifyDocument = verifyDocument,
    verifyFile = verifyFile,
    checkReceipt = checkReceipt,
    checkReferenceProfile = checkReferenceProfile,
    checkDisinterestedTier = checkDisinterestedTier,
    anchorUsesTierShape = anchorUsesTierShape,
    verifyChainV07 = verifyChainV07,
    verifyChainV06 = verifyChainV06,
    entryDigestV07 = entryDigestV07,
    finalEntryDigestV07 = finalEntryDigestV07,
    checkFinalEntryCommitment = checkFinalEntryCommitment,
    verifyChainCommitment = verifyChainCommitment,
    merkleRoot = merkleRoot,
    verifyWorkflowSteps = verifyWorkflowSteps,
    checkWorkflow = checkWorkflow,
    canonicalJson = canonicalJson,
    decodeBase64Strict = decodeBase64Strict,
    isRfc3339 = isRfc3339,
    sha256hex = sha256hex,
    isValidUtf8 = isValidUtf8,
    collectJsonFiles = collectJsonFiles,
    runCLI = runCLI,
    JSON_NULL = JSON_NULL,
    isObject = isObject,
    isArray = isArray,
}

-- Run the CLI only when executed as a script (not when require()'d).
local src = debug.getinfo(1, "S").source
if src:sub(1, 1) == "@" then src = src:sub(2) end
if arg ~= nil and arg[0] ~= nil and arg[0] == src then
    os.exit(runCLI(arg))
end

return M
