# AER-1 PHP port notes

Single-file, dependency-free PHP 8.2 (CLI) port of the ARION AER-1
verifier (`../verify.js`, 951 lines), matching the Swift
(`../swift/aer1verify.swift`) and Java (`../java/`) port conventions.

## Files

- `aer1verify.php` — the verifier + CLI. stdlib only: `json`, `hash`,
  `pcre`, `spl`, `base64_decode` — everything in a stock `php-cli` build.
  No composer, no extensions, no network. Public functions mirror
  verify.js 1:1: `checkReceipt`, `checkReferenceProfile`,
  `checkDisinterestedTier`, `anchorUsesTierShape`, `verifyChainV07`,
  `verifyChainV06`, `entryDigestV07`, `finalEntryDigestV07`,
  `checkFinalEntryCommitment`, `verifyChainCommitment`, `merkleRoot`,
  `verifyWorkflowSteps`, `checkWorkflow`, `canonicalJson`,
  `verifyDocument`, `verifyFile`, `decodeBase64Strict`, `isRfc3339`.
- `run_negcorpus.php` — port of `../neg-corpus/run.js`; replays
  `manifest.json` (67 cases + 6 differentials) through `verifyDocument`
  and requires the EXACT pinned `{check, reason}` failure set per case.
- `EXPECTED.md` — ground-truth verdicts for both frozen corpora.
- `results.txt` — captured verification output.

## Run

    php aer1verify.php <file-or-dir> [--strict] \
        [--chain|--chain06|--workflow|--receipt] [--profile] [--anchor] [--json]

Exit 0 iff every input verifies, 1 otherwise, 2 on usage error. Verdict
lines are `PASS [kind] <path>` / `FAIL [kind] <path> — <first failing
check> (+N more)`; tail is `<n>/<m> verified` then `CONFORMANCE: <n>/<m>`
(the extra tail line matches the Swift port's convention).

## PHP version assumptions

- Developed and verified on **PHP 8.2.33 (cli)**. Requires >= 8.1
  (`array_is_list` is *not* used, so 8.0 would also work; no 8.3-only
  features). Tested with `serialize_precision=-1` (the default).

## Porting decisions / deviations

- **JSON model.** `json_decode` is called WITHOUT `assoc=true`, so JSON
  objects arrive as `stdClass` and JSON arrays as PHP lists — this keeps
  `{}` vs `[]` unambiguous (an assoc-decode would conflate them). JS
  `x in obj` → `property_exists`, `obj.x` (possibly absent) →
  `obj->x ?? null`, `Array.isArray` → `is_array`, `isObject` →
  `instanceof stdClass`.
- **Numbers.** `JSON_BIGINT_AS_STRING` is deliberately NOT used: JS
  parses every number to a double, and PHP's default behavior (int when
  it fits, float otherwise, float for big ints) is the closest match.
  `Number.isInteger` is ported as `is_int || (is_float && floor(v)===v)`,
  so `seq: 2.0` stays valid exactly as in JS. Everywhere verify.js
  interpolates a number into a message or canonical JSON, the port calls
  `jsNumToString()` — a small ECMA-262 `Number::toString` implementation
  built on PHP's shortest-round-trip float printer — so `2.0` prints as
  `"2"`, `1e21` as `"1e+21"`, etc. The kit's `{"__float__": N}` lexical
  marker check in `verifyChainCommitment` is preserved verbatim
  (`instanceof stdClass && property_exists('__float__')`).
- **Regexes.** Every pattern carries the `D` (PCRE_DOLLAR_ENDONLY)
  modifier: without it, PCRE `$` matches before a trailing `\n`, while
  JS `$` (no `/m`) does not. With `D`, the kit's trailing-newline
  adversarial vectors behave identically to verify.js.
- **Base64.** Surface check `^[A-Za-z0-9+/]*={0,2}$` + length % 4, then
  `base64_decode($s, true)` — like Node's `Buffer.from(s,"base64")`, it
  does not verify canonical pad bits.
- **UTF-8.** Strict decode is `preg_match('//u', $b) === 1` (PCRE rejects
  overlongs, lone surrogates, and > U+10FFFF — `TextDecoder{fatal:true}`
  equivalent). No mbstring dependency.
- **Canonical JSON (§7.1).** Object keys are sorted by UTF-16 code unit
  order via an explicit `utf16Units()` comparator — UTF-8 byte order would
  diverge from JS for non-BMP keys vs U+E000..U+FFFF. Escapes are emitted
  per code point: `\"`, `\\`, `\b`, `\t`, `\n`, `\f`, `\r`, `\u00xx` for
  other C0 controls, and `\uXXXX` (lowercase) for every unit >= 0x7F,
  including surrogate pairs for non-BMP characters — matching
  `JSON.stringify` + verify.js's `>= 0x7f` escape pass.
- **JS `||` on objects.** `doc.timeline || doc.entries` is ported through
  `jsTruthy()` — JS arrays/objects are always truthy (even `[]`), unlike
  PHP. `doc.timeline ?? null` maps missing AND null to falsy, matching
  JS `undefined`/`null`.
- **Directory scan is recursive** (the one deliberate extension, same as
  the Swift/Java ports): every `*.json` under the target, skipping any
  path component starting with `.`, sorted by pathname (byte order ==
  UTF-16 order for the ASCII corpus paths). Required so
  `test-vectors/{anchored,invalid-profile,invalid,valid}` runs as one
  target; `-` sorts before `/`, so `invalid-profile/` precedes
  `invalid/`.
- **Parse failure reason** is `not valid JSON: <json_last_error_msg()>`
  (PHP messages differ from V8's — only the `parse` check and the FAIL
  verdict are pinned). An unreadable file is a FAIL line (`cannot read
  file`), where verify.js would crash — fail closed, same call as the
  other ports. All corpus files are valid UTF-8, so PHP's stricter
  `json_decode` (which rejects malformed UTF-8 that Node's utf8 read
  would silently U+FFFD-replace) cannot diverge on this corpus.
- **`checkDisinterestedTier` quirk preserved:** when `anchor` is not an
  object the function returns `["anchor is not an object"]` and DISCARDS
  the earlier id-profile failure — verbatim port of verify.js.
- **`--json`** output is `json_encode(JSON_PRETTY_PRINT)` of the same
  `{file, kind, ok, failures[]}` report shape; field order may differ
  from `JSON.stringify`.

## Verification results

- `test-vectors` (recursive, 30 files): **CONFORMANCE: 7/30**, every
  verdict line identical to `EXPECTED.md`.
- `frozen-vectors/v1.3.0` (18 files): **CONFORMANCE: 5/18**, identical.
- `neg-corpus` via `run_negcorpus.php`: **70/73 cases conform — the same
  70/73 `node run.js` itself scores.** The 3 non-conforming differentials
  (d01 id trailing newline, d02 offset-minute 60, d03 provenance trailing
  newline) are cases where the -09 kit's loose-mode oracle is more
  tolerant than verify.js's strict-`$` regexes; verify.js rejects them in
  loose mode and this port reproduces that behavior bit-for-bit rather
  than diverging from the reference implementation.
- Full differential sweep — every `*.json` in `kit-snapshot` +
  `neg-corpus/vectors` (1281 files, incl. the 1000-case fuzz corpus and
  the adversarial vectors) × 9 flag combos = **11,529 runs**: the only
  differences are the parse-message text on 59 unparseable fuzz files
  (V8's "Unexpected end of JSON input" vs PHP's "Syntax error") — the
  `parse` check and FAIL verdict are identical. Zero verdict, kind, or
  first-failure divergences on any parseable input. See `results.txt`.

Disclosure: this port and its docs were written by ARION, an autonomous
agent.
