diff --git a/.gitignore b/.gitignore
index 15876fa47fee8..dbec517727bbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@ wp-tests-config.php
/packagehash.txt
/.gutenberg-hash
/artifacts
+/tools/html-api-fuzz/oracles/lexbor/build
/setup.log
/coverage
diff --git a/tools/html-api-fuzz/README.md b/tools/html-api-fuzz/README.md
new file mode 100644
index 0000000000000..26c0ef81e0e4a
--- /dev/null
+++ b/tools/html-api-fuzz/README.md
@@ -0,0 +1,582 @@
+# HTML API Fuzzer
+
+Pure PHP fuzzer for the WordPress HTML API under `src/wp-includes/html-api`.
+It compares `WP_HTML_Processor` against a selectable tree-construction oracle
+using an html5lib-style textual tree, and separately checks a set of API
+invariants described under “Invariants” below. The default oracle is PHP's
+`Dom\HTMLDocument`, preserving the historical behavior.
+
+No browser, Playwright, Node, or `wp-env` is involved.
+
+## Requirements
+
+- PHP 8.4+ with ext-dom, for `Dom\HTMLDocument`.
+- POSIX and PCNTL PHP extensions for Common Crawl worker process-group
+ isolation.
+- Run from the repository root.
+- Optional source-built Lexbor oracle: `git`, `cmake`, and a C compiler.
+
+## Common Crawl with cc-analyzer
+
+`commoncrawl-analysis.php` is an analysis callback for `cc-analyzer.phar`. It
+accepts the analyzer's `CcAnalyzer\Analysis\HtmlAnalysisInput` value object,
+runs the raw response body through `WP_HTML_Processor` in full-document mode,
+compares the resulting tree with Lexbor, and records the Common Crawl
+provenance needed to locate or replay the document.
+
+Build the oracle from the current upstream `master` first:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+The build resolves the moving ref, uses commit-keyed build/install directories,
+refuses a dirty Lexbor checkout, and writes `build/build-manifest.json` with the
+requested ref, resolved commit, upstream URL, build time, compiler, CMake
+version, and oracle SHA-256. For exact reproduction, rebuild the manifest's
+`resolvedCommit`, for example:
+
+```sh
+LEXBOR_COMMIT=de1d07a7765aad37090cc36f7fac3bb59e21467d \
+ tools/html-api-fuzz/oracles/lexbor/build.sh
+```
+
+Then point cc-analyzer's HTML analysis-file argument at the absolute path to:
+
+```text
+tools/html-api-fuzz/commoncrawl-analysis.php
+```
+
+The PHAR owns crawl selection, ranges, checkpoints, and concurrency. The
+callback boundary is the same as other cc-analyzer HTML analyses: the file
+returns a `static function (HtmlAnalysisInput $document): void` closure. Use
+`php /path/to/cc-analyzer.phar --help` for the binary's crawl-specific command
+and argument names. This checkout does not contain `cc-analyzer.phar`, so the
+real PHAR CLI boundary is deliberately not claimed as tested; the included
+smoke test exercises a value object matching the observed callback contract.
+
+Each accepted document runs in a separate PHP child with its own memory and
+wall-clock limit. The callback writes `input.bin` and an initial replay before
+starting that child. A timeout, memory exhaustion, crash, or signal is retained
+as a finding without terminating cc-analyzer or losing the triggering bytes.
+The child is a new process-group leader, so its Lexbor descendant is terminated
+with it. Stdout/stderr are continuously drained to `worker.log`; only bounded
+tails remain in the long-lived analyzer process.
+
+The Common Crawl adapter uses Lexbor by default. Its environment is:
+
+- `CC_ANALYZER_OUTPUT_DIR`: shared output directory for this analyzer run. If
+ absent, a unique directory is created under `artifacts/html-api-commoncrawl`.
+- `HTML_API_CC_RUN_ID`: explicit immutable run identifier. When the analyzer
+ provides an output directory, the default is a stable hash of that path.
+- `HTML_API_FUZZ_LEXBOR_ORACLE`: non-default Lexbor oracle binary path.
+- `HTML_API_CC_EXPECT_LEXBOR_COMMIT`: optional exact commit assertion; startup
+ fails if the executable reports another commit.
+- `HTML_API_CC_ORACLE_TIMEOUT_MS`: Lexbor subprocess timeout; default `10000`.
+- `HTML_API_CC_PROCESS_TIMEOUT_MS`: whole-document worker timeout; default
+ `30000`.
+- `HTML_API_CC_MEMORY_LIMIT`: PHP memory limit for each worker; default `256M`.
+- `HTML_API_CC_MAX_INPUT_BYTES`: largest response body to analyze; default
+ `2097152`, or `0` for unlimited.
+- `HTML_API_CC_MAX_TOKENS`, `HTML_API_CC_MAX_NODES`,
+ `HTML_API_CC_MAX_DEPTH`, and `HTML_API_CC_MAX_TREE_BYTES`: bounded work and
+ tree-output ceilings; defaults are `50000`, `50000`, `512`, and `16777216`.
+- `HTML_API_CC_CHECKS`: `baseline`, `full`, or `sampled` (default). Baseline
+ performs the WordPress/Lexbor differential; full also runs API invariants,
+ mutation differential, and normalize preservation.
+- `HTML_API_CC_FULL_SAMPLE_PERCENT`: deterministic full-check share when using
+ `sampled`; default `1`.
+- `HTML_API_CC_REQUIRE_UTF8`: defaults to `1`. Invalid UTF-8 bodies are
+ summarized as skipped because the WordPress HTML API consumes UTF-8 and a
+ legacy-encoded response otherwise produces mostly encoding noise. Set it to
+ `0` for raw-byte/encoding fuzzing.
+- `HTML_API_CC_MAX_KEEP_PER_SIGNATURE`: retained examples per distinct
+ signature; default `3`.
+- `HTML_API_CC_RETAIN_ALL`: retain passing inputs too; default `0`.
+- `HTML_API_CC_ORACLE=php-dom`: testing/debug escape hatch. Normal Common
+ Crawl runs should leave this unset so the independent source-built Lexbor
+ oracle is used.
+- `CC_ANALYZER_VERSION`, `CC_ANALYZER_CRAWL`, and `CC_ANALYZER_INVOCATION`:
+ optional provenance strings recorded verbatim in the immutable run config.
+
+The output root contains:
+
+- `configuration.json`: run ID and exact configuration fingerprint plus Git,
+ PHP, Lexbor build manifest/hash, limits, and analyzer provenance. Reusing the
+ directory with different settings fails at startup.
+- `commoncrawl-summary.ndjson`: one locked, append-only record per document;
+ safe for concurrent analyzer workers.
+- `coverage.json`: aggregate total, covered, failed, and per-status counts.
+- `findings/SIGNATURE/DOCUMENT/`: bounded byte-exact exemplars containing
+ `input.bin`, `result.json`, `replay.json`, both rendered trees, and a
+ `.complete` marker. Findings are written in `pending/` and atomically renamed
+ only after every checked write succeeds; retries of the same record/body are
+ idempotent. Any directory left in `pending/` is recoverable evidence of a
+ callback interruption.
+
+Passing inputs are represented only by their summary by default. A finding's
+replay embeds the exact input body, so it remains reproducible without another
+Common Crawl download. This is also true when the worker died before producing
+its own manifest—the callback's pre-worker replay already contains the input,
+oracle configuration, limits, and provenance:
+
+```sh
+php tools/html-api-fuzz/replay.php \
+ --replay artifacts/html-api-commoncrawl/findings/SIGNATURE/DOCUMENT/replay.json
+```
+
+Replay uses the finding's recorded Worker script, PHP memory limit, whole-worker
+timeout, check pipeline, and oracle timeout by default. It applies the same
+process-group isolation and synthesizes the same timeout/OOM/crash result when
+the Worker produces none. `--worker-script`, `--memory-limit`, and
+`--timeout-ms` are explicit diagnostic overrides.
+
+Replay also verifies the recorded oracle identity before starting the Worker.
+For a source-built Lexbor oracle, both the resolved Lexbor commit and executable
+SHA-256 must match. Use `--allow-oracle-mismatch` only for a deliberate
+diagnostic comparison; the resulting replay records the oracle actually used
+and retains the source identity in its provenance.
+
+Transport charset, content type, target URI, WARC record ID, analyzer state
+key, and source range are metadata only. The comparison intentionally feeds
+the same raw bytes to both parsers; it never silently transcodes one side.
+
+## Common Commands
+
+Run one generated seed:
+
+```sh
+php tools/html-api-fuzz/worker.php --seed 1 --output-dir artifacts/html-api-fuzz/seed-1
+```
+
+Run one generated seed with a specific terminal payload policy:
+
+```sh
+php tools/html-api-fuzz/worker.php --seed 1 --payload-policy valid-utf8 --output-dir artifacts/html-api-fuzz/seed-1
+```
+
+Run a batch in worker subprocesses (seeds are batched into shared worker
+processes, 25 per process by default; see `--batch-size`):
+
+```sh
+php tools/html-api-fuzz/runner.php --max-seeds 100 --duration-seconds 60
+```
+
+Run a structural UTF-8-biased batch with a post-generation byte cap:
+
+```sh
+php tools/html-api-fuzz/runner.php --max-seeds 100 --payload-policy valid-utf8 --max-input-bytes 4096
+```
+
+Build and run against the source-built Lexbor oracle:
+
+```sh
+tools/html-api-fuzz/oracles/lexbor/build.sh
+php tools/html-api-fuzz/worker.php --seed 1 --dom-oracle lexbor-source --output-dir artifacts/html-api-fuzz/seed-1-lexbor
+php tools/html-api-fuzz/runner.php --max-seeds 100 --dom-oracle lexbor-source --duration-seconds 60
+```
+
+Use `--lexbor-oracle-bin PATH` or `HTML_API_FUZZ_LEXBOR_ORACLE` when the
+oracle binary is not at
+`tools/html-api-fuzz/oracles/lexbor/build/lexbor-tree-oracle`.
+
+Run indefinitely:
+
+```sh
+php tools/html-api-fuzz/runner.php --duration-seconds 0 --max-seeds 0
+```
+
+Run parallel lanes and triage failures after completion:
+
+```sh
+php tools/html-api-fuzz/launcher.php --lanes 4 --max-seeds 1000 --watcher
+```
+
+For continuous fuzzing, run the launcher with `--duration-seconds 0 --max-seeds 0`
+and run `watcher.php` in a second shell against the same output directory.
+
+Stop an indefinite run gracefully (each lane finishes its current batch, the
+watcher performs a final scan, and the codex orchestrator drains its running
+jobs):
+
+```sh
+php tools/html-api-fuzz/stop.php --run-dir artifacts/html-api-fuzz/run-...
+```
+
+Without `--run-dir` the most recently active *unfinished* run under
+`artifacts/html-api-fuzz` is targeted. Finished and stale runs are not
+preferred; if nothing live is found, the most recent stopped-looking run is
+targeted with a warning. The script creates the stop file advertised by the
+run state and also `RUN_DIR/STOP` when a run directory is known, so watchers
+and orchestrators see the stop request. For a standalone runner with custom
+`--stop-file PATH`, both files are written. Relative custom stop files are
+resolved with the runner cwd recorded in new runner state; for older state,
+pass only `--stop-file PATH` to write a known stop file directly if needed.
+With `--run-dir --stop-file PATH`, the explicit path is added to the run-state
+and `RUN_DIR/STOP` targets. `touch` works just as well. The launcher and runner refuse to start while a stop file already
+exists — remove `STOP` (or the custom stop file) before reusing a run
+directory. (A stop requested in the sub-second window between the launcher's
+startup check and a lane's own makes that lane refuse rather than stop
+gracefully; the run still ends.)
+If state cannot be read or old state lacks enough context to locate a relative
+custom stop file, the tool still writes `RUN_DIR/STOP` but exits `2` with
+`ok: false` and warnings because a standalone custom stop file may be unknown.
+
+The watcher exits after a final scan once every runner under the run
+directory reports a stop reason. A runner whose state has gone silent is
+presumed dead after `--stop-stale-seconds` (default 120); per lane that
+threshold is floored at twice the lane's advertised batch budget
+(`timeout-ms × batch-size`), so long batches are not mistaken for crashes.
+The stop tool uses the same default stale threshold when auto-selecting the
+latest unfinished run.
+
+Replay a failure from a retained seed directory, or from the lane's SQLite
+store when the seed directory was pruned (see "Artifact Retention"):
+
+```sh
+php tools/html-api-fuzz/replay.php --replay artifacts/html-api-fuzz/run-.../seed-.../primary/replay.json
+php tools/html-api-fuzz/replay.php --store artifacts/html-api-fuzz/run-.../lane-00/results.sqlite --seed 12345
+```
+
+Minimize a failure while preserving the same signature:
+
+```sh
+php tools/html-api-fuzz/minimize.php --replay artifacts/html-api-fuzz/run-.../seed-.../primary/replay.json
+```
+
+By default (`--probe-mode auto`) the minimizer evaluates candidates in worker
+subprocesses so `--timeout-ms` can kill pathological candidates and each probe
+starts with fresh PHP state. Use `--probe-mode in-process` for faster
+exact-signature minimization when that isolation is not needed; in-process
+probes write only the final minimized artifacts unless
+`--keep-candidate-artifacts` is also passed. Use `--probe-mode process` to
+force subprocess probes explicitly.
+
+Watch an existing run directory and minimize new distinct signatures:
+
+```sh
+php tools/html-api-fuzz/watcher.php --run-dir artifacts/html-api-fuzz/run-... --once
+```
+
+Configured ceilings are reported as `failureClass: "resource-limit"` and remain
+in the watcher/minimizer triage path. This bucket includes tag/tree token
+ceilings (`tag-token-limit-exceeded`, `mutation-token-limit-exceeded`,
+`wordpress-token-limit-exceeded`) and oracle node ceilings
+(`node-limit-exceeded`, recorded as `dom-node-limit-exceeded` in historical
+signature facts). Process timeouts, PHP fatal errors, and memory failures are
+separate failures and are also in scope for triage.
+
+## Execution Model
+
+The runner batches consecutive seeds into one worker process
+(`worker.php --batch-count N`, default `--batch-size 25` on the runner) so the
+WordPress bootstrap and process spawn are paid once per batch rather than once
+per seed. Each seed still writes its own `seed-N/primary` artifacts. If a batch
+process dies or times out mid-way, seeds left without a `result.json` are
+re-run individually in isolation, so a crash on one input cannot take
+neighboring seeds' results with it.
+
+## Input Stages
+
+Seeds are deterministically split between two input stages:
+
+- **Generated** (default ~80%): the structural grammar described under
+ “Generator Profiles”.
+- **Corpus-mutated** (default ~20%, `--corpus-mutate-percent N` on
+ `worker.php`/`runner.php`): a `#data` section from the html5lib-tests
+ tree-construction corpus (`tests/phpunit/data/html5lib-tests`), passed
+ through 1–4 deterministic mutations (byte insert/replace, chunk
+ delete/duplicate, tag-name swap, case toggle, corpus splice). The stage,
+ corpus file, entry index, and operations are recorded in result metadata,
+ and the mutated input itself is in the replay manifest, so replays are
+ standalone. Inputs report `inputSource: "corpus-mutated"` and
+ `profile: "corpus-mutated"`.
+
+Both stages derive entirely from the seed, so seed N always produces the same
+input for the same fuzzer version and corpus.
+
+## Artifact Layout
+
+The runner writes:
+
+- `results.sqlite`: one row per attempted seed (table `attempts`, WAL mode).
+ Passing attempts store summary columns only — every attempt is regenerable
+ from its seed. Failure rows additionally store the summary, result, and
+ replay JSON documents; the replay embeds the input as base64, so a pruned
+ failure can be reproduced with `replay.php --store results.sqlite --seed N`.
+ `signature_hash` and `family_key` are indexed columns for grouping
+ failures without `json_extract`. `oracle_kind`, `oracle_version`,
+ `oracle_commit`, and `oracle_binary` record which oracle generated the
+ summary, including for passing rows whose JSON payloads are pruned. The
+ watcher tails these stores
+ incrementally by row id. (`summary.ndjson` files from older runs are still
+ scanned.) Durability is `synchronous=NORMAL`: an OS crash (not a process
+ crash) can lose the last moments of a run.
+- `events.ndjson`: runner lifecycle events, including batch boundaries.
+- `logs/batch-N.log`: output of a batch worker process, kept only when the
+ batch contained a retained failure or a seed that needed an isolated
+ re-run — over-cap repeats of a known signature do not accumulate batch
+ logs.
+- `state.json`: aggregate counters, stop reason, and compact Git metadata.
+ Oracle losses are counted per class: `oracleParseErrors` (inputs the
+ selected oracle rejects receive no differential coverage),
+ `oracleUnsupported` (tree shapes the oracle cannot represent), and
+ `oracleTolerated`
+ (comparisons that passed only under the documented scalar tolerance).
+- `seed-N/primary/input.bin`: raw generated bytes.
+- `seed-N/primary/replay.json`: base64 replay manifest, including the commit
+ hash, tracked-file dirty state, selected oracle, and fragment context needed
+ to interpret a standalone replay.
+- `seed-N/primary/result.json`: full worker result.
+- `seed-N/primary/wordpress-tree.txt` and `dom-tree.txt`: rendered trees when available.
+
+### Artifact Retention
+
+Seed directories are working space, not the archive. After each seed is
+recorded in `results.sqlite`, its `seed-N` directory is deleted unless the
+attempt failed *and* the failure's signature has fewer than
+`--max-keep-per-signature` (default 5, minimum 1) exemplar directories still
+on disk in this lane. The cap counts directories, not rows, so restarting a
+runner against the same output directory neither double-counts re-recorded
+seeds nor deletes a previously retained exemplar. The first exemplar of every
+new signature is always retained, so the watcher's minimization path keeps a
+replay file to work from; subsequent repeats of a known signature add a
+database row and nothing else. A failure whose replay document is missing
+(worker killed before writing it) always keeps its directory — the files are
+the only reproduction. Disk growth is therefore proportional to *new
+distinct failures*, not to seeds executed.
+
+The cap applies per lane: the launcher passes the same value to every lane,
+so a signature that appears in all lanes keeps up to `N × lanes` directories
+across the run. Use `--keep-all-artifacts` (runner or launcher) to keep every
+seed directory for debugging.
+
+The run-level Git metadata intentionally stays compact: full and short commit
+hash, current branch when available, commit date, and a dirty flag for
+tracked-file changes. The dirty flag is tri-state: `true`, `false`, or `null`
+when Git is unavailable or dirty detection fails. Full `git status` or diff
+output is not recorded because it is noisy, can expose local edits, and grows
+indefinitely in long runs. Launcher and runner processes collect this metadata
+once and pass it to workers so long runs do not invoke Git for every seed.
+Replayed and minimized manifests keep current checkout metadata at the top level
+and preserve discovery provenance in `sourceReplay`.
+
+The watcher writes triage state under `.triage-watcher` by default, or under
+`--state-dir` when provided. Each signature gets a stable directory containing
+`failure.json`, minimizer logs, and minimized replay/result artifacts. Failed
+minimizations are retried on later scans, up to `--max-minimize-retries`
+(default 3) attempts per signature.
+
+## Modes and Fragment Contexts
+
+- `fragment-body`: parse as a fragment. The selected oracle uses real fragment
+ parsing (the `innerHTML` setter on a context element of an empty document),
+ not a document-wrapping approximation.
+- `full-document`: parse as a full HTML document.
+- `auto`: weighted choice.
+
+In fragment mode a context element is selected per seed
+(`--fragment-context TAG` on `worker.php` for replays). `