CLAUDE.md · git:20261025.e49fab9 · 2026-10-25 · sha256 dd617689c3454971

CLAUDE.md git:20261025.e49fab9A

Immutable. This exact content never changes and is served at /api/v1/blob/dd617689c3454971.

# LethAL — project instructions

Write in plain English.
Use common words and short sentences. Avoid jargon when a simpler term exists. If a technical term is necessary, define it the first time you use it.
Assume I am intelligent but unfamiliar with the terminology. Be concise, but do not remove details needed for correctness.

Mutation-testing tool for Microsoft Dynamics 365 Business Central **AL** code. Bun + TypeScript monorepo. Authoritative architecture is `design.md` (repo root); per-layer specs/plans under `docs/superpowers/`. Gate figures and history: `docs/gates/`. Background for rules below: `docs/claude-md-background.md`.

## The roadmap — `docs/roadmap/` is the record, `ROADMAP.md` is a generated index
Open work, measured-but-unclosed correctness risks, and known product gaps live **one item per file** in `docs/roadmap/R<nnn>.md` (zero-padded), each with a stable `R<n>` id, a `status` and an evidence pointer. Repo-root `ROADMAP.md` is a GENERATED index — never hand-edit it; `bun scripts/roadmap-index.ts` rebuilds it and `scripts/roadmap-index.test.ts` fails if the two disagree.
- **Reading:** the index answers "is this already filed?"; reading one item is reading its file.
- **Adding:** **file an item the moment you discover one** (a limitation you hit, a gap a real project would trip on) — write `docs/roadmap/R<nnn>.md`, then regenerate.
- **Closing:** mark `status` `done (<commit>)` as it lands, then regenerate. A row closed by a RULING rather than code may say `closed <date> — <the ruling>`; both count as closed, and a "how many are open" count must accept either. Session ledgers under `.superpowers/` are scratch; the roadmap is the durable record.
- **Re-check the next free id immediately before writing** (`ls docs/roadmap/`), because other sessions commit here concurrently and HEAD can move mid-session.
- Check it before starting new work: what you are about to build may already be filed, or blocked on something that is.

## Packages (workspaces under `packages/`)
- `engine` — AST (tree-sitter-al, parsed by the native addon in `packages/engine/native`; the WASM parser is kept only as a reference for comparisons), `MutationSpec`, semantic layer, `astSubtreeHash`.
- `operator-sdk` — operator interfaces.
- `builtin-tier1` — Tier-1 mutation operators.
- `schemata` — compiler: instrument a project with all mutations behind runtime guards, one artifact (`selector.ts`, `project.ts`, `compile.ts`).
- `runner` — orchestration + execution: `orchestrator.ts`, backends (`bcdev-backend.ts`, `al-runner-v3-backend.ts`), `store.ts` (bun:sqlite), `deployment-verifier.ts`, `run-mutant-transport.ts`, `harness.ts`.

AL extension: `extensions/lethal-control` (the `LethAL Control` BC extension, runtime 17).

## Build / test loop (order matters — the dist trap bites every session)
0. `LLVM_BIN="C:/Program Files/LLVM/bin" bun scripts/build-native-parser.ts` (inside the kraken container `LLVM_BIN` is already set, to `/opt/llvm-23.1.2/bin`: run `bun scripts/build-native-parser.ts`) — on a fresh checkout or worktree, and after any change under `packages/engine/native/` (needs Rust stable (1.96 or newer) and clang/LLVM 23.1.2, installed on this machine; elsewhere `bash scripts/install-llvm.sh <platform-key>`). The unit tests parse through the addon, and the loader refuses a missing or stale one (`NativeParserMissingError`, `NativeParserStaleError`); there is no WASM fallback. The built `.node` is gitignored.
1. `bun run typecheck` — `tsc --build --force`. SEPARATE from tests; run explicitly.
2. `rm -rf packages/*/dist` — **AFTER typecheck, BEFORE any `bun test`**: `tsc --build` regenerates `packages/*/dist`, and its stale compiled `*.test.js` get picked up by `bun test` as phantom failures.
3. Run the unit suite as `bun scripts/verify.ts` (one package: `bun scripts/verify.ts <pkg>`), from the repo root. It does steps 1 and 2 first, then runs `bun test` with `CI=true`. The root `bunfig.toml` preloads `scripts/test-preload.ts`, which hides the real home directory from every test, and `real-home-guard.test.ts` fails if it did not load. Never a bare `bun test`: it does NOT type-check, and it lets a timed-out test write its snapshot under the NEXT test's name, while verify.ts refuses new snapshots and fails if any `.snap` changed. Add a snapshot on purpose with `bun test --update-snapshots <file>`. `CI=true bun test` runs the tests alone, after you did steps 1 and 2. verify kills and names any test file that runs over 180 s, and logs each run to `/coord/locks/verify-ledger.tsv` with how many other verifies overlapped it. Since R616 only one verify runs at a time in the container: full and partial runs queue on `/coord/locks/verify-slot.lock` (stale by pid; CI skips it), a waiting run prints `verify slot: waiting for pid ...` and fails by name after 10 minutes, the per-test timeout is 15 s instead of 5 s when io pressure is high at start (the receipt says so), and every child verify runs is capped at 900 s. (PostToolUse hooks clean dist after a typecheck and format touched `*.ts`; see `.claude/settings.json`.)
4. `bun run compile:fixtures` — offline `alc` compile of every `fixtures/*` AL project. Run it after touching ANY `.al` under `fixtures/`. Nothing else compiles them: LethAL publishes the target on every run but treats publishing the TEST APP as the user's own workflow, so a broken test fixture leaves the live gate measuring the previously published build (R56).
- `bun run mutate`: StrykerJS over our own TypeScript. Optional, never gates. **Read `docs/mutation-testing-ourselves.md` before reading a score.**
- Lint: `biome check .` is noisy (pre-existing debt). Run biome **only on files you touched**: `bunx biome check <paths>`.

## Integration tests (env-gated, live BC, minutes each — run foreground, never poll)
Each gate compares every mutant with a committed baseline. Its frozen figures, what they mean, and their history are on its page under `docs/gates/`; `packages/runner/tests/gate-pages.test.ts` pins the pages to the baselines. Quote a gate's figures only from a run of that gate, never inferred from another gate (R177).

**Where the BC gates run: `bc1`, `bc2` and `bc3`** (BC 28.4 W1 on Linux; ports, company and setup: [docs/gates/servers.md](docs/gates/servers.md)). **Cronus28 is PARKED** (owner, 2026-10-21): run no gate there. Select a server with `LETHAL_ITEST_CONFIG=lethal.config.bcN.json`, always: the default config still names the parked Cronus28. It requires `.vscode/launch.bcN.json` naming the same server, and `bun scripts/bc-linux-setup.ts --server bcN` writes both. A gate's first line, `[itest target] ...`, names the server it measured: read it. The servers' databases are in memory, so after a server1 restart re-run the setup script before any gate. Proof is per server: a gate runs on a server only after its frozen figures reproduced per mutant on THAT server, under the current fixtures. The proof table in docs/gates/servers.md is the record: use a server only for the gates listed for it, and prove a new pairing first. Since R604 every BC gate is proven on all three servers under the control build master pins; re-prove a server's gates whenever its control build or fixture apps change (`bc3` also carries the Continia stack). Any difference is a BLOCK. One gate per server, under a coord lease. A BC gate against a bc server does not count toward the container's two-heavy-job limit (three when every heavy job is an al-runner-v3 job and no BaseApp corpus job runs: owner, 2026-10-25; see the autonomy README): run at most one per server in parallel, and back off when `free -g` shows under 10 GB available.
- `LETHAL_ITEST_BCDEV=1 bun run itest:bcdev` — authoritative backend. [docs/gates/bcdev.md](docs/gates/bcdev.md). Variants on `itest:bcdev`'s connection, kept separate so bcdev's figures cannot move with them: `LETHAL_ITEST_BCDEV=1 bun run itest:bcdev-names` ([page](docs/gates/bcdev-names.md)) and `LETHAL_ITEST_BCDEV=1 bun run itest:bcdev-wrapped` ([page](docs/gates/bcdev-wrapped.md)).
- `LETHAL_ITEST_ALRUNNER_V3=1 bun run itest:alrunner-v3` — the al-runner-v3 backend (the Rust al-runner; it needs no BC server and no lease). [docs/gates/alrunner-v3.md](docs/gates/alrunner-v3.md). Every figure is pre-committed; a differing verdict, killing test, note set or refusal set is a BLOCK. The .NET al-runner (v2) was retired by R654: `--backend al-runner` and a stored v2 run are refused by name.
  - **Which al-runner build.** The binary is pinned at `/work/tools/al-runner-v3/current`, and `.gated` names it. The backend refuses any build `.gated` does not name. The pin moves only through `bun scripts/al-runner-v3-update.ts` (the update gate), never by hand. The update gate tries the newest release first and stops at the first that passes; `current` never moves backwards (R723). Each build carries its own expected results, pinned by version and binary sha256 (R586c); a build without a pin is judged against the default tables. v3 is not measured on Windows and refuses there.
  - The gate PRINTS the build it ran against as its first line: read it before calling a difference a regression.
  - **Do not pin the tool to an older release to make this gate pass**: find the cause, and fix LethAL or report it upstream at aldevtools/BusinessCentral.AL.Runner (R125).
  - **When al-runner moves, sweep the prose, not only the code**: `grep -rn "al-runner" README.md docs/*.md fixtures/README.md .claude/skills` and read each claim against the contract probe (leg C) and `docs/measurements/README.md` §"al-runner v3" (the measured contract).
  - al-runner-v3 results are not authoritative (`authoritative: false` until its verdict-changing defects are measured gone, R586); coverage for multi-object and `#if`-wrapped object files is measured by the MO and WR legs (R602), and a build that breaks it cannot become `current`.
  - `compile:fixtures --require-symbol-sets` compiles all three builds of the symbol pair (`fixtures/sandbox-symbols`) and refuses a skip. The pair's `app.json` files must never define a symbol: the build's symbol set includes `app.json`'s own symbols, and the pre-committed tables assume there are none.
  - `LayoutBeta`'s header comment, and the load-bearing comment inside `Twice` in `fixtures/sandbox-wrapped`, set the line spans the v3 layout leg's and `itest:bcdev-wrapped`'s predictions rest on: do not edit them without a new pre-commitment.
- `LETHAL_ITEST_ENVTOOL=1 bun run itest:envtool` — bcdev reached through a config-declared external environment tool (see `fixtures/README.md` §"Running against an external environment tool"). [docs/gates/envtool.md](docs/gates/envtool.md). Needs a gitignored `fixtures/sandbox-app/lethal.config.envtool.json` naming a reachable environment.
  - Its per-mutant table must equal `itest:bcdev`'s on the same day; since R197 the two may differ in `killingTest` alone. Its baseline predates R197, so the next live run should expect `killingTest`-only differences: re-record it by the R332 procedure below. Any VERDICT difference is a regression.
  - **Provisioning a replacement environment has two hard requirements, both found by hitting them: BC 28+ (`lethal-control` and every fixture are runtime 17.0; BC 27 caps at 16.x) and the company its config names (the old config names DK `CRONUS Danmark A/S`, which W1/BASE does not have, and the harness 404s AFTER the control app publishes fine).** Prefer BC 28 W1, which matches `bc1`-`bc3`. A brand-new environment also needs the fixture bootstrapped once by hand — publish `sandbox-app`'s built `.app`, then `sandbox-tests`' — or the tests app fails with `AL0185: Codeunit 'Sandbox Logic' is missing`.
  - It refuses RECORD mode while `UNVERIFIED_MOVES` (in the itest) is non-empty: confirm each listed mutant by name against `itest:bcdev` on the same day and clear the list in the same commit that records the baseline.
- `LETHAL_ITEST_TABLES=1 bun run itest:tables` — the TABLE fixture (`fixtures/sandbox-data` + `-tests`), where Tier-2 operators and table-trigger mutation live. [docs/gates/tables.md](docs/gates/tables.md). It reads the config in `fixtures/sandbox-data/`, **not** sandbox-app's. The setup script writes a config in four fixture directories (`sandbox-app`, `-data`, `-hang`, `-harden`), and each BC gate reads one of them: its page says which.
  - The test app declares a dependency on Microsoft's `Library Assert`; its symbols come from the server's own dev endpoint.
  - It asserts that exactly ONE test fails, `Data Tests.PageActionComputesNonZero` (a `TestPage` test the fenced session refuses), BY NAME, and that the report names the refusal. Never relax this to "the baseline is not green", and never back to "the baseline is green".
  - A hang-capable site (one a mutant can make loop forever) must never enter any gate other than `itest:hang` (R164); prove such a refusal offline instead.
- `LETHAL_ITEST_CHUNKED=1 bun run itest:chunked` — the CHUNKED group path, which every other gate is blind to. [docs/gates/chunked.md](docs/gates/chunked.md). Both legs must have identical verdicts and `killingTest`: chunking is a cost knob, never a semantic one.
- `LETHAL_ITEST_HANG=1 bun run itest:hang` — non-terminating mutants; the only gate that exercises the control app's stop. [docs/gates/hang.md](docs/gates/hang.md).
- `LETHAL_ITEST_HARDEN=1 bun run itest:harden` — planted survivors and an answer key. [docs/gates/harden.md](docs/gates/harden.md).
- `LETHAL_ITEST_LEND=1 bun run itest:lend` — real lends (history, `--resume`, `--resume-run`, snapshot reuse) on `sandbox-app`, on `itest:bcdev`'s connection, scratch store. [docs/gates/lend.md](docs/gates/lend.md).
- **The control app at the version in `extensions/lethal-control/app.json` must be published to every gate server** (on `bc1`-`bc3` the setup script publishes its pinned build over the dev endpoint, so after a bump update its `CONTROL_APP` and `CONTROL_SHA256` first; on a Windows container, `Publish-BcContainerApp ... -sync -upgrade`, see `.claude/skills/control-app`; `lethal doctor --config <path>` reports what a server has installed, as its `control-version` check): the client refuses an older one before any call (`MIN_CONTROL_VERSION` in `packages/runner/src/harness.ts`). The runtime-17 build is the minimum. Which feature arrived in which version: `docs/gates/README.md`. Verify AL edits to the control app by compiling AND by the hang gate.
- `lethal run` writes its JSON with `--out`; `--report` is `campaign`'s flag and is SILENTLY IGNORED by `run` (one shared strict `parseArgs` table serves every subcommand). `--progress-out` writes the events NDJSON.
- **A frozen baseline never records itself (R332).** Every gate that writes or reads a committed `packages/runner/itest/*.baseline.json` REFUSES to start when the file is missing, and prints the command that records it. Recording is deliberate and one-time. `LETHAL_ITEST_RECORD_BASELINE=<file>.baseline.json` (exact basenames, comma-separated; any other value is an error) arms exactly those files. It refuses to overwrite a file that exists, and the run EXITS 3 with a failed receipt after writing, so a record run is never a pass. To change a frozen baseline: pre-commit the change, delete the file, run once in record mode, re-run without the variable to confirm a pass, review the diff, update the gate's page, commit. The Edit/Write hook's `LETHAL_RERECORD_BASELINE=1` only lets a hand edit through; no gate reads it. `campaign freeze` is a separate mechanism.
- A differing verdict is a BLOCK (a real regression), never "close enough". Live execution is the authority — unit tests are structurally blind to AL that can't compile or to real BC behavior. Use `/live-gate`.

## AL has no unit-test harness
Verify AL edits by an offline `alc` compile (use `/al-compile` or the `al-compiler` subagent), plus the live gate. `alc.exe` and `altool.exe` live under `~/.vscode/extensions/ms-dynamics-smb.al-*/bin/` on the per-platform VSIX (18.0.2668733, the one installed) and under `bin/win32/` on the multi-platform one: probe both, newest first. Inside the kraken container the Linux `alc` and `altool` are under `bin/linux/` of the extension, and `LETHAL_ALC_DIR` names that directory: `/home/dev/.vscode/extensions/ms-dynamics-smb.al-18.0.2732683/bin/linux`.

**Changed a fixture's AL? Symbols and the test app do NOT update themselves.** Bump the target's `app.json` version, `alc` the target INTO the tests project's `.alpackages` (deleting the stale `<name>_<oldver>.app` there), then compile and `altool publishapp` the tests app. Skipping it gives `AL0185: Codeunit 'X' is missing` at compile, or `StaleTestAppError` at run (R56's guard refusing rather than measuring a stale build).
A dev-endpoint publish can be refused as a DOWNGRADE even when `app.json` is newer: LethAL mints `<major>.<minor>.<days>.<halfSeconds>` for every instrumented publish, so the container's resident version outranks it. Unpublish (tests app FIRST, since unpublishing the target cascades to its dependents), then republish target, then tests. See `.claude/skills/control-app`.

## Conventions (enforced — CI will fail otherwise)
- No `!` non-null assertions (biome `noNonNullAssertion: error`). Destructure then check `undefined`.
- `exactOptionalPropertyTypes`: build optional props with `...(v !== undefined ? { k: v } : {})`.
- Typed error classes extend `Error` **directly**, never each other — `AlcCompileError` (deterministic alc rejection) vs `ArtifactPrepareError` (spawn/IO/hash/manifest) vs `DeploymentError`. Bisection reads ONLY `AlcCompileError` as "subset does not compile"; anything else aborts. Preserve this separation.
- Fail loudly on caller-contract violations (bad artifact-id, corrupt manifest, echo mismatch) — throw, never return a plausible empty default. Empty-vs-empty "matches" is this project's signature bug.
- Generated AL: web-service `ObjectType` exactly `CodeUnit` (capital U). `emitMutationSelector` and `emitStaticSelector` must expose the identical procedure set. Artifact id: 32 lowercase hex, random per artifact.
- Assert phase ordering with call counters on stateful fakes, never wall-clock timing.
- Adding a `SessionReport` field ripples, and several guards fail far from the change: `events.ts`, then `report-fold.ts`'s accumulator, then `report.ts`'s type/builder/banner, then `bun scripts/generate-schemas.ts`, then `tests/schemas.test.ts` (the pinned root-required list AND the older-reports expectation), then `bun test <file> --update-snapshots` for `report-equality`, then every committed sample report REGENERATED live, since `schemas.test.ts` asserts gift-card's validates. A new `Caveat` also needs `CAVEAT_INTERPRETATIONS` plus the counts in `interpretation.test.ts` and `report.test.ts`.

## The recurring hazard: "test passes for the wrong reason"
A test asserts the right thing but passes whether or not the code is correct. Reading the diff does not catch it; only mutation does. **Red-check every fix**: revert the specific fix, confirm the specific test goes red, restore — report both. Use the `mutation-red-checker` subagent. Gate on per-mutant equality, not aggregate counts.

## Committing a campaign report — this repo is PUBLIC
A `SessionReport` from a real project carries that project's AL source in every mutant's `originalText`/`mutatedText`. **Run `bun scripts/redact-campaign-report.ts <report.json>` BEFORE committing one**; `--check` exits 1 on an unredacted file and `scripts/redact-campaign-report.test.ts` asserts both committed reports stay clean. The ruling it implements: **filenames, paths, procedure names and test names are fine to publish; source code is not.** `killingTestFailure` is deliberately KEPT (callstacks are names and line numbers, and its text is the corpus `scripts/r121-classify-eval.ts` scores false-kill rules against). Redaction after a push changes only what a fresh clone sees, so redact first.
Re-freezing a stage: `campaign compare` REFUSES an uncommitted baseline (the pre-commitment rule in code, not a bug), and `freeze` will not overwrite `<stage>.report.json` on a per-mutant mismatch; it archives to `<stage>.mismatch.report.json`. To re-freeze deliberately: delete `<stage>.baseline.json`, re-run `freeze`, review the diff, commit. Coverage-mode refusals: the `measurement-campaign` skill.

## Environment
Git bash on Windows; use bash syntax with Windows paths. **Never `2>nul`** (creates undeletable files) — use `2>/dev/null`. The `! <cmd>` prefix is only for interactive logins the tools can't complete (e.g. device-code auth). BC container publish/unpublish/restart are NOT user-only: `bccontainerhelper` is reachable from the PowerShell tool — the Cronus BC servers are Windows Docker containers on this machine (switch `docker context use desktop-windows` first, since the session default is the Linux engine). `altool publishapp` publishes over HTTP (dev endpoint); removal needs `UnPublish-BcContainerApp` under the Windows context.

**Inside the kraken container** (Linux, no Docker): BcContainerHelper, `docker context use desktop-windows`, a BC container restart, `Publish-BcContainerApp` (the control app, Global, with sync/upgrade) and `UnPublish-BcContainerApp` are all host-only: ask the owner. `altool publishapp` over the dev endpoint works from the container, and so does reaching `bc1`-`bc3`. **Cronus284 is CentralGauge's, not LethAL's** (owner, 2026-10-21): never lease, publish to or clean up there. The coord root is `/coord` (`LETHAL_COORD_ROOT`). The envtool (`continia.exe`) is host-only: ask the owner.

**The BC on Linux servers `bc1`, `bc2`, `bc3` (R589)**: lease one with `kraken coord lease bcN <your lane>` like any container. No BcContainerHelper there: everything goes over the dev endpoint, and `bun scripts/bc-linux-setup.ts --server bc1|bc2|bc3|all` (idempotent) sets a server up. The login is in the gitignored fixture configs and in `/coord/secrets/bc-linux.env`; never print either. Ports, company and how the itests read them: [docs/gates/servers.md](docs/gates/servers.md).

**Edit files with the Edit and Write tools, never with an inline script** (`python -c`, `python - <<EOF`, `bun -e`, `sed -i` on source, `cat >> file <<EOF`), because hand-written find-and-replace and appends to source fail often, while the Edit tool checks the match for you. For the build/test loop, roadmap ids and status, coord status, report summaries and diffs, and store queries, a tested script already exists: see the `repo-scripts` skill (`.claude/skills/repo-scripts/SKILL.md`). **Never search with `find /`** (or `grep -r /`): in Git Bash `/` spans every mounted drive, including the worktrees, `lethal-coord` and corpora on `H:`, and such searches have run for hours as orphans. Search a named directory. **Never put a password on a command line** (`curl -u user:pass`): it lands in the session transcript; read it from the config file inside a script.