# Contributing to revl revl gets two kinds of contributor, and this guide is written for both: - **Humans**: reading the design, fixing the checker, adding a tier, writing docs. - **AI agents**: the language's primary *author* is increasingly an agent, and agents also *contribute to the compiler itself*, in parallel, in isolated worktrees. The workflow below was built for that and is described literally, because an agent cannot infer it from repo vibes. If you only want to *write* revl (not change the compiler), you are in the wrong file: read [docs/guide-humans.md](docs/guide-humans.md) or [docs/guide-ai-agents.md](docs/guide-ai-agents.md) instead. This file is about changing revl. Everything here is enforced, not aspirational: the pre-commit hook and CI run the same checks, and a change that skips them will be caught at the gate. ## The one rule that shapes everything > **The checker is the product.** revl sells exactly one promise: a component > that would corrupt a running system cannot compile. Every contribution is > measured against whether it keeps that promise provable. That is why the > rejection suite is an executable spec, why emitted output never changes > unreviewed, and why emitted code is handed to real compilers instead of > trusted. Read [DESIGN.md](DESIGN.md) §4 for the guarantee table (generated from `revl.diagnostics.GUARANTEES`, so it cannot drift), and [docs/rejections.md](docs/rejections.md) for the full set (the lifecycle rules A1, A2, A3, A5, A6, A8 and A9, the confidentiality rules `G-SECRET` and `G-SECRET-FLOW`, and the typing rules T1–T3) before changing the frontend. Read [docs/backend-ir-v1.md](docs/backend-ir-v1.md) and [docs/backend-ir-v3.md](docs/backend-ir-v3.md) before changing an emitter. ## Setup ```bash git clone https://github.com/inso1337/revl && cd revl python3 -m venv .venv && source .venv/bin/activate pip install -e ".[test]" pytest tests/ # the frontend suite: should be green ``` (`uv venv && source .venv/bin/activate && uv pip install -e ".[test]"` is the equivalent with uv. The PyPI name `revl` is an unrelated project: install from source, never `pip install revl`.) The root suite, `tests/`, defines more than 10,000 test functions (parametrization collects far more tests than that), and it takes tens of CI minutes of test time across the shards, the sum of `tests/shard_weights.json`. `tools/docgen.py --check` fails when either claim stops being true; `--show suite-size` prints the exact figures. **The fast frontend loop.** A single local run of the whole suite takes hours on a loaded machine (253 test-minutes when issue #2217 measured it), so frontend-only work (parser, checker, lowering, docs, frontend-facing tests) does not pay for it every iteration. The same two files cover most of it: ```bash .venv/bin/pytest tests/test_frontend.py tests/test_doc_examples.py -q # ~0.5s ``` Before committing, run the tests your change affects, not the whole tree between every edit. The pre-commit hook already does this for you: it selects the affected tests with `tools/affected_tests.py` and reports how many passed. When the selector reports `FULL`, or is unavailable, the hook never runs the whole suite: it runs a defined core subset (`CORE_TESTS` in `tools/hooks/pre-commit`, override with `REVL_HOOK_CORE_TESTS`) under a wall-clock ceiling (`REVL_HOOK_FULL_BUDGET`, default 60s), and says the full suite did not run. If that subset fails, passes no test, or exceeds the ceiling, the commit is refused with the selector's reason and the `--no-verify` instruction. The hook never escalates to the whole suite. An affected selection whose every test skips (a machine without a backend's toolchain) does not block the commit, but the step prints `SKIPPED` and the hook ends with "ok, but the affected tests verified nothing". The full suite is CI's job, sharded there. For the fast inner-loop gate over the same selection, run `make pre-merge-affected`; it prints the selector receipt and stops before the full gate when the selector reports `FULL` or is unavailable. Read the receipt, then run `make pre-merge` only when you deliberately want the full gate. **Where the code lives (a two-minute map).** The compiler is `src/revl/` (`parser.py` → `typecheck.py` → `lower.py` → `apply.py`). The **emitters are NOT under `src/`**: each backend is its own directory with its own `emit.py`, and most have two or more *expression dispatchers* inside that file (component vs fn-body renderers; wasm has three). When a change touches an IR expression kind, you must patch every relevant dispatcher in `backends/{python,typescript,rust,go,java,wasm}/emit.py`; each file declares its dispatcher coverage in `EXPR_DISPATCHERS`/`EXPR_REFUSED`, checked by `tests/test_expr_dispatcher_conformance.py`, so "did you patch both paths" is a red test rather than a 15-minute grep. Tier runtimes and scenario harnesses live in the same per-backend directory. Install the pre-commit hook once per clone: ```bash git config core.hooksPath tools/hooks ``` The hook (`tools/hooks/pre-commit`) is deliberately kept under ~20s: a hook people wait a minute for is a hook people turn off. It runs the checks that have actually broken CI (see [The pre-commit contract](#the-pre-commit-contract)). Skip it for a single commit with `git commit --no-verify`; do not make that a habit. Per-tier toolchains are only needed if you touch that tier: the frontend suite runs the pure-Python halves of every backend and skips the runtime-integration tests cleanly when a toolchain is absent. To work on a tier you will want its runtime: - **python** tier: `sh backends/python/setup.sh` (builds the cordis-py venv) - **typescript** tier: `cd backends/typescript && npm ci` - **wasm** tier: `wasmtime` on `PATH` (CI pins v47.0.3) - **rust** tier: a Rust toolchain (`cargo`), crates.io reachable - **java** tier: JDK 21 - **go** tier: Go 1.26 ## The branch model: waves, worktrees, integration into main revl is developed in **waves**. A wave is one focused unit of work (a roadmap item, a bug, a tier fix) done on its own branch, in its own git worktree, so that many waves (human and agent) run in parallel without treading on each other. The pattern, visible throughout the commit history: 1. **Branch from `origin/main` in a fresh worktree.** ```bash git fetch -q origin git worktree add /tmp/wave- -b wave- origin/main ``` A worktree is a separate checkout sharing the one `.git`, but not its `.venv`: give it its own (`uv venv && uv pip install -e ".[test]"` inside it), or commit with a virtualenv active that has revl installed from this worktree. The pre-commit hook runs every check on one interpreter: the `REVL_HOOK_PYTHON` you set, else the active virtualenv, else the worktree's `.venv`, else `python3` on PATH. It never borrows the primary checkout's `.venv`, and it refuses an interpreter without pytest or one whose `revl` is installed from another checkout (`tools/hooks/resolve-python`). Work there; never on `main` directly. 2. **Do the work, commit on the wave branch.** Small, well-described commits. Commit messages are prose, present-tense, scoped (`fix(stdlib): …`, `docs(roadmap): …`, `test(swap): …`; match the existing log style). 3. **Integrate by merging `origin/main` into your branch, not by rebasing main onto you.** Before pushing: ```bash git fetch -q origin git merge --no-edit origin/main # take origin's moves onto your branch # re-run the suite; a clean merge can still be a semantic conflict ``` These integration merges are the `Merge remote-tracking branch 'origin/main' into wave…` commits you see in the log. Resolve conflicts on *your* branch, with the suite green, before the change reaches `main`. 4. **Fast-forward `main` only when it is truly an ancestor.** Guard the push so you never clobber a concurrent wave: ```bash git fetch -q origin && git merge --no-edit origin/main git merge-base --is-ancestor origin/main HEAD && git push origin HEAD:main ``` If `origin/main` moved under you, re-fetch, re-merge, re-guard. **Never force-push `main`.** 5. **Clean up the worktree** when the wave lands: ```bash git worktree remove /tmp/wave- ``` Human contributors from outside the core repo use the same shape through a **fork + pull request**: branch, keep it merged up to date with `main`, and open a PR. The pre-commit contract below is what a reviewer will check the PR against, so run `./tools/pre_pr.sh --merge-ref` before the first push and again before merge. The command checks the branch and a temporary merge with current `origin/main`, including the tree-wide encoding guards, strict census reproduction, and selector mapping pins; see [docs/process.md](docs/process.md) for the output and failure handling. ## The pre-commit contract Every change must pass these before it merges: the hook runs them locally and CI (`.github/workflows/ci.yml`) runs the ones it covers again. A green local hook is the minimum bar, not a substitute for CI, because the hook skips what your machine cannot run. 1. **Frontend suite + emitted-code validation**: `pytest tests/ -q`. This is the core gate. It includes `tests/test_conformance_validate.py`, which hands each tier's emitted output to that tier's *real* compiler (`tsc`, `cargo check`, `javac`, `wasmtime`, a scope walk for python), because "the emitter did not raise" never meant "the code compiles." Runtime-integration tests skip cleanly when a toolchain is absent; **read the skips** (`-rs`), a skip is not a pass. 2. **The conformance matrix (emit)**: `python3 tools/conformance.py`. Pure Python, no toolchain, always runs: it catches a backend that stops emitting a construct entirely. See [docs/conformance.md](docs/conformance.md). 3. **The TypeScript backend suite**: `cd backends/typescript && npx vitest run` (and `npx tsc --noEmit`). Runs `tsc` over emitted code; the hook runs it only where `node_modules` exists. 4. **The backend golden tests**: each tier's emitter has a checked-in golden and a test asserting it reproduces it **byte-identically**: | tier | command | |---|---| | python | `sh backends/python/setup.sh && cd backends/python && .venv/bin/pytest -q` | | typescript | `cd backends/typescript && npm ci && npx vitest run` | | wasm | `pytest backends/wasm/test_v3_emit.py tests/test_wasm_backend.py -q` | | rust | `pytest backends/rust/test_emit_rust.py -q` | | java | `pytest backends/java/test_emit_java.py -q` | | go | `pytest backends/go/test_emit_go.py -q` | CI runs each tier in its own job with the toolchain pinned. If you touch an emitter, run its tier's goldens; the frontend suite alone does **not** cover every per-backend golden. A change that adds or edits a test also carries its red-before / green-after receipt: run `python3 tools/evidence_receipt.py --base origin/main --node ` and put the block in the PR's Verification section. It names the base failure line and the head pass line, and it refuses when the node passes on base, so a reviewer never has to re-ask for hand-typed counts. ## The pre-merge gate > **Run `make pre-merge` before a change reaches `main`.** It is the gate that > catches the per-backend drift `pytest tests/` alone misses. **If you are an agent working a branch, read [docs/process.md](docs/process.md) first: it is authoritative for the branch-and-PR flow and it says the opposite of what this section assumes.** Under that process the PR jobs run in CI on the PR (the heavy jobs listed under "Pull request descriptions" run after merge, or on the merge queue when it is enabled), agents run only the tests covering what they changed plus `./tools/pre_pr.sh`, and the orchestrator merges on green. `make pre-merge` stays the local gate for anyone landing without that CI round-trip, and `make pre-merge-affected` is the narrow inner-loop form. The pre-commit contract above is necessary but not sufficient, and the gap is load-bearing: **the per-backend suites run OUTSIDE `pytest tests/`** (they are their own CI jobs: `backend-python`, `backend-typescript`, `backend-{go,rust,wasm,java}`, because each needs its own toolchain). So a change that is green under `pytest tests/`, which is what a wave agent self-verifies against, can still red CI on a per-backend suite and go unnoticed. That is not hypothetical: on 2026-08-26 `main` stayed **red for ~2h across ~19 pushes** because item 247's A5 respec left a stale `backends/python` semantics test and a ts fixture went uncommitted, both green under `tests/`, both red on the per-backend CI jobs (roadmap item 327; see the `revl-wave-backend-golden-gap` note). There is a second, narrower version of the same gap, and it is the reason CI has a `frontend-cordis` job as well as `frontend`. `pytest tests/` on a plain `.[test]` install has no cordis-py runtime, so every test in `tests/` guarded on `importlib.util.find_spec("cordis")` skips. Both halves are gated now: the `frontend` job runs the suite WITHOUT a runtime (it must stay green on a bare checkout) and `frontend-cordis` runs the same suite under `backends/python/.venv/bin/python` after `sh backends/python/setup.sh`, which is where the guarded tests actually execute. `make pre-merge` (and `--affected`) runs that half locally too, in its `frontend-cordis` step (issue #2207, below). Roadmap item 430 tracks what is excluded there and why. `make pre-merge` (script: [`tools/pre_merge.sh`](tools/pre_merge.sh)) closes that gap by mirroring the **fast half** of every per-backend CI job in one local command, in order, reporting each step: | step | what it runs | |---|---| | frontend | `pytest tests/ -q` (includes `test_conformance_validate.py`) | | frontend-cordis | the files of every test that skipped naming cordis in the frontend step (tests/conftest.py logs them to `REVL_CORDIS_SKIP_LOG`), re-run by `tools/cordis_half.py` under the cordis-py venv: `$REVL_CORDIS_PYTHON`, else `backends/python/.venv/bin/python` of this tree, else of the primary checkout, whichever can import the suite's deps, `cordis` and `watchdog` and has its cordis-py checkout at `CORDIS_PY_PIN`. An empty log means nothing to re-run. With no such venv the step prints `MISSING, NOT OK` and the gate exits non-zero: a runtime half that did not run is never `ok` (issue #2207) | | backend-python | `cd backends/python && .venv/bin/pytest -q`: the suite item 247 left stale | | backend-go | `pytest backends/go/test_emit_go.py -q` | | backend-rust | `pytest backends/rust/test_emit_rust.py` (emit/golden tests) | | backend-wasm | `pytest backends/wasm/test_v3_emit.py backends/wasm/test_canonical_abi.py` | | backend-java | `pytest backends/java/test_emit_java.py` (emit/golden tests) | | backend-ts | `tools/regen_goldens.py --check typescript` (golden drift) | | golden drift (all targets) | `tools/regen_goldens.py --check` (CI adds `--strict`: `lint` for the toolchain-free targets, `backend-go` for `go`) | | conformance matrix + emit sweep | `tools/conformance.py --check-readme` | | site wheel | `tools/check_site_wheel.py`: the playground wheel is NOT committed; `.github/workflows/pages.yml` builds it into the published site, and this checks that contract (also runs post-merge on main: `.github/workflows/site-wheel.yml`) | | docs drift | `tools/docgen.py --check` (the source-derived doc blocks; `make docs-gen` regenerates) | | lint | `ruff check` (pinned `ruff==0.16.4` via `uvx` if not on `PATH`) | | census artifact | `tools/census_artifact.py --verify --strict`: CI's `census-artifact` job, run locally on the artifact's own input filter, so a diff that stales `docs/census-artifact/` reds here instead of on the PR. Adding a corpus document is enough to stale it (`corpus_dirs` includes `tests/fixtures`), and the test the selector used to pick for these paths skips the half that reds. The slowest step in this gate: a full census re-run, ~85 s on a shared dev box (issue #2103) | | formal | `sh formal/scripts/run_gate.sh` (lake build + the axioms gate; loud-skips without lake) | | ci-only suites | three kinds of CI step, read from `ci.yml` by `tools/ci_suites.py`: pytest on whole backend directories (`pytest backends//`), vitest, and pytest under `REVL_CROSS_TIER_SLOW`, `REVL_SWEEP_ALL_TIERS` or a `REVL_REQUIRE_*` switch set by the step or its job (so the frontend job's `pytest tests/` under `REVL_REQUIRE_TIERS=go` is one). Each is opt-in by its CI job name and otherwise prints `SKIPPED (not opted in; CI job runs it; opt in: --with=)`. Opted in, it runs CI's command with CI's switches when the job's toolchain is present, else prints `SKIPPED` with what is missing (issue #2210) | Two properties make it trustworthy rather than theatre: - **It is the fast half on purpose, not a full CI replica.** The toolchain-*executing* tests (cargo build, javac + a cordis4j clone, wasmtime, `tsc`/`vitest`) stay CI's job; they are slow, networked, and SIGKILL on the shared dev box. The rust/wasm/java emit suites are run with the heavy compilers hidden from `PATH`, so their `which()`-gated tests **skip loudly** and only the emit/golden half runs, which is the half that catches a stale golden or a respec'd emitter, the exact item-327 drift class. The typescript `vitest`/`tsc` suite needs `node_modules` and is not in the fast gate; its pure-Python goldens are (the `backend-ts (golden drift)` step). The toolchain-executing suites are still listed, one `SKIPPED` line each, and run on request: `sh tools/pre_merge.sh --with=backend-typescript,conformance`, `--with=all`, or `REVL_PREMERGE_WITH=all make pre-merge`. `sh tools/pre_merge.sh --plan` prints what each step would run and runs nothing. - **Nothing is a silent skip.** Every step is either *ran* (and must pass) or *skipped* with the reason printed (`SKIPPED (no backends/python/.venv; run sh backends/python/setup.sh)`), because a silent gap is precisely what let CI stay red. The target exits non-zero if any step that ran failed; a skip does not make it pass green quietly: it tells you what CI will still check that you did not. On a fully-provisioned checkout nothing skips. If you touched an emitter or a per-backend suite, `make pre-merge` is the difference between "green on my machine" and "green on `main`". Run it before you fast-forward `main` (step 4 of the branch model above). It is not a substitute for the orchestrator's post-merge `gh run list --branch main` check; it shrinks the window that check has to catch, it does not remove the check. ### Two invariants that will fail your change if you break them **Goldens are snapshots, reviewed.** Every emitter has a checked-in golden for the reference composition (`backends//golden/user_cache.`), and the suites assert the emitter reproduces it byte for byte. A change that alters emitted bytes fails the suite until the golden is regenerated and its diff is reviewed; the invariant is that output never changes *unreviewed*, not that it never changes (see "Golden policy" in [docs/conformance.md](docs/conformance.md) and [docs/stability.md](docs/stability.md)). Do not "fix" a failing golden by regenerating it to match your change; understand why it moved first. New IR capability goes under a new `ir_version`, not into an existing one. **Every rejection joins the executable spec.** This is the rule that keeps the soundness promise self-proving. When you make the checker refuse something it used to accept (closing a hole, adding a guarantee), you must: 1. add a minimal fixture under **`examples/rejections/`** (e.g. `g4_my_new_hole.rvl`) that triggers the rejection; 2. add a row to the **`REJECTIONS`** table in **`tests/test_frontend.py`** mapping that filename to the exact diagnostic substring the checker must produce. `tests/test_frontend.py::test_every_rejection_file_is_covered` asserts the set of files on disk **equals** the set of keys in `REJECTIONS`, so you cannot add a fixture without a spec entry, or an entry without a fixture. The rejection suite is therefore the checker's *definition* of sound, exhaustive by test, not a sample. (The handful of guarantees that no source program can violate at compile time (G5, G7, A3, A4, A5, A7) are documented in the block after the `REJECTIONS` table, with where each is actually enforced. If you believe you have a new such case, extend that block; do not leave it implicit.) A diagnostic is itself a deliverable ([DESIGN.md](DESIGN.md) §9): it must name its guarantee and state the fix, and it is written for the model that will read it. A rejection whose message is a bare "error" is not done. ## Pull request descriptions Describe validation precisely: - Name the local command and its result. For CI, name the job and event, and report its result. A job skipped by its `if:` condition or changed-path filter is not a pass. - Branch protection requires `lint` and the six `backend-*` jobs (`backend-python`, `backend-typescript`, `backend-go`, `backend-rust`, `backend-java`, and `backend-wasm`); `.github/workflows/ci.yml` defines them. The backend jobs are path-filtered and may skip when no changed path can affect them. `root-suite-affected` selects tests by changed paths and may fall back to the full set. - The `ci.yml` jobs excluded from `pull_request` events are `frontend`, `frontend-cordis`, `conformance`, `formal`, `temporal-exit`, `sandbox-container`, and `sandbox-microvm`. They run on a `merge_group` candidate when the merge queue is enabled in branch protection. Without the queue, they run on pushes to `main`, the schedule, `workflow_dispatch`, and `workflow_call`, as their conditions allow. Other workflow files have their own triggers; check them before describing their results. - If a check did not run locally, say why and where it runs, including whether it can run locally with the needed toolchain. For a check that runs only after merge, state whether a failure would be fixed forward or reverted. Read the job's `if:` and the workflow's `on:` before claiming that a check ran before merge. A skipped job is not evidence of a pass. ## Adding or changing a backend tier - Read [docs/backend-ir-v3.md](docs/backend-ir-v3.md) (and [docs/backend-ir-v1.md](docs/backend-ir-v1.md) for the v1 core). The frontend is the single IR producer; a backend consumes IR and must gate on `ir_version`, refusing a version it does not implement rather than misemitting. - Keep the tier honest with the conformance matrix: `tools/conformance.py` emits every construct through all backends, and `--validate` hands the output to the real toolchain. A new gap must show up there, and a baselined failure that starts passing must be un-baselined (`tests/test_conformance_validate.py` fails both on new breakage and on a baselined case that starts passing, so the list can only shrink). - A tier's runtime-integration tests should **skip loudly** without their toolchain, never silently pass. CI installs the toolchain and, for the tiers where it matters, makes a missing runtime *fatal* (e.g. `REVL_REQUIRE_WASMTIME=1`) so coverage is real and not implied. - **A test that drives `revl.test.RUNNERS` must be run by a CI step that sets `REVL_REQUIRE_TIERS`** for every tier it names (issue #266). The switch takes a comma-separated list of tiers (or `all`); on those tiers an *absent toolchain* stops being a skip and becomes a failure, while a tier's by-design refusal of a document still skips. Unset (a laptop, a fresh worktree) keeps the old behaviour, so nothing about local development changes. The `conformance` job is where both halves exist (`sh backends/python/setup.sh` for cordis-py, `npm ci` for cordis-ts), and it runs the tier tests with `REVL_REQUIRE_TIERS: "py,ts"`. Add a new tier test to that step's file list; `tests/test_env_gated_skips_run_somewhere.py::test_every_tier_test_is_required_to_actually_run_in_some_ci_job` fails if you do not. Without it, `test_..._executes[ts]` executes nothing and reports green everywhere the toolchain is absent, which is how a live `TypeError: Cannot mix BigInt and other types` sat under a green ts test. Some Cordis runtime targets are upstream projects (cordis-py, cordis, cordis-rs, cordis4j, stc-go): a genuine *runtime* defect there is fixed upstream (fork + PR to that repo), while an *emitter* defect is fixed here in `backends//emit.py`. Fix the emitter for an emitter bug; do not fork a runtime to paper over a lowering mistake. ## Documentation changes Docs are load-bearing and public-facing: accuracy matters more than polish. Match the existing tone: precise, honest about what is tested vs. demonstrated vs. claimed, and specific about the command that backs a claim (a number with no command behind it is the failure mode this project keeps hitting). The roadmap ([docs/v2.0-roadmap.md](docs/v2.0-roadmap.md)) is the ledger of what is done and what is in flight; keep it current when you land a roadmap item. ## Tracking work: issues, the roadmap, and security findings The roadmap decays because its state is prose that somebody has to remember to edit. On 2026-09-02 twelve per-finding markers still read ``FIXING on `fix/` `` for branches that had already merged, five items were closed behind a stale in-progress marker, one open item was a duplicate of a landed one, and two agents fixed the same defect in parallel because neither could see the other's scope. `make roadmap-check` (`tools/check_roadmap_markers.py`, run in the `lint` CI job) now fails when a marker contradicts git. Four rules: Git only answers questions about branches and shas, and on 2026-09-15 five agent reports each opened by correcting a roadmap sentence that git agreed with and the working TREE did not: a test renamed out from under its citation, a function lifted into a shared module while four paragraphs kept pointing at the old file, a named xfail registry emptied because its gap closed. `make roadmap-claims` (`tools/check_roadmap_claims.py`) resolves the roadmap's citations against the tree instead, and its `--check` line runs in `lint`. `tools/roadmap_claim_allowlist.json` carries the citations that name a sibling project, each with a written reason. A cited path is judged whether or not it carries a `:line`, and whether it names one file or a glob of them: 489 of the document's 508 backticked path citations, against 75 before issue #1233. Line numbers are still never judged, because a drifted coordinate is not a false claim about the tree. The three shapes that are not judged, and the count that decided each, are in the tool's docstring. A citation that is stale and needs a roadmap EDIT does not go in the allow-list. It goes in `tools/roadmap_claim_ratchet.json`, which records it with the sentence and the edit that clears it, so that widening a rule does not red the branch it lands on. That file is debt and is built to come down: its `count` has to be restated on every change, and an entry that stops matching fails `tests/test_roadmap_claims_gate.py`, so correcting the sentence forces the entry out. At zero entries the file is deleted. 1. **State lives in GitHub issues. The roadmap holds the reasoning.** Whether something is open, assigned, in flight or closed is a tracker's job, and a tracker updates itself. The roadmap keeps what a tracker is bad at: the argument, the reproducer, the evidence, the negative results, and the cross-references between findings. The migration is done: `tools/check_roadmap_markers.py --require-issue` runs in the `lint` job, and every open or partial item has to cite its issue, or, for a security item whose finding must not be a public issue (rule 4), its private security advisory (a GHSA id or the advisory URL). 2. **A fix records which instances it covers, and which it does not.** This is the one thing no tracker fixes for you. A merged branch is not a closed finding: a cordis guard closed one test while 47 files stayed exposed, and a backend-suite fix de-collided one pair of roots while `pytest backends/` still died at collection. When you land a fix, write down the scope you actually swept and the instances you knowingly left, by name. "Fixed" with no scope is the sentence that costs the next person a full re-investigation. 3. **Say what is still open in the same breath as what is closed, and say which tiers.** Git can only answer questions about branches and shas, and five failures on 2026-09-02 were invisible to it. Five more checks exist for them, each behind its own flag, all five under `make roadmap-check-all`. What they ask of a writer: - A header that claims closure must not have open findings under it, and a closed finding must not contradict itself in its own body. Item 422's header read "ALL SEVEN FINDINGS FIXED" while its own body said F1-F4 were untouched on the typescript tier, and a disclosed CRITICAL sat unowned because the header was believed. Qualify the claim instead: "fixed on the py tier, F1-F4 still open on the ts tier" passes, because it is true. - "Folded into X" tracks something only if X exists, is open, and is owned. Item 425 F3 and item 427 F5 both pointed at item 416's block (c), a list of unexecuted hypotheses with no owner, so the residual was closed nowhere and nobody was working it. - An open finding names a branch, an issue, an owner, or the sha that fixed it. The sha you re-verified it on does not count: that records that the finding is still live, not that anybody is closing it. - One item NUMBER carries one block per section. Numbers restart per section by design, but two blocks under one number inside a section is always a defect: merge `2d2fa07f` kept both sides of a hunk and left a stale `106. 🚧` header above the `106. ✅` entry that had replaced it, and that one line was the only reason a landed item read as open. - A fix that touches one `backends//` path says which other tiers it does not cover. Three defects were fixed on the python tier on 2026-09-02 and left live everywhere else. No gate can decide semantic parity; naming the other tier is the whole discipline, and it is also the escape hatch that silences the check honestly. - **A marker must not say work is IN FLIGHT on the branch the PR is from.** ``FIXING on `fix/277-rust-vec-char` `` is green while that PR is open, because the branch exists; the merge deletes it, so the marker the PR introduced is stale on landing. `lint` runs on branch tips, so that reds main *and* every open PR whose merge-ref carries the new text, each of which then has to be retriggered by hand. Four occurrences on 2026-09-02. Cite the PR instead (`PR #175`), or once it has landed, the merge sha. `--head-branch` checks this: CI passes `github.head_ref`, which is empty on a push to main, and `tools/pre_pr.sh` passes your current branch before the PR is even opened. Naming your own branch in the PAST tense is fine and does not trip it: ``LANDED SO FAR (`fix/391-selfhost-parity`)`` stays true after the branch is gone, and that is the whole difference. Rule 3 of `tools/check_roadmap_markers.py` (a cited sha must be a commit in this repo and reachable from the base ref) is the one check that needs HISTORY rather than the file alone, so it says out loud what it managed to look at. On a shallow checkout it deepens the clone once (`--no-fetch` declines) and prints `Checked N of the M commit citation(s) ...`; if it still cannot examine every citation it says so and says how many, and does not print `roadmap markers OK`. Do not read that sentence as a finding against your roadmap: it is a statement about the checkout. 4. **Security findings go to private GitHub Security Advisories, never public issues.** See [SECURITY.md](SECURITY.md). This repository is public and the roadmap already carries working reproducers, so this is a going-forward rule, not a claim about what is already out there. Note plainly: deleting a reproducer from HEAD does not unpublish it. Git history retains it, as do forks, clones and mirrors. Treat anything already committed as published, and decide about it on that basis rather than on a deletion that changes nothing. 5. **A closing keyword next to an issue reference closes that issue, even in the sentence that says it does not.** GitHub scans the PR body, the PR title and the commit message for `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves` and `resolved` followed by a `#` reference, and closes that issue on merge. The scan is not markdown-aware and does not read negation: backticks, a fenced block, an HTML comment and a "does not" in front of it are all invisible to it. On 2026-10-07 this fired twice here. PR #2128's body argued that its change was not the closure of a parser family; the family tracker is issue #2094, and it was closed two seconds after the merge. PR #2133's body carried the same denial about the replacement tracker, issue #2131, and closed it the same way. Both had to be re-opened by hand. The second did not stop at the body: #2133 was a single-commit PR, so its squash adopted that commit message, and the denying sentence now sits permanently in the history of `00fdd6059`. Put the reference where no keyword can reach it (`refs #2131`, `#2131 remains open`, `leaves #2131 open`) or phrase it with the reference first: "#2131 is not closed by this PR" is safe, because the keyword is no longer the thing nearest the `#`. The residual risk is replay rather than the merge itself: text that already fired once does not fire again on its own, but if that sha is carried into a new merge (a rebase, a cherry-pick, a branch based before it), the parser reads it as new. Nothing offline guards this: `tests/test_roadmap_issue_state.py` is deliberately network-free (issue #2100) and reports no verdict without evidence, so an issue closed this way reds no gate. If a tracking issue closes and no merge of yours explains it, read the commit message of whatever landed last. ## Reporting bugs and requesting features Use the issue templates (bug report / feature request). A bug report that arrives with the `.rvl` source, the target tier, and the emitted/expected output is triageable in minutes; one that arrives as prose is a day of archaeology. The templates ask for exactly what a maintainer needs; please fill them in. For a **security or soundness escape** (a program the checker *accepts* that violates a guarantee), do **not** open a public issue. Follow [SECURITY.md](SECURITY.md). ## License By contributing, you agree that your contributions are licensed under the license that covers the path they touch: AGPL-3.0-only for the compiler, gate and tooling, MIT for the stdlib, runtime shims and examples. The map is [LICENSING.md](LICENSING.md). Copyright in the repository is held by Thomas Moussajee; the names and logo are covered by [TRADEMARK.md](TRADEMARK.md).