Skip to content

v0.4.0

@dcoai dcoai tagged this 03 Oct 19:05
**A minor line, opened by the namespace.** Every module moved from `TLA` to `ExTLA`, and
every Mix task from `mix tla.*` to `mix extla.*` (#337, D-40). `TLA` was a namespace ExTLA
doesn't own, which Elixir's code anti-patterns call *namespace trespassing*. The change is
breaking and mechanical, and it is why this is 0.4.0 rather than 0.3.21. **Migrating to
0.4.0**, below, is the whole rewrite: the application (`:extla`), telemetry, configuration
and every spec you have written are unchanged.

The rest is the three weeks since 0.3.20, and most of it moves the surface on its own:
- `mix extla.check --format json` is schema 3;
- predicates that return a non-boolean are refused rather than counted as holding;
- a temporal formula outside §5.1's fragment is refused at compile time;
- unknown options, names and flags are refused instead of being dropped.

Beside those are the gates that hold the project to its specification: the surface goldens
(`API_SURFACE.md`, `CLI_SURFACE.md`, `PACKAGE_SURFACE.md`, `DIAGNOSTICS.md`), a decision log
whose every entry is pinned to a test, and the guide harness over `spec/` and `README.md`.
Then the fixes those gates and two reviews found.

**Entries written before the rename name the modules and tasks as they were then** (`TLA.*`,
`mix tla.*`). The CHANGELOG is a record, so they are left as written; read them through the
migration.

The next release moves every module from `TLA` to `ExTLA` (#337). It is a breaking change, and
it is mechanical.

- **Modules:** `TLA` → `ExTLA` and `TLA.<Name>` → `ExTLA.<Name>`, everywhere a module is
  named: `use TLA.Spec` → `use ExTLA.Spec`, `use TLA.Refinement`, `use TLA.GenServer`,
  `import TLA.Spec.Test`, `alias TLA.{ModelChecker, Trace}`, `%TLA.Trace{}`, and
  `TLA.ModelChecker.run/2`-style calls. One rewrite covers it, as long as it leaves `TLA+` and
  prose alone. For example, replace `TLA` with `ExTLA` where it is followed by `.` and a
  letter or `{`, and is not preceded by a letter, digit, `.` or `:`. Then fix any bare `TLA`
  module reference by hand.
- **Mix tasks:** `mix tla.<task>` → `mix extla.<task>` for `check`, `explore`, `replay`,
  `simulate`, `stamp` and `visualize`: in scripts, CI files, Makefiles and aliases. A module
  that calls a task directly moves from `Mix.Tasks.Tla.*` to `Mix.Tasks.Extla.*`. Switches,
  arguments, output and exit codes are unchanged.
- **Unchanged:** the application name `:extla` (your `deps` entry), telemetry event names
  (already `[:extla, …]`), configuration keys, and every spec you have written. A spec module
  is yours; only the `use` line names ExTLA.

- **A refinement mapping missing a `concrete_message/2` clause is named, not crashed on**
  (#321, closes #299). `TLA.Refinement.validate!/3` answered "does this mapping name every
  action the spec declares" by *calling* `concrete_message/2` for each one, so a mapping
  with no clause for an action raised `FunctionClauseError` from inside the validator —
  where the contract is a `TLA.Refinement.Error` with a sentence naming the action, which is
  precisely what that rule exists to report. It is a **list comparison** now: `use
  TLA.Refinement` installs an `@on_definition` hook recording the first argument of every
  clause, and the spec's actions are compared against the record. Two consequences beyond
  the crash. The clause half of the rule needs no binding, so it is decided even for an
  action with params and no constant values — the shape of a published example, where the
  probe used to skip the action and a missing clause validated clean. And the three shapes
  stay distinct where the crash had collapsed them into two: defining **none** is legal and
  reported as an undecided rule, defining **all** passes, defining **some** is the finding
  and it names the actions it does not cover. A clause whose first argument is a *variable*
  covers everything and is read that way — reading it as covering nothing would report a
  complete mapping as incomplete, which is worse than the crash. `abstract_action/1`'s
  totality probe was deliberately **not** copied: its domain is any message a server might
  receive, so a probe is the only instrument; `concrete_message/2`'s domain is the spec's
  actions, which `validate!/3` is holding (`spec/08-decisions.md` D-39). A mapping that
  declares `@behaviour TLA.Refinement` by hand has no record and the rule is named in the
  deferred list for it rather than claimed. New corpus mutant
  `TLA.Conformance.Counter.Mutants.NoClauseForAction`, one door over from
  `ActionNeverNamed`, which names an action *wrongly* rather than not at all — which is why
  the suite had never exercised this.

- **The Mix tasks are `mix extla.*`** (#339, for #337). The same rule as the modules, one
  level down. Elixir lets a library define tasks under `Mix.Tasks`, and its own example names
  the task after the library, so `mix tla.check` was open to the same collision as `TLA.*`.
  The six user tasks, the six ledger tasks that regenerate the goldens, and the test-support
  `mutants` task move to `Mix.Tasks.Extla.*`. CI, golden headers, spec, guides and README use
  the new names, and so does the deferred `mix extla.gen.test` the spec designs.
  `CLI_SURFACE.md`'s discovery reads `extla.*.ex`. Worked test-first like #338: 33 test files
  renamed and run against the old tasks gave 156 red, and each of the 237 that stayed green
  was explained (#339 records the run).

- **Every module lives under `ExTLA`** (#338, for #337; `spec/08` D-40). ExTLA defined its
  modules under `TLA`, a namespace it doesn't own: Elixir's code anti-patterns call that
  *namespace trespassing*, because the VM loads one module per name and two libraries that
  define the same one can't both be used. `TLA` is the name any other TLA+ library would
  reach for first. Files follow the modules (`lib/ex_tla/`, `ledger/ex_tla/`, `test/ex_tla/`),
  and spec, guides, README, usage rules and every golden name `ExTLA.*`.
  `test/ex_tla/namespace_test.exs` holds every module under `lib/` and `ledger/` to the
  namespace. There are no `TLA.*` compatibility modules, since they would be the trespass
  itself. **Migrating to 0.4.0**, above, is the rewrite a consumer applies.
  - The rename was worked **test-first**. 48 test files were renamed and run against the
    unrenamed code: 372 tests went red, and each of the 93 that stayed green was explained.
    They read documents or source, or reach ExTLA through a Mix task still named `tla.*`;
    none exercised nothing (#338 records the run). Not recorded as surfex evidence: a test
    that fails only because a module doesn't exist yet says nothing about its assertions.

- **surfex `~> 0.5.24`** (#335, for #334), up from 0.5.16. Upstream changed only its usage
  rules: `usage-rules.md` is one short agent page pointing to `mix surfex.info`. What ExTLA
  calls is unchanged; `PACKAGE_SURFACE.md` moves only in surfex's own row.

- **surfex comes from Hex, `~> 0.5.16`** (#333, for #332), not the GitLab git tag v0.4.0.
  It was the only git dependency, so CI's `git config insteadOf` rewrite onto the job token
  (#71) — and with it extla's place on `dco-tek/surfex`'s job-token allowlist — has nothing
  left to do and is gone, and depdep keys every dependency in its first pass. What ExTLA
  calls is unchanged: five goldens regenerate byte-identical, and `PACKAGE_SURFACE.md`
  moves only in surfex's own row. 0.5.16 also carries surfex's adoption work (a trust
  baseline, evidence checked across CI jobs, renamed tests keeping their records), which a
  relation-log pilot would build on; nothing here adopts it yet.

- **surfex is pinned at v0.4.0** (#330, for #327), up from v0.1.0. What ExTLA calls —
  `Surfex.Golden.render/1`/`stat/2` and `Surfex.SourceScan`'s `definition_hash/1`,
  `defmodules/1`, `lib_sources/1` and `project_root/1` — is unchanged across the three
  releases, and every golden surfex renders regenerated byte-identical; `PACKAGE_SURFACE.md`
  moved only in the surfex row's own tag. What the bump brings is `lib_sources/1`'s stricter
  first-party rule (surfex #9/#10): the `tmp/` trees a `@tag :tmp_dir` surface test leaves
  behind are no longer scanned, so `API_SURFACE.md` can no longer drift on a machine that has
  just run the tests while CI stays green. v0.4.0's removals (the deprecated v0.2 trace)
  touch nothing here.

- **CI restores dependencies with depdep v0.7.0** (#329, for #328). The store already worked
  for this project — a 100% hit rate over three days — and could not show it: objects pushed
  before v0.7.0 carry no compile time, so a hit had nothing to post as `depdep.saved`. v0.7.0
  pushes the compile time with each object and posts `depdep.compile_carried` for every hit
  that carries one. Its keys come from Mix's own converge (schema v3) and fingerprint the full
  toolchain, so every object path changes — yet the bump's own pipeline was warm, not the
  cold refill depdep warns of: `pulled 6, missing 1`, and `saved ~10.3s`, the first saving
  extla has reported. The one miss, `telemetry`, is restored and then rebuilt as outdated on
  every run, and `--push` does not replace it; that is dco-tek/depdep#110. The CI line is
  unchanged — it already ran `--compile-deps`.

- **The guides teach set operations wherever they teach set types** (#320, closes #183).
  `type: set(t)` told a reader that a variable can *be* a set, and nothing told them how to
  take a union of one: neither `TLA.Set` nor `TLA.Function` was named anywhere in `guides/`,
  `README.md` or `usage-rules.md` — only in `spec/`. So the whole of TLA+'s set theory was
  reachable from a spec body after #319 and invisible to the reader who needed it, and
  anything past a map-keyed lock — a queue, a quorum, a set of messages in flight — had
  nothing to reach for and no signal that it existed. `guides/tutorial/02-dsl.md`, the page
  that introduces `type:`, gains §Operations on sets and functions: the whole vocabulary as a
  TLA+-to-DSL table, a worked spec (`Tutorial.Quorum` — a node acknowledges once, the value
  commits when some subset of the nodes is a majority and has all acknowledged) compiled and
  checked by the guide harness at **12 states, depth 5**, and the two traps, one of them a
  block asserted to raise. That spec is the same model as the `set_vocabulary` corpus entry,
  so what the page teaches is held to TLC and not only to ExTLA. `usage-rules.md` gains a
  ninth rule carrying the traps #319 left: the vocabulary needs no qualification, `set_map/2`
  and `set_filter/2` are renamed because an unqualified `map/2` in a spec block reads as
  `Enum.map/2`, `for_each/2` refuses a sequence by name, and `domain/1` of a sequence is its
  indices. The gate is `test/docs/set_operations_taught_test.exs`, and both its halves are
  found rather than named: the page, by the type form it introduces; the operators it must
  teach, from `lib/tla/spec/helpers.ex`'s own `defdelegate`s — so an operator added to the
  vocabulary and not taught is red, which is how a page drifts back into types-only one
  function at a time. It also fails on an operator the page names that a spec body cannot
  call.

- **One implementation of each TLA+ operator, and `01-foundation.md`'s vocabulary reachable
  from a spec body** (#319, for #183; D-38). `TLA.Set` and `TLA.Function` are specified as
  the foundation everything else builds on, and a spec body could not reach them:
  `TLA.Spec.__using__/1` imports only `TLA.Spec.Helpers`, which **re-implemented** four of
  their operators and exposed none of the rest. An author had to write
  `TLA.Set.powerset/1` in full, and exactly one spec in the repository did — which is also
  the only place the gap was visible. The two copies had already drifted, in the direction
  this project refuses everywhere else (#318): `for_each/2` refused a list **by name**
  because building a function over a *sequence* deduplicated it silently (#211), while
  `TLA.Function.construct/2` still did exactly that — **#211's fix had landed in one copy**
  — and `TLA.Function.domain/1`, being `Map.keys |> MapSet.new`, raised `BadMapError` on a
  sequence and answered a **`MapSet`** — a struct, therefore a map — **with the struct's own
  field names**, a wrong answer that looks like an answer, in the module the specification
  calls the foundation. The duplicates are gone: the foundation holds the implementation and
  adopts the semantics that were right (`construct/2` refuses a sequence by name; `domain/1`
  reads one as the function on `1..Len(s)` and refuses a set by name), and `TLA.Spec.Helpers`
  is a façade of `defdelegate`s that implements **no** operator — only its own thin covers
  over `MapSet` and `Enum`, which have no counterpart there. The façade also widened, so
  `powerset`, `cross`, `cross_many`, `distributed_union`, `set_map`, `set_filter`,
  `for_all?`, `exists?`, `range`, `function_set`, `except` and `record_set` are now callable
  **unqualified** from an `action`, `init`, `invariant`, `terminal` or `action_property`
  block. #183 preferred importing the two modules into the spec body directly; that is not
  available, because `domain/1` and `choose/1,2` exist on both sides and a second import
  makes every shared name ambiguous at its call site — the collision the proposal asked us
  to check is real, and it is between the two modules rather than with `Kernel`. `map/2` and
  `filter/2` arrive as `set_map/2` and `set_filter/2` because an unqualified `map/2` in a
  spec body reads as `Enum.map/2` to every Elixir programmer. The gate is derived, not
  written down: `test/tla/spec/operator_agreement_test.exs` reads `lib/tla/spec.ex`'s own
  `__using__` imports for what is reachable, requires every foundation export to be
  reachable under its own name or a listed alias, and reads `lib/tla/spec/helpers.ex`'s AST
  so that an operator *implemented* there rather than delegated fails the build. A seventh
  differential corpus entry, `test/differential/set_vocabulary/`, is the only one whose
  subject is the vocabulary rather than an algorithm: it calls the operators the way an
  author writes them and TLC says whether they mean what TLA+ means (12 states, diameter 5).
  The Paxos entry's fully-qualified `TLA.Set.powerset/1` is now a plain `powerset/1`.
  `test/docs/spec_api_test.exs` learned to read `defdelegate` as a head, since the block it
  gates is now mostly delegations and a reader that skipped them would exempt exactly the
  block that had just been rewritten.

- **The four declaration kinds agree about a non-boolean, the wrapper stops warning in the
  user's module, and the formatter covers the repository** (#317, closes #182).
  02-dsl-semantics.md §4.2 says "a boolean expression" for invariants, action properties,
  preconditions and postconditions, and notes no difference between them — but the invariant
  path read the value with `unless` while the condition paths matched `true` and `false`
  exactly. So a precondition returning `nil` (a `Map.get/2` on a key that is not there is the
  ordinary way to get one) **killed the server with a `CaseClauseError`** naming a generated
  function, where the same `nil` from an invariant was reported as a violation of the
  declaration the user wrote. The `:verbose` paths diverged a third way, treating a *truthy*
  non-boolean as a violation — so telemetry could change a verdict. All six check paths now
  share one reading, and it is Elixir's own truthiness. Separately, the wrapper bound
  `tla_new_state` unconditionally, so a module carrying **only preconditions** got
  `warning: variable "tla_new_state" is unused` about code it did not write and paid an
  extraction call per transition against the zero-cost principle; the binding is now guarded
  on whether anything downstream reads it, and
  `test/tla/declaration_shapes_test.exs` compiles **all sixteen** declaration combinations,
  because the defect lived in exactly one of them and a test of one combination is what let
  it. `.formatter.exs` covered `{bench,config,lib,test}` while `bin/`, `ledger/` and
  `scripts/` held real Elixir — the CHANGELOG guard, the surface-drift ledger and its six Mix
  tasks — and one of those files was **unformatted while the `format` job passed**; the
  inputs are widened and `test/docs/formatter_inputs_test.exs` holds them to the set
  `git ls-files` reports. Dead code goes with it: `TLA.Config.max_states/0`, which nothing
  read and which duplicated `TLA.ModelChecker`'s own default (two copies of one number is the
  defect class, so it is deleted rather than synchronised — and `spec/01-foundation.md` says
  why); `TLA.Profile.calibration_samples/0`, a published function with no caller and no test;
  and an unreachable third clause of the private `from_init/3` in `TLA.Trace`. The
  ten-percent failure-injection probability was a literal inside the injector with no way to
  reach it, and is now `failure_rate:`, defaulting to `0.1` and pinned by agreement against
  an explicit `0.1` under a fixed seed.

- **A run releases what it allocated: the graph's ETS tables, and the replay's server**
  (#316, for #182). An ETS table is owned by a *process* and is not reachable from a term, so
  discarding a `TLA.StateGraph` freed nothing. `TLA.ModelChecker.run/2` discards the graph and
  left both tables holding one record per distinct state for the **calling process's**
  lifetime — twenty runs, forty tables. It was invisible under ExUnit, where every test is its
  own process and a process's exit takes its tables with it, and it accumulated exactly where
  processes are long-lived: `mix tla.check A B C`, `TLA.Spec.Test`, the ledger's corpus pass.
  `run/2` now searches in a process of its own, so the tables are created there and the
  child's exit is what frees them — on the normal path and **on a raise**, with no `try/after`
  and no delete to forget at a call site. The exception is re-raised in the caller with the
  stack it was raised on, so nothing is swallowed; the cost is one crash report on stderr from
  the isolated process before the diagnosis, which is the same trade `TLA.Ledger` already
  makes. `explore/2` keeps its tables, because the graph *is* its return value, and now says
  so where it is returned — the specification already promised "the caller owns the graph, and
  its ETS tables with it: `run/2` is this with the graph dropped, and it releases them", which
  was true of no code. `TLA.StateGraph.delete/1` is the release that contract needs, and is a
  no-op on a graph already gone. `TLA.Replay` likewise left a live `GenServer` per replay
  where `TLA.Simulation` — the same shape one door over — stops its own; it now stops the
  server it started, the way a supervisor stops a child, and leaves a caller-supplied
  `:server` alone. Counted, not reasoned about: `test/tla/resource_lifetime_test.exs`
  measures the graph's own tables by name and the servers by `$initial_call`, and reproduces
  both numbers — forty and twenty — against the unfixed code.

- **The four `rescue` sites in `lib/` are reviewed one at a time, and the two that hid a
  failure are narrowed** (#315, for #182). CLAUDE.md forbids `try/catch/rescue`, but a count
  is the wrong reading of the rule: a rescue scoped to a *function* rather than a *call*
  reports any failure underneath it as the one it was written for, which is not a swallowed
  error but a **confident wrong diagnosis**. `TLA.Refinement`'s totality probe was scoped to
  the whole of its enclosing function, so a `FunctionClauseError` raised by a *helper* the
  mapping's catch-all called came back as "`abstract_action/1` is not total — add
  `def abstract_action(_), do: :unmapped`", advice to add a clause the mapping already had,
  about a partial helper the message never named. It now reads one stack frame to tell the
  probed call's own failure from any other and re-raises every other untouched. `mix
  tla.explore` refused an undefined *local* name once and let an undefined *remote* call —
  `Enum.no_such_thing(count)` — fill a cell per state, the exact outcome the probe's own
  comment says it exists to prevent; a deterministic failure is now refused once, like a
  `CompileError`. The two that stay stay for a stated reason — for both, the exception is
  data about the **user's own input**, so a blanket removal would have made the trace
  explorer worse than it was — and `mix tla.check`'s is scoped to the single call that
  raises rather than to a body that also builds the config. `test/docs/rescue_inventory_test.exs`
  is the recurrence guard: it names every permitted site, requires a `# PERMITTED RESCUE`
  comment beside each, and fails a rescue that covers more than one call — which is the
  defect itself, gated rather than remembered.

- **The decision log is reopened, and every decision is pinned to a test** (#275, for #176).
  `spec/08-decisions.md`'s last edit was 2026-08-31; **fifteen decisions were made in the
  three weeks after it**, in commit messages and moduledocs where nobody looking for the
  project's reasoning would find them — and one of them contradicted D-18 in the same file
  that cites it. Four decisions are amended (D-7's tagged delivery, D-10's `allow_incomplete`
  escape hatch, D-17's refusal to compare a truncated run, D-18's `desc:` surface), five are
  backfilled as D-28–D-32, and **all thirty-two now carry a `Pinned by:` line** naming the
  test that would fail if they were reversed — or `nothing — <why>`, because "unguarded by
  decision" and "unguarded by oversight" read identically in an empty space.
  `test/docs/spec_decisions_test.exs` holds the two to each other, and `spec/README.md` states
  the three rules that keep this specification true.

- **`mix tla.stamp --check` exits 1 on a stamp nothing verifies — and there is deliberately
  no CI job for it** (#311, closes #173). The task exited 0 whatever it found, so it could
  never gate, while its own documentation calls a stale stamp a compile error and the task
  "part of the layer's surface, not a convenience".

  `--check` asks one question of each mapping: **is this stamp verified by something?**

  | status | verified by | `--check` |
  |---|---|---|
  | `:fresh` | this task | 0 |
  | `:stale` | this task — and it is **wrong** | **1** |
  | `:unstamped` | nothing to verify; the mechanism is opt-in | 0 |
  | `:unverifiable`, spec source not on disk | nothing | **1** |
  | `:unverifiable`, `spec:` not a literal alias | **the compiler** | 0 |

  **The last row is the finding, and it reverses what #173 assumed.** A `spec:` given as
  `@an_attribute` cannot be resolved by a task that reads source without compiling — but
  `use TLA.GenServer` has the attribute's *value* at expansion, so the compile-time check
  verifies that stamp completely. Measured: `attribute spec, stale stamp:
  {:raised, TLA.Spec.CompileError}`. Failing a build on it would be a false finding about a
  mapping that is fully checked, and a false finding is how a check gets turned off.

  **No CI job**, which is the other half of the answer #173 expected a yes to. A stale stamp
  is *already* a compile error and a spec with no source on disk *already* emits a compile
  warning, so on any project compiling with `--warnings-as-errors` both arrive from the
  `compile` job, earlier and with a better message. A job here would be a second way to
  learn the same thing and a second place to keep in step.

  So the flag serves the workflow the compiler **cannot**: the project does not compile, and
  a script wants the list of mappings to re-read rather than one error at a time — which is
  the situation this task reads source for. It is a flag and not the default because a
  *report* that exits non-zero by default breaks every script that runs it for the numbers.

  D-37 records all of it; `CLI_SURFACE.md` loses its "NO EXIT-CODE TABLE" note.

- **`mix tla.stamp` is exercised end to end, over a tree with all four statuses** (#308, for
  #173). It is the only code in the project that rewrites files under `lib/`, and it was the
  least covered thing in it: **replacing `run/1`'s entire body with a raise left the full
  suite green**. The tests called `states/0` and `module_index/0` and nothing else.

  Two of its four statuses had **never executed**. The real corpus holds only fresh and
  unstamped mappings, so collapsing `:stale` and `:unverifiable` into `:fresh` stayed green
  and the coverage gate did not move, because the golden records `stale 0`. Dead with them:
  both `detail/1` clauses, the "N stale" report, and the documented stale-wins-over-fresh
  precedence. Nor could the corpus hold them — a genuinely stale stamp in a compiled tree is
  a **compile error**, which is what the mechanism is for.

  So `test/fixtures/stamp/` is a tree outside every path the task scans and outside
  `elixirc_paths` in every environment: inert, invisible to this repository's own report,
  and unable to fail a build once #311 makes the task gate. `run/2`, `states/1`, `sources/1`
  and `module_index/1` take their roots as an argument for exactly that — a seam, said to be
  one, rather than a `--root` flag added to the CLI for a test.

  It also separates two defects that look alike: `unverifiable.ex` **reaches** `state/2` and
  is given a status, because its spec's source is not in the index; `dropped.ex` never gets
  that far, because `spec_of/1` returns `nil` for a `spec:` that is not a literal alias and
  the mapping is silently rejected. The second is #173's defect 4, pinned here so that #310
  changing it is deliberate.

- **`mix tla.stamp` scans the project's own `elixirc_paths` rather than hardcoded
  directories** (#308). It appended `["lib", "test", "ledger"]` unconditionally — `ledger`
  is a directory of *this* repository, and a consumer running the task got it scanned along
  with their whole `test` tree. `elixirc_paths` is the project's own answer to "where is my
  code", and for this repository it already names `ledger` and the compiled test trees:
  measured before and after, the same 95 sources and the same 8 mappings.

- **The refinement mapping says how a message is delivered, and a cast's completion has an
  answer** (#305, for #172). A decision, not yet a change to the driver — D-36 records it
  and #306 builds it.

  `TLA.GenServer.Driver` sends `{:"$gen_call", …}` unconditionally and is the sole delivery
  path for both Layer 3 drivers, while **spec/05's own canonical refinement maps
  `:complete_transfer` and `:reset` to `handle_cast/2` clauses**. Delivered as a call those
  reach `handle_call/3`, which has no matching clause: the server dies and replay reports
  `:not_followed` — "the spec permits a behaviour the implementation does not take" — about
  a server that is correct. The gap is exactly *checked but not drivable*, and it went
  unseen because the whole conformance corpus uses `handle_call` only.

  **The contract.** `concrete_message/2` may return `{:call, msg}`, `{:cast, msg}` or
  `{:info, msg}`; a bare term is a **call**, which is what it has always meant, so no
  existing mapping changes. The one ambiguity a tagged tuple introduces has a total escape:
  `{:call, {:cast, 5}}`. Inferring delivery from which callback matches was rejected — it
  guesses, and a message two callbacks both match is not decidable that way — and declaring
  it spec-side was rejected as the wrong layer.

  **When a cast is done**, which is the hard half: a **monitored `:sys.get_state/2`**. It is
  a system message through the same mailbox, so it is ordered behind the cast just sent.
  Cost stated rather than asserted: **no additional round-trip**, because both drivers
  already make that call once per step to map the post-state through `abstract/1`. All four
  outcomes stay distinguishable through the monitor, and a cast that never completes is a
  named `{:timeout, ms}` finding, never a silent "followed".

  Both reader-facing documents carry a "not yet delivered (#306)" marker, and
  `test/docs/delivery_contract_test.exs` holds spec/05, `usage-rules.md` and D-36 to the same
  forms — **and asserts the driver still sends a call unconditionally**, so the marker
  cannot outlive the work it points at. The gate retires itself when #306 lands.

- **`usage-rules.md`, and it is gated** (#292, for #181). The project's own governing
  instructions require one and there was none — but the convention is the weaker half of
  the case. The stronger half is that **the DSL is written in Elixir syntax and is not
  Elixir**: assignment is a next-state relation rather than a rebinding, `^name` is a
  constant rather than a match pin, `require` is an enabling condition rather than
  `Kernel.SpecialForms.require/2`, and guard order decides between a disabled action and a
  raised `DomainError`. An agent writing what looks like ordinary Elixir gets each of those
  wrong, and each is a compile error or a silent misspecification. It ships in `files:`, so
  `mix usage_rules.sync` can pull it into a consumer project.

  A usage-rules file is a hand-written enumeration of what the compiler refuses, which is
  this project's most familiar defect class — so it is held to the generated goldens rather
  than trusted. Every diagnostic kind it names must exist in `DIAGNOSTICS.md`; every `D-n`
  it cites must exist in `spec/08-decisions.md`; every `mix tla.*` it tells a reader to run
  must be one the package ships, per `CLI_SURFACE.md`. Its six code blocks are under the
  guide harness like every other page. Demonstrated red by citing `:no_such_diagnostic`
  and `D-99`.

- **`--fingerprint-bits` is refused at the flag, and the two outcomes nothing exercised are
  gated** (#314, closes #180).

  `--fingerprint-bits 7` was passed straight through and raised an `ArgumentError` from
  inside `TLA.Fingerprint`, **mid-search**, with a stack trace through the checker — a typo
  in a flag reported as though the library had failed, after the run had begun.
  `--workers` was already refused at the flag; this is the same treatment, and the widths
  are **asked of `TLA.Fingerprint`** rather than restated, so a width added there cannot
  leave the flag refusing it. Neither flag's refusal had a test; both do now.

  **The other two items were not what they were filed as, and the difference is the point.**
  #180 called `mix tla.simulate`'s exit 2 "produced by nothing" and the "server did not
  answer" path "entirely dead, reachable by nothing". Measured before writing anything:

      TLA.Simulation.run(SlowServer, %{timeout: 200})
      #=> {:error, "no reply within 200ms"}

  Both were **reachable and untested** — a coverage gap, not dead code. The code was right
  and nothing held it to that. So nothing was deleted and nothing was added to make them
  reachable; they are gated, by two routes each, with the measurement recorded beside them.

  The task-level exit-2 test goes through an `init/1` that declines rather than through the
  timeout, because `mix tla.simulate` has no `--timeout` switch and a task-level timeout
  test would wait the default five seconds per step. Adding a flag to make a test convenient
  is surface this issue did not ask for; the timeout path is gated at the library level,
  where it runs in 200ms.

- **`--against-graph` is one suite, under the options it was given** (#313, for #180). It
  ran the full simulation and then **another `--traces` simulations**, one at a time — so
  `mix tla.simulate X --traces 10000 --against-graph` ran twenty thousand traces.

  Worse than the cost: the second suite forwarded **two of the five** options. So
  `--unrestricted --against-graph` audited containment with the **restricted** generator
  while printing that every state was a node and every step an edge; `--inject-failures` and
  `--concurrency` were dropped the same way; the two suites disagreed about the step default
  (25 and 20); and their seeds were derived differently, so *"trace 7 (seed 8)"* named a
  trace the reported seed does not produce at index 7.

  **The graph is built once and handed to `TLA.Simulation` as `against_graph:`**, which
  holds each behaviour against it as that behaviour finishes — beside the temporal check,
  which is the other per-trace question. That is the proposal's second option, and it is the
  larger change it predicted: forwarding, the step default and the seed derivation are not
  three fixes but one, because **one suite cannot diverge from itself**.

  Leaving the graph is its own outcome, `:left_the_graph`, and **not** a conformance
  violation: the implementation may have been right and the *search* wrong, which
  `TLA.Conformance`'s `:checker` class is for and which no other gate in the project can
  produce. Collapsing it into `:violation` would name the implementation for a defect that
  may be the checker's.

  It also settles defect 3's second half at no extra cost: the old check wrote to the shell
  **before** the format dispatch and could exit before reaching it, so `--format json`
  produced an unparseable file and a discarded result. With the check inside the one suite
  there is no earlier writer, and the finding's class travels in the JSON.

  The tests assert on the **config a trace runs under**, never on the report — the old
  second suite printed "every state a node and every step an edge" while using the wrong
  generator, so the output was exactly what could not be trusted.

- **`mix tla.visualize --max-states` bounds the search, and a partial picture is never drawn**
  (#312, for #180). The flag was read **after** `explore/2` and gated rendering only, so the
  search ran to the checker's own million-state default — exploring up to a million states in
  order to draw none of them, and, where that default was *reached*, drawing a truncated
  graph with **no note and exit 0**, as though it were the whole state space.

  It bounds the search now, and means here what it means in `mix tla.check`: **how many
  states to explore**, and therefore at most how many to draw. That removes the truncated
  render by construction rather than by adding a note — past the limit there is nothing to
  draw, so a graph that *is* drawn is one the search finished.

  **The refusal exits 2**, not `Mix.raise`'s 1. `1` here already means "the spec has a
  violation", and a graph too big to draw is not a finding about the spec; `2` is
  `mix tla.check`'s code for a run that did not see the whole state space, which is exactly
  what this is.

  **The cost is stated rather than hidden.** The moduledoc promised the refusal "names the
  real count", and a bounded search cannot know it — so the message says **more than N**.
  That promise was already unkeepable: the number it printed on a truncated run was the
  truncated one. Measured, including the overshoot the message must not present as a census:

      limit=500      status=:max_states_reached  states=502   refuses
      limit=1000000  status=:ok                  states=1000  refuses

  Both rows refuse, which is why the truncated-render case disappears. The existing test
  asserted `"4 states"` — the size of the whole state space, knowable only because the task
  searched all of it to draw none of it — and now asserts that the number is not offered as
  a census.

- **`mix tla.stamp --restamp` handles two stale mappings in one file, and reports a mapping
  it cannot read rather than dropping it** (#310, for #173).

  **The crash.** `rewrite!/1` uses `String.replace/3`, which replaces every occurrence by
  design — and two mappings in one file sharing a stale stamp is the **normal** case, since
  they share a spec and therefore a resolution. `restamp/1` still iterated per mapping, so
  the first call fixed both and the second found nothing: `Mix.raise` told the user to
  hand-edit something already done, and **aborted before any other file was processed**,
  which is most of the damage. It is one rewrite per `{file, stamp}` now, and the guard on a
  stamp that genuinely is not there is unchanged.

  It **refuses rather than guessing** where a group's members resolve to different versions
  — an 8-hex collision between two specs in one file. Rewriting them together would stamp a
  mapping against a spec nobody read it against, and `--restamp` records a judgement rather
  than performing one.

  **The silent drop.** `spec_of/1` returned `nil` for a `spec:` that is not a literal alias
  — `@an_attribute`, a variable, anything the AST does not name outright — and `mappings/1`
  rejected the entry, so the task could go on to say "no refinement mappings found" about a
  file it had read and could not understand. That collapses *cannot be checked* into
  *absent*, which is the one thing the four-status design exists to prevent. Such a mapping
  is `:unverifiable` now, and because `:unverifiable` has two causes calling for different
  fixes, the report says which:

      (spec source not found)
      (spec: is not a literal alias, so it cannot be resolved without compiling)

  Both demonstrated red, and every `--restamp` test works on a **copy** — one that let the
  task rewrite the committed fixture would pass once and then run forever against a tree
  with no stale stamp in it.

- **A bounded run stops accusing, and `--strict-coverage` fails on exactly what the
  specification gives it** (#307, closes #167). Three defects, one theme: a run that did not
  see the whole state space may not make a negative existential claim.

  **The rule, applied once instead of four times.** *No reached state satisfied P* is not
  supportable by a bounded run — it cannot tell a P nothing satisfies from a P whose states
  it never got to. #128 established that for `never_enabled` and it stopped there. The other
  three are the same claim in different words: `:antecedent_never_held`, an invariant with
  zero near misses, and — not named in the proposal — `:consequent_always_held`, which is the
  same claim with the quantifier turned around. All four now downgrade together, and each
  keeps its line in the report carrying the qualifier `never_enabled_line/2` has used since
  #128.

  **`:constrained` meant "a constraint was configured", not "a constraint bound this run".**
  An exhaustive run whose `state_constraint` never fired was reported as unverified, exited
  2, and flunked `assert_spec` — over a predicate that turned out to exclude nothing. The
  coverage accumulator now counts `held_back` where the constraint is applied, so it merges
  correctly under `--workers`, and both the status and `bounded_by` are derived from it.

  **`--strict-coverage` failed on two conditions the specification never gave it.** spec/04
  scopes it to a never-enabled action or a vacuous property and says of `terminal_but_live`
  that it is "not an error — nothing is cut"; the code also failed on that and on zero near
  misses. The root cause was one predicate answering two questions, so they are now two:
  `clean?/1` decides whether there is anything worth **printing** — including the two that
  are not errors — and the new `strict_failures/1` decides what may **fail a build**.

  **The specification's own worked example was the witness.** It renders
  `type_ok … <-- never at risk` and then counts **2 warnings**; the code counted three and
  failed on all of them. The footer now counts `strict_failures/1`, so the sentence beside
  the number — "fail on these" — is true of the things counted. `test/tla/bounded_accusation_test.exs`
  reads the number **out of spec/04** rather than restating it, so an edit to the example
  moves the test.

  All three demonstrated red.

- **An action the server takes by cast is drivable, not merely checkable** (#306, closes
  #172). `TLA.GenServer.Driver` sent `{:"$gen_call", …}` unconditionally and is the sole
  delivery path for both Layer 3 drivers, while **spec/05's own canonical refinement maps
  `:complete_transfer` and `:reset` to `handle_cast/2` clauses**. Delivered as a call those
  reached a `handle_call/3` with no matching clause: the server died and replay reported
  `:not_followed` — "the spec permits a behaviour the implementation does not take" — about
  a server that is correct.

  `Driver.deliver/3` now routes by the form the mapping returns — `{:call, msg}`,
  `{:cast, msg}`, `{:info, msg}`, and a bare term is still a call, so no mapping written
  before this changes. D-36 has the reasoning; `call/3` remains, as `deliver/3` with a
  `{:call, …}`.

  **A cast completes at a `:get_state` system message sent straight after it**, which rides
  the same mailbox and is therefore ordered behind it. Sent raw rather than through
  `:sys.get_state/2`, because that function *exits* the caller on a timeout or a dead
  server and turning an exit back into a value needs a `try/rescue` — which is what this
  module exists to avoid. So all four outcomes stay ordinary messages: a violation raised in
  `handle_cast/2` kills the server and arrives as the same `:DOWN` a call's would, and a
  cast that never completes is a `{:timeout, ms}`, a named finding rather than a silent
  "followed". A driver that moved on unconfirmed would compare the next action against a
  stale state, which is #168's defect class.

  **The compile-time round-trip strips the route before asking**: `abstract_action/1` is
  given what the callback will receive, not the envelope. Without that, a mapping correctly
  saying `{:cast, :done}` round-trips to `:unmapped` and `validate!/3` reports the action as
  one the mapping can never produce — a false finding about a correct mapping, worse than
  the gap it was added to close. It was the first thing that broke.

  **The corpus entry is the substance, not a nicety.** This went unseen because every entry
  in the conformance corpus was `handle_call`-only — a corpus that cannot express a case
  cannot find a bug in it. `Impl.HandlesACast` takes `done` by cast and `reset` by info,
  keeps `increment` a bare term, and is exercised by **both** Layer 3 drivers, since they
  share one delivery path and a fix tested through one proves nothing about the other.

  Demonstrated red: routing casts back to calls fails the new entry with exactly the
  `:not_followed` above.

- **A lasso replays, and a step that names no spec action no longer crashes the replay**
  (#304, closes #168). Two defects in `TLA.Replay`, both on the path from a counterexample
  back to the code — the loop that makes the three layers one tool.

  **The splice.** `ModelChecker.Liveness` builds a cycle with
  `Trace.from(graph, List.last(prefix.states), cycle_steps, [])`, and `Trace.from/4`
  prepends the state it is handed, so the cycle's first state **is** the prefix's last.
  `to_behavior/1` compensates; `splice/2` adjusted `length` for it and **not the list**
  (#212 saw half of it). `drive/5` zips actions against `Enum.drop(states, 1)`, so from the
  first cycle step onward every action was compared against the state *before* it, and
  **every genuine liveness counterexample replayed as `:not_followed`** — which reads as
  "the implementation does not take a step the spec permits". A true finding, inverted.

  It survived because the only lasso in the suite was hand-written and `Trace.validate/3`
  **rejects it**: `length: 1` on a two-state prefix, a one-state cycle carrying an action,
  and a cycle head that is not the prefix's tail. The liveness checker cannot emit that
  shape, so the one test covering the splice exercised something the defect could not
  reach. `test/conformance/replay_edges_test.exs` replaces it with a lasso the checker
  **actually returns**, asserted to pass `validate/3` before it is used for anything.

  **The steps that name no action.** A trace's `actions` are not all actions of the spec:
  `TLA.Simulation` writes `:unmapped` for a message the mapping does not model — its
  *normal* answer — and `:restart` for a crash injected by `--inject-failures`.
  `concrete_message/2` has no clause for either and `drive/5` called it regardless, so the
  advertised simulation-to-replay handoff died with a `FunctionClauseError` on its ordinary
  input.

  `:unmapped` is **skipped, and the skip is reported** — in `skipped`, in the human report
  and in `--format json`. Its claim is that the message does not move the abstract state,
  so the behaviour is unchanged by it; the trace's post-state is still checked, so a step
  that claims to stutter and moves is still a finding. A replay that skipped steps and
  exited `0` would be the defect one door over.

  `:restart` is **reproduced**: the server is killed (`:kill` — a crash runs no
  `terminate/2`) and started again, and recovery is checked to land where the trace says it
  did, which is all `TLA.Simulation` checks about a restart either. Skipping it would
  compare the next step against the pre-crash state. Only for a server this module started:
  with `:server`, the replay refuses with the new `:not_replayable` status and
  `mix tla.replay` exits **3** — a limit of the replay, not a claim about the
  implementation, which is why it is not `2`.

  spec/05 documents both, the new exit code and the `skipped` field; all four defects were
  demonstrated red first.

- **One invariant for a behaviour trace, held by every producer** (#303, for #168). The
  rule — *n states, n - 1 actions, one binding each, `length` == n* — was already written
  down, as a private `misaligned?/1` in `TLA.Trace`, and checked at three **consumers**:
  `from_json!/1`, `to_diffs/1` and `legal_steps/4`. Two producers broke it and neither said
  so. It is now `TLA.Trace.well_formed/1`, public and documented, asserted by
  `well_formed!/2` where a trace is **built** — which is where the fix goes, and what the
  message names.

  `to_diffs/1`'s own comment records what checking it at the far end cost: the misalignment
  was found when `Trace.Explore` and `format/1` disagreed about one trace, three modules
  from where it broke, after `format/1` had rendered three states of four and marked the
  wrong one `[VIOLATION]` (#204, #260).

  **`TLA.Simulation`'s restart path** pushed a state with no action on a *successful*
  restart while its own violation branch pushed all three. Both branches now record the
  same step — a `:restart` from the pre-crash state to where recovery landed — and differ
  only in the verdict about it. It had to be the success branch that was wrong: a run that
  ends in a violation stops, and a run that recovers goes on to take more steps against
  `hd(states)`.

  **`TLA.GenServer.Runtime.record_initial!/3`** recorded a transition from the initial
  state to itself, and `record_transition!/4` seeds an empty buffer with its own `pre` — so
  the first state landed **twice** and every recorded behaviour opened with a step that
  never happened. Fed to `TLA.Conformance.against_graph/3` that self-step classifies as
  **forbidden**, and the layer's two behaviour sources therefore disagreed about the same
  server. It seeds the buffer now.

  The conformance corpus could not have caught the second one: its counter spec has
  `reset`, which from the initial `%{count: 0, status: :ready}` is a **self-loop**, so the
  duplicated state was a legal step of that spec. `test/conformance/behaviour_invariant_test.exs`
  adds `Ratchet`, which has no self-loop anywhere — with a test asserting that, since it is
  the property the central test rests on.

- **`use TLA.GenServer` compiles for a server with no author-written `init/1`, and every
  moduledoc example is compiled** (#301, closes #171 with #300). #300 fixed the injection
  for Layer 1 and left Layer 3, where the same defect is **unconditional** — so
  `TLA.GenServer`'s own front-page example was still broken after the fix that was supposed
  to close it. Layer 3 now filters its wrapped set through `Module.defines?/3` the same way
  Layer 1 does.

  It was found by the gate filed in the same work item rather than by a reader.
  `test/docs/moduledoc_examples_test.exs` extracts every `defmodule … end` from a doc string
  under `lib/` and compiles it in its own process, observing the exit. Nothing was checking
  these: the guide harness globs `guides/`, `spec/` and `README.md` (#281), doctests cover
  six modules, and the options gate reads option lines rather than code — a `defmodule` in a
  moduledoc was checked by nothing at all, which is how the first thing a reader copies
  could be wrong for as long as it had existed.

  A moduledoc has nowhere to carry a `<!-- tla:… -->` annotation, so **compiled is the
  default and the exceptions are listed with reasons** — one, `TLA.Invariant.Test`, because
  compiling an `ExUnit.Case` module outside a running ExUnit raises and it is the reader's
  suite that runs it. Forgetting an exception reddens the build; a skip naming a file with
  no example reddens it too, and the corpus size is asserted, so an extraction that silently
  found nothing cannot report green.

- **A GenServer with no author-written `init/1` compiles** (#300, for #171). It failed with

      ** (ArgumentError) cannot make function init/1 overridable because it was not defined

  from `Module.make_overridable/2`, naming neither this library nor the cause. Layer 1 hit
  it for any module with an invariant that applies to `init`; `use TLA.GenServer` hit it
  **unconditionally**. The example at the top of `TLA.Invariant`'s own moduledoc — the
  first thing a reader copies — produced it.

  `@on_definition` keeps the wrapper off the defaults `use GenServer` injects, which is
  what the moduledoc claimed, and it is true of `handle_call/3`, `handle_cast/2` and
  `handle_info/2`: GenServer injects those in `__using__`, **before** the hook is
  installed. `init/1` it injects from its own `@before_compile`, **after** — so the hook
  does see it, `{:init, 1}` lands in the wrapped set for a module that never wrote one, and
  by then GenServer's `defoverridable init: 1` has moved it out of the definitions table.

  The wrapper is now emitted only for a callback still in the definitions table when
  `__before_compile__/1` runs — which is exactly what `make_overridable/2` requires, asked
  as a question rather than assumed, so it is right for any future callback of the same
  shape rather than a special case for one name. The moduledoc says what is true of
  `init/1` instead of denying the case.

- **`spec/05` says which of its five rules are decided at compile time, and the probe draws
  every binding it can** (#298, closes #169). The section listed five rules "checked, in
  order" and said a mapping not covering the spec's variables "fails the build". Neither
  was true: rules 1 and 2 need a concrete state and are enforced per-callback at runtime,
  rule 5 runs only where the optional `concrete_message/2` exists, and rule 4 was sampled.
  The section now sorts them into decided, probed and deferred, and says why each is where
  it is.

  **The widen-or-not question #169 refused to assume, measured** (D-35). The probe took
  three bindings from the first `init` block; across the differential corpus every action
  has between **0 and 4** bindings at an initial state, because the sets a param draws from
  are mostly empty there — no messages in flight, no locks held — and every corpus spec has
  exactly one `init` block. So the cap was excluding at most one binding per action and the
  first-block restriction nothing at all. It now draws every binding every `init` block
  yields, which is every binding a compile can construct.

  It remains a **probe**, and the specification says so: bindings reachable later in the
  search cannot be drawn without running the search, and running it at compile time is not
  a probe — it is the check the model checker already performs.

- **The deferred list reaches the user, and constants are resolved the way everything else
  resolves them** (#297, for #169). `TLA.Refinement.validate!/3` is built to return the
  rules it could not decide — its moduledoc argues that "a validator that claims a check it
  cannot perform is worse than one that states its limits" — and the only call site was
  `{:ok, _deferred} = …`. A user whose mapping has no `concrete_message/2`, so action
  coverage was **never checked at all**, was never told.

  `use TLA.GenServer` now says so at compile time. Each deferred entry is tagged
  `:always` — the two rules that need a concrete state, deferred for every mapping — or
  `:for_this_mapping`, and only the second kind is printed. A compile-time message that
  fires on every module is one a reader learns to skip, which would leave the ones that
  matter unread.

  It is a **message and not a warning**, and the distinction is load-bearing:
  `concrete_message/2` is an `@optional_callbacks` entry, so a mapping without one is
  legal and documented — it can still be *checked* as it runs, it just cannot be *driven*.
  `IO.warn/2` participates in `--warnings-as-errors`, which this project uses and many
  consumers do, so warning there would turn a documented choice into a build failure. The
  first version of this did exactly that, and the `compile` job caught it on this
  repository's own `Undrivable` fixture.

  **Constants:** `validate!/3` fell back to `default_constants/1`, which reads `default:`
  options and nothing else, so a spec assigned through
  `config :extla, MySpec, constants: %{…}` was validated against the **empty** assignment
  and reported its rules as decided. `resolve_constants/1`, which merges config, was in the
  same module and simply not the one called.

- **The TLA+ primer says which parts of TLA+ this library implements** (#293, closes #181).
  1321 published lines with **zero** occurrences of "ExTLA", shipping in the package and
  rendering in the sidebar, with chapters on PlusCal, TLAPS and TLC's distributed mode. A
  reader arriving from the sidebar got TLA+ with nothing indicating which parts this tool
  implements, which it does not, and which are translated into Elixir syntax.

  The header now says all three, and points at the tutorial for the mapping and at
  `usage-rules.md` for the rules. PlusCal gets the accurate framing rather than a claim the
  project does not make: it is TLA+'s algorithm language and ExTLA's DSL fills that role in
  Elixir, so those chapters describe a notation this library does not use. Not being a
  theorem prover **is** a stated non-goal, and the header cites `spec/06` §Non-goals for
  it — which `test/docs/primer_framing_test.exs` holds to that section, so a non-goal
  becoming a goal cannot leave this page contradicting the architecture.

  **#291's gate caught two real defects while this landed**, which is the first work it has
  done since it was turned on: `usage-rules.md` shipped in `files:` but was not an ExDoc
  `extras:` page, so the primer's link to it would have 404'd in the published docs; and
  the usage rules linked `DIAGNOSTICS.md`, which is a generated golden this repository
  keeps and **does not ship**, so a consumer following it would find nothing. The first is
  fixed by publishing the page, the second by pointing at `spec/02` §7 — the table a
  consumer actually receives.

- **Strong fairness is refuted by something, and the parallel search is asserted to be
  parallel** (#296, closes #174). Two mechanisms that were only ever exercised in the mode
  where they cannot be told apart from their own absence.

  **The Emerson–Lei split.** `weak_break/3` breaks a cycle only when the action is enabled
  in **every** state of it; `strong_break/3` when it is enabled in **any**. That difference
  is the whole of what makes SF stronger than WF, and every strong-fairness fixture was
  only ever *verified* — which `[]` achieves just as well, so replacing the strong branch
  with `[]` left the suite green. Two specs now differ by the single word `:strong`: an
  action enabled intermittently in a cycle and never taken is **fair** to WF (the run is
  refuted) and **unfair** to SF (the run verifies). The verdicts differ, so the branch is
  the only thing that can be producing them.

  **The parallel path.** Setting the batch count and the concurrency both to 1 made
  `TLA.ModelChecker.Parallel` fully sequential and left everything green, because every
  parallel test asserts agreement with the single-threaded result — which a sequential run
  satisfies perfectly. Agreement is the right property and it cannot see this. `batches/2`
  and `concurrency/1` are now `@doc false` and asserted directly: 200 states over 8 workers
  is 64 parts, every state in exactly one, and the stream is told to run that many at once.

  What that establishes is stated in the test rather than implied: the work is **divided**
  and the stream is **told** to run the parts concurrently. It does not establish that the
  scheduler ran two at the same instant, and no deterministic test can — a timing assertion
  would be a flake on the two-core runner rather than a gate.

- **Two checks that could not fail** (#295, closes #175). The Agent layer's `cast/3` and
  `TLA.Invariant.Test`'s `assert_invariants/2`, each proven by #175 by mutating the code and
  watching the suite stay green, and each now demonstrated red against that mutation.

  **`Agent.cast/3`** is the path D-25 is about — an `Agent`'s checks dispatch at runtime and
  `:disabled` still costs a call there — and replacing its `check_agent_step!/3` with a
  plain call left everything green. A cast is asynchronous, so a violation arrives as the
  agent's **exit**, which is exactly what made the check invisible unless something watches
  for it; the test now does.

  **`assert_invariants/2` passed for every state** when a module's invariants had all been
  compiled out by `enabled: false`. `__tla_names__(:invariant)` is then `[]`, the
  comprehension over it is `[]`, and `[]` means "nothing failed". A project that compiles
  its invariants out in production and runs this assertion in a release test was asserting
  nothing, including about the states the invariant existed to reject. It now raises, naming
  `enabled:` as the usual cause. **`refute_invariant/3` has guarded its equivalent since it
  was written**, through `__ensure_declared__/2`; the asymmetry between the two was the
  finding.

  `assert_action_properties/3` had the identical hole and is fixed with it — #175 named only
  the first, and fixing a mechanism at the site it was found is how it goes on running one
  door over (#192).

- **The docs build fails on an unresolved reference** (#291, closes #156). The `docs` job ran
  `mix docs` with no `--warnings-as-errors`, so it emitted warnings and **exited 0** — ten
  when #156 was filed, **34** by the time it was implemented. `TLA.DocLinks` cannot see them:
  it scans `@href` in the built HTML, and an unresolvable reference emits **no href at all**,
  rendering as plain inline code. A gate that cannot see the surface the defect lives on,
  which is #102's shape one door over.

  **Three of the thirty-four were introduced by #277, two days after D-30 was written, by the
  same hand.** Its moduledoc table named `Liveness.shape/4`, `GenServer.Runtime.verdict/3` and
  `Coverage.walk/3` in the backticked autolink form that D-30 exists to forbid, and nothing
  said so. D-30's `Pinned by:` promised this gate; it now names it.

  The thirty-four fell into four classes and each got the treatment its class calls for:
  **wrong markup, right name** — a callback needs `c:`, a type needs `t:`, and
  `Kernel.require/2` is `Kernel.SpecialForms.require/2`; **prose about an internal**, which
  D-30 governs, reworded to name the function plainly; **removed or renamed functions in
  `CHANGELOG.md`**, which D-30 explicitly exempts — *a changelog that cannot name a removed
  function cannot record a removal* — implemented as
  `skip_undefined_reference_warnings_on: ["CHANGELOG.md"]` rather than by rewording history;
  and **references to filtered modules**, for which ex_doc offers no exemption (its warning
  is unconditional) and which D-30's *main* rule covers anyway, since a module
  `filter_modules` keeps out of the published docs is an internal from a consumer's point of
  view.

- **Eight assertions that held when the code under test was broken** (#294, for #175). Each
  was proven by #175 by mutating the code and watching the suite stay green, and each fix
  here was demonstrated red against that same mutation — a fix nobody saw fail is the
  defect one layer up.

  Two were unreachable by any ordinary assertion, because both are claims about a wrapper
  that is **absent**, and absence leaves no trace. Under `telemetry: :verbose` it does: a
  wrapped callback emits `[:extla, :invariant, :checked]`, so "this callback was not
  wrapped" becomes "this callback fired no event" — a positive observation. So
  `callbacks: [:handle_call]` is now proven by a `cast` firing one event and a `call`
  firing two, and "no wrapper on the callbacks `use GenServer` injected" by an unhandled
  message reaching the injected `handle_info/2` and contributing nothing. The old test
  asserted `refute function_exported?(Account, :handle_continue, 2)` — and `use GenServer`
  does not inject `handle_continue/2` at all, so it held whatever the wrapper did.

  The rest: the symmetry warning the test was **named** for is now produced and asserted
  (it asserted two factorials, which hold whatever the warning does); the diagnostic's line
  is compared against where the offending token actually is, not `is_integer/1`, which line
  0 satisfies — and this is the only line assertion in the tree; the three violation-message
  tests assert the **rendering**, since a `defexception` with no working `message/1` falls
  back to inspecting the struct, which embeds every substring they were checking; the guide
  link gate asserts its corpus is non-empty, since renaming `guides/` left 57 of 58 tests
  green; the sorted-tree test compares the tutorial's order against a list it builds itself,
  because `Path.wildcard` already sorts and deleting the production `Enum.sort` stayed
  green; and one test was simply **misnamed** — it promised a per-binding vacuity report and
  asserted that nothing is reported, while the per-binding message is asserted by the nested
  quantifier test #200 added later.

- **The slug cross-check runs, for the first time** (#290, for #156). It validates
  `TLA.GuideCheck.Links.slug/1` against ExDoc's **real generated ids** — the only thing
  proving the link gate's anchor model is true, since every `#heading` link in every guide
  is accepted or rejected by that reimplementation. It lived in `test/guides/`, and the
  `docs` job — the only one that builds `doc/` — runs `mix test test/docs`. So in CI it
  always took its `else` branch, asserted that a directory does not exist, and passed.

  Its author had anticipated vacuity and written a guard; the guard fired on the wrong
  condition, so the check reported green for never having run. **This project's own
  cardinal sin, arriving through directory layout rather than through anyone's decision.**

  It now lives in `test/docs/slug_model_test.exs` with `@moduletag :needs_built_docs`
  (#217's mechanism, which `package_docs_test.exs` next door already uses — and whose
  moduledoc names this very test as what it was avoiding becoming), and its guard **fails**
  on an absent `doc/` rather than passing. Run against a real build it passes, which is the
  first evidence the slug model was ever correct.

- **The guides are reachable from the README** (#289, closes #155). `README.md` contained
  **zero** occurrences of `guides/`, `getting-started` or `tutorial` while linking into
  `spec/` eleven times, and its repository-layout table listed the offline proposal tracker
  and not the guides. `mix.exs` sets `main: "readme"`, so that is the first page every
  hexdocs reader lands on, and it routed them into the normative specification — which
  `mix.exs` itself calls "the authority" that the guides link *to*.

  The front page now opens the two paths by name, `guides/` is in the layout table, and the
  command list names all **six** packaged `mix tla.*` tasks rather than three
  (`mix tla.explore` and `mix tla.stamp` were absent, as #155 found; `mix tla.visualize`
  appeared only in prose).

  `test/docs/readme_entry_test.exs` holds that list to **`CLI_SURFACE.md`**, which is
  generated from the tasks that exist and gated by `surface-drift` — so the README's list
  is a fact about the code rather than a hand-kept set that goes stale the way the install
  pin did. It also fails if the README mentions a task that is *not* packaged, since a
  reader following it could not run one.

  The README's **proportions** are deliberately untouched: the Status section runs half the
  file, sits above the reasons to use the library, and "who is this for" is never stated.
  #155 left that for its own decision on the grounds that bundling a rewrite with a
  navigation fix would stall the fix, and the gate records that rather than quietly
  adopting it.

- **The tutorial's forward chain is restored, and gated** (#288, for #155). Nine of eleven
  pages ended with a `Next:` line; `07-layer3.md` ended with a "Where to next" section
  listing four links into `spec/` — the heading `getting-started.md` uses where it genuinely
  *is* an ending — and `08-modelling.md` ended with closing prose. A reader following the
  tutorial's own navigation finished page 07 and stopped.

  The three stranded pages are how to get from a system you have to a specification worth
  checking, what to do when the state space explodes, and how to keep a specification in a
  project: the three a reader with a real codebase needs most, reachable only from the ExDoc
  sidebar, which a reader of the repository does not have.

  Both contents lists described a seven-page tutorial and now describe the eleven-page one,
  including `01-mathematics.md`, which the introduction omitted despite it being its own
  `Next:` target. The duplicated `specification.` on the introduction's fourth line is gone.

  `test/guides/tutorial_chain_test.exs` is the gate: every page but the last links forward
  to its successor in sorted order, the last links forward to nothing, and — the
  anti-vacuity half — the walk is checked to be reading the whole tutorial and parsing the
  links it finds, since both assertions are comprehensions that pass on an empty list.

- **The install instruction is current, and a gate keeps it that way** (#287, closes #154).
  `guides/getting-started.md` is the repository's only dependency snippet and it read
  `tag: "v0.1.0"` against a release of **v0.3.20** — in documentation that ships in the
  package, on a page whose next chapter teaches `TLA.Trace.explore/3` and
  `mix tla.explore`, neither of which that install has. A reader who followed it and then
  followed the tutorial would conclude they had made a mistake.

  **The mechanism was the interesting part, and it is what changed.** Every other Elixir
  block on that page is compiled and its verdict asserted; the one block exempted by
  `tla:skip reason=a mix.exs fragment, not a spec` is the one that drifted. "Not a spec"
  was true and was taken to mean "not checkable" — but a `mix.exs` fragment parses and a
  `tag:` is a string. `<!-- tla:deps -->` now holds that tag to `CHANGELOG.md`'s newest
  release heading, which is in the tree (so a shallow CI clone with no tags still checks
  it) and is, since #282, the project's record of what is built.

  The tag is **named** rather than replaced with a branch or with prose, and the guide now
  says why: a branch installs unreleased work from a library whose subject is
  verification, prose cannot be followed without leaving the page, and only a literal tag
  can be held to the release record. A version requirement is not available while Hex
  publishing is undecided.

  The other eleven `tla:skip` blocks in `guides/` were reviewed and all are genuine
  fragments — an action body, an ExUnit module, a shell invocation — with nothing in them
  that can go stale against the code.

- **No concurrent test writes VM-global state, and a gate says so** (#285, closes #283).
  `test/conformance/step_test.exs` was `async: true` and set
  `Application.put_env(:extla, :trace_buffer, 3)`. That value is read by
  `TLA.GenServer.Runtime.record_transition!/4` **in whatever process is recording**, so for
  the duration of that one test every concurrently running test that recorded a transition
  saw a 3-state buffer.

  Nothing could have failed on it: a neighbour's trace was truncated, its temporal verdicts
  became `:inconclusive`, and the assertion beside it reads
  `verdict in [:holds, :inconclusive]` — exactly what a corrupted run still satisfies. A
  passing result that meant nothing was checked, arriving through the config of a different
  test file.

  The test moves to `test/conformance/trace_buffer_test.exs`, `async: false`, unchanged —
  it is the only coverage of the bound reached through the **instrumented callback** rather
  than through `Runtime` directly. `test/docs/async_isolation_test.exs` is the guard: it
  reads each test file's **AST** (a textual scan would fail `truncated_behaviour_test.exs`
  for explaining in a comment why it avoids a global) and fails any `async: true` file that
  calls `Application.put_env/delete_env` or `:persistent_term.put/erase`.

  `Process.register/2` is deliberately not checked, and the gate says why: a per-test
  unique name is legitimate and a fixed one is not, and nothing distinguishes them without
  judgment the gate does not have.

- **A refinement mapping is probed only where a binding exists** (#286, closes #284).
  `TLA.Refinement.validate!/3` applied `concrete_message/2` to `%{}` whenever the spec's
  constants had no values — which is whenever a *published* example is compiled, since
  `constant :processes` without a `default:` is what a reader should write. For an action
  with no params `%{}` is the real binding; for one with params it is a binding the search
  would never produce, so a mapping written for the real ones matched no clause and the
  probe raised **`FunctionClauseError` from inside the validator**. Both conformance
  examples in the specification — `spec/05`'s `MyApp.TransferServer` and `spec/07`'s
  `MyApp.LockServer` — could not compile, which is how #281 found this.

  An action this call cannot bind is not evidence of a mapping that never names it, so it
  is **not probed and not reported** — D-28's rule about conclusions drawn from an
  absence, which `against_graph/3` and #278 already apply. It is not silent either: the
  un-probed actions are returned in the list of rules `validate!` "could not decide",
  which is what that list has always been for. No compiler warning, because a published
  example has no constants by design and a warning there is noise a reader cannot act on.

  It survived because **every refinement fixture in `test/conformance/` is for a spec
  whose actions take no params**, so the suite exercised exactly the one case the fallback
  handled. `test/conformance/param_mapping_test.exs` is the missing case, and the two
  specification blocks lost their `tla:skip` — so the guide harness compiles them and a
  regression reddens the gate rather than a unit test.

- **§Implementation Phases withdraws its single-source claim; the graph and §Concurrency
  describe the code** (#282, for #179). The four phases were written as a *plan* and ended
  honestly on 2026-09-01; the sentence "this section is the single source of truth for what
  is built" was added the next day, to a finished enumeration nobody was extending — so
  eight shipped features looked like omissions rather than like work done after a plan
  completed. There is **no Phase 5**: that would be a third hand-maintained list beside a
  gated one. §Phases records the plan, `CHANGELOG.md` records what is built and
  `bin/changelog_guard.exs` fails any `lib/` commit that adds no entry, and `README.md`
  now points at the gated record. D-34's neighbour in `spec/08` is not needed; the
  withdrawal is pinned by `test/docs/shipped_has_a_home_test.exs`, which asserts the eight
  orphans each have a home.

- **The five-step concurrency protocol is deleted rather than corrected** (#282). #38 built
  `Task.async_stream` and added the true description *underneath* the false one without
  removing it, so §Concurrency read as a protocol that does not exist followed by prose
  saying what does. The protocol is gone; #38's paragraphs stand alone.

- **§Storage draws the liveness structures where they live** (#282). The edge table and
  ENABLED bits were drawn inside the ETS box and are per-struct **maps** — the fact that
  *governs* D-27's refusal of liveness alongside `workers:`, admitted in prose seventy
  lines below the diagram. They are now their own box, marked not-shared, so a reader of
  the picture can derive the rule that governs the feature.

- **The module graph describes the code as written** (#282). It drew
  `ModelChecker.check_liveness/2` and `Coverage.compute/2`, neither of which has ever
  existed, and an edge from `TLA.Spec` to `TLA.Invariant` that the code does not have,
  while omitting `TLA.Temporal`'s call back *up* to `ModelChecker.enabled_bindings/3`. It
  was never a copy of the code — it is the design's module tree, from the initial commit.
  Nothing gates it, and the section now says so and states the review rule: check it
  whenever `lib/tla/*.ex` gains or loses a module, which is one `grep -l` per box.

- **`spec/` and `README.md` are under the guide harness: 76 blocks, compiled or skipped with
  a reason** (#281, for #178). Every `elixir` fence in the specification was compiled by
  nothing. Most are API sketches whose signatures `spec_api_test` already holds to
  `API_SURFACE.md`, so the gap was smaller than the count suggests — but "nothing compiles
  this" and "something else gates this" read identically in an unannotated block, and only
  one of them is acceptable. D-34 records the rule and the four kinds of skip.

  **The pass found three things nobody was looking for.** An **empty** `elixir` block stood
  in for `TLA.Profile`'s API block (§Per-declaration cost, since #130) — the section's prose
  describes `instrument/1` beside a fence containing nothing; it now carries the sketch, and
  `spec_api_test` holds it to the golden. And the two conformance examples a reader is most
  likely to copy — `spec/05`'s `MyApp.TransferServer` and `spec/07`'s `MyApp.LockServer` —
  **do not compile**: `TLA.Refinement.validate!/3` probes `concrete_message/2` with an empty
  binding, which a mapping for an action with params cannot answer, so it raises
  `FunctionClauseError` from inside the validator. Filed as #284; both blocks are skipped
  naming it, and those skips are meant to disappear.

  `<!-- tla:skip -->` with no reason is now **refused** (#215): the harness's own rule is
  that an unannotated block is an unchecked claim, and a reasonless skip is an unchecked
  claim wearing an annotation. A `reason=` also runs to the end of the annotation rather
  than to the first space — `reason=a shell invocation, and the timing is from this machine`
  had been parsed as the word "a" ever since reasons existed, which nothing noticed because
  nothing had ever asked what a reason said.

- **`spec/07`'s examples are the corpus, not a copy of it** (#280, for #178). Three corpus
  moduledocs said "Example N verbatim, which is the point: the corpus checks the
  documentation, not a paraphrase of it". `git log` dates that sentence to 2026-08-30/31
  and D-21 to **forty-three minutes later**, where it rewrote every corpus spec's types and
  left the page — so for a month the page a reader copies from declared
  `type: [^resources ~> :set]` where the checker had been given `set(^processes)`.
  `mix tla.examples --write` now generates `test/differential/<entry>/spec.ex` **from the
  page's fence**, and `surface-drift` fails on a difference; the page carries the types
  D-21 settled on.

  The drift was **invisible to the corpus itself** — TLC agrees either way, so all 88
  differential assertions passed with the wrong type on the page. That is the argument for
  a byte-level gate rather than against it: the only instrument that can see this class of
  error is one that compares the text.

  Generating code from a document is the inverse of the five goldens that render documents
  from code, and D-32's reasoning is unchanged: an enumeration kept by hand does not
  announce that it has stopped being true. Compiling the fence in place — the guide
  harness's own principle — would be purer, and #281 should revisit it; it cannot be done
  without taking three specs out of `TLA_COVERAGE.md`, since the ledger finds items by
  parsing the file the BEAM records as a module's source.

- **`spec/02` §8's worked example is pinned to the trace `spec/04` prints** (#280). Its
  `conservation_of_money` is refuted at depth 3 — deliberately: `fail_transfer` discards
  `pending`, and that *is* `04-layer2-model-checker.md`'s worked counterexample, "⚠ money
  lost!" included. Nothing asserted it, and the one test that ran the spec passed
  `invariants: []`, so "fixing" `fail_transfer` — which is what this work item was scoped
  to do — would have left §04's printed trace describing a run that no longer happens,
  with the suite green. The bare `invariants: []` is now `[:type_ok]`, which holds over the
  whole constrained space, so that test still reaches its bound while checking something.

- **The two `:checked` telemetry events that could not fire, now fire** (#279, for #177).
  `03-layer1-invariants.md` documents four success events under `telemetry: :verbose` and
  argues from that count — "a single transition can fire four of them" is the reason they
  are opt-in — while `emit_wrapper/3` passed `:verbose` to the invariant and
  action-property checks only, and `TLA.Invariant.Runtime` had no `:verbose` arity for
  `check_preconditions!` or `check_postconditions!` to receive it. The same file said so
  three hundred lines down, where its API block listed the two arities that existed: #22
  amended the block from the code and left the event table alone.
  `[:extla, :precondition, :checked]` and `[:extla, :postcondition, :checked]` now carry
  `module`, `name` and `duration` like the other two.

  A declaration whose pattern does not match the message fires **nothing**. Its clause
  answers `:no_match` without evaluating the predicate, so an event there would time a
  pattern match and report it as the cost of a check that never ran.

  `telemetry: :verbose` had **no test coverage at all** — #175's third hole, where two
  independent mutations of it stayed green. `test/tla/telemetry_verbose_test.exs` is its
  first: all four events from one transition, their metadata, the `:no_match` silence,
  the opt-in asserted under the default (and the violation events still firing there, so
  "nothing fired" cannot pass on a module that emits nothing at all), and the events held
  to the table in `spec/03` that lists them.

- **A recorded behaviour says what its buffer threw away, and a truncated one cannot
  settle a claim about its beginning** (#278, for #177). `TLA.GenServer.Runtime`'s trace
  buffer drops oldest-first — correctly; an unbounded per-process trace is a memory leak in
  any long-lived GenServer — and recorded nothing about having dropped anything. The
  bare-predicate verdict read the list's head as "the behaviour's first state", which for a
  server past the buffer is a different state, and the answer was not merely
  over-confident: a server 1,200 transitions from `n = 0` reported the property `n == 0`
  **violated**, as a settled fact, about a behaviour that satisfies it. A false refutation
  sends someone to fix a specification that is correct.

  `trace/0` and `get_trace/1` now return `%{states: [...], dropped: n}`, and
  `check_temporal_properties/2` **refuses a bare list** rather than defaulting it to
  `dropped: 0` — defaulting would have left every existing caller compiling and the
  truncated one still lying, which is the argument `TLA.Conformance.against_graph/3`
  already makes for requiring `status:`.

  **Only the bare predicate changes.** Truncation drops a *prefix*, and every other shape
  survives it: `always(P)` answers `:violated` from a retained state that really occurred,
  `eventually(P)` answers `:holds` from a retained witness, and `<>[]P`, `[]<>P` and
  `P ~> Q` need a lasso a recorded trace never has. Making `always` `:inconclusive` on
  truncation — as the work item asked — would discard a sound refutation, which is the
  honesty rule pointed the wrong way; `spec/05` now says so.

- **A temporal formula outside §5.1's fragment is refused at compile time** (#277, for #177).
  `spec/02` promised "a **compile error** naming the unsupported operator" and that "every
  rule here is enforced at compile time"; the fragment's *nesting* bound was enforced
  nowhere at compile time and three times after it. `always(always(P))` was inside the
  grammar — which was recursive — so it compiled, and then met
  `TLA.ModelChecker.Liveness.shape/4`'s `ArgumentError`, three frames into a run, carrying
  no module, declaration or line, and only under `check_liveness: true`. Under the default
  configuration the formula was never looked at and the run reported `:ok`.
  `TLA.Spec.Temporal` now carries the bound as a table and refuses the nesting at the
  declaration, and §5.1's grammar states it instead of contradicting it.

  **Two more doors were open.** `TLA.GenServer.Runtime.verdict/3` and `TLA.Coverage.walk/3`
  each enumerate the same fragment again and neither had a fallback clause, so the same
  formulas died there as a `FunctionClauseError` printing closures and no property name —
  under `mix tla.simulate`, and under the **default** configuration respectively. Both now
  say what reaching them means, which after this change is a bug in ExTLA rather than a
  mistake in the specification.

  **And one formula that was never wrong.** `always(Enum.all?(^procs, fn p -> done[p] end))`
  is `always(pred)`: a quantifier over a constant with a boolean body is an ordinary state
  predicate, as it has always been inside an `invariant`. Taken as the formula-level `\A`
  it built a shape no consumer evaluates and died in the coverage probes, by default, on a
  formula that means exactly what it looks like. It now compiles and checks.

  `test/tla/spec/temporal_fragment_test.exs` is the recurrence guard: it **derives** the
  shapes from the parser's own table and drives every one through all four consumers, so a
  fragment widened in one place goes red rather than quiet.

- **A temporal property's description reaches the counterexample a user reads** (#276, for
  #176). `temporal :x, desc: "…"` was stored by the DSL and dropped by
  `compiled_temporals/1`, so the option #160 kept — on the reasoning that accepting one
  nothing reads is the same defect as an unknown key — reached no consumer. It now travels
  all four hops, and a refutation reads `temporal property :eventually_two is refuted by this
  behaviour — the counter reaches two and does not spin forever`. One hop below it,
  **`TLA.Trace.format/1` printed the banner and dropped the `message` every finding writes**:
  an invariant's violation text, an action property's, the deadlock's pointer at
  `terminal :name do` and every liveness refutation were rendered to nobody, while
  `to_json/1` carried them out to a file the whole time. The new tests assert on the
  rendering rather than on the struct, because one reading `violation.prefix.message` would
  have passed over a counterexample that showed the reader nothing. D-33 records what
  accepting `desc:` obliges, beside D-18's map of which declarations accept it.

- **The specification's API blocks are held to `API_SURFACE.md`** (#164, closes #153). `spec/01–05`
  published **seventeen signatures and seven type shapes the code does not have** — a reader
  coding to them got `UndefinedFunctionError` on the first call — and nothing had compared the
  two, because until #271 there was no side derived from the code to compare against.
  `test/docs/spec_api_test.exs` is the comparison, in four layers: every `defmodule` fence must
  parse (two did not, and three findings hid in them); every head and arity must be a row in the
  golden; every record type's keys must match; and **no `@spec` may appear in the specification
  at all**, which is #68's decision about `lib/` applied to the document that had the same
  problem — 116 of them, wrong in seventeen places. `result()` gains `:deadlock`, `diameter` and
  `profile`, and deliberately not `:unsound_symmetry`, which is `mix tla.check`'s JSON status
  rather than one `run/2` returns.

  Writing the gate first, and running it red before correcting anything, is what made the rest
  visible: **layer 2 passed vacuously on every shape finding it exists for**, because its row
  pattern let the shape column swallow the version cell. The anti-vacuity test now counts parsed
  key sets rather than matched rows. It also surfaced two defects in the golden's own scanner —
  a `def` inside a compile-time conditional was never read, and a wide union was rendered with
  newlines inside a table cell, breaking the row.

- **`PACKAGE_SURFACE.md`, and the published docs stop advertising what the package omits**
  (#274, from #270). `mix docs` runs under `MIX_ENV=dev` because `ex_doc` is a dev dependency,
  and `elixirc_paths(:dev)` includes `ledger/` — so the published documentation carried
  **eleven modules** a consumer cannot call, TLA.Ledger and mix tla.coverage among them.
  Nobody decided to document the ledger; the docs job's environment did, and the two halves of
  the promise were checked in two environments that cannot see each other (`consumer-compile`
  compiles the package and has no `ex_doc`; the docs job builds pages and never reads
  `files:`). `mix.exs` now filters the docs on the same `files:` list the package is built
  from, `test/docs/package_docs_test.exs` joins the two, and the golden lists every dependency
  as declared beside every module and whether it ships.

- **Every compile diagnostic carries a `kind`, and `DIAGNOSTICS.md` lists them** (#273, from
  #270). `02-dsl-semantics.md` §7 tabulates the diagnostics under a preamble promising that a
  spec violating any rule does not compile, and nothing held the table to the code — nothing
  *could*, because a row's first column was a name its author chose and `TLA.Spec.CompileError`
  carried no identifier to match it against. §7 was **twenty-eight kinds short of the fifty the
  DSL raises**, and one of its rows described §6's term check, which runs at runtime, so the
  preamble was false for exactly that row. `kind` is the join key: 53 raise sites pass one, the
  golden renders them, §7's first column is the kind, and `spec_diagnostics_test.exs` holds the
  four sets — raised, rendered, tabulated, tested — to each other. Renaming one kind reddens
  five of its eight assertions.

- **`CLI_SURFACE.md`: every `mix tla.*` task's switches and exit codes** (#272, from #270).
  One row per `@switches` entry and per documented exit code, read from source, with the
  specification section that reproduces each task **found by searching `spec/*.md` for its
  `defmodule` line**. That derivation replaces `spec_options_test`'s six-entry `@documented`
  list: the set of tasks is now the directory, a task shipped without a spec section fails a
  named assertion instead of going unchecked, and a task with no exit-code table (`mix
  tla.stamp`, #173) reads as a finding rather than a blank. Generating it found two things
  the hand list could not: `mix tla.visualize`'s exit codes had never been read by anything,
  because its moduledoc is an interpolated heredoc, and `mix tla.mutants` was a task no gate
  knew about.

- **`API_SURFACE.md`: what `lib/` declares, as a surfex golden** (#271, from #270). Every
  public function head with the arities its defaults imply, and every named type with its key
  set and atom unions, scanned from source by `Surfex.SourceScan` — so the gate runs before the
  build — and rendered by `mix tla.api_surface --write`. `--check` is HARD: a signature or type
  change without a regenerate fails the build, and so does a hand edit. It is the code's side of
  the comparison the specification's API blocks are held to (#164), which had no side to be
  compared against when #153 found seventeen signatures the code does not have.

- **`spec/06-architecture.md` §Dependencies is a link, not a copy** (#274). It carried the
  `deps` block from the initial commit — a fence whose first line was the comment `# mix.exs`,
  so it announced itself as a copy — and by then listed `dialyxir`, which #69 removed, omitted
  `surfex`, which #64 added, and pinned `ex_doc` three minor versions behind. The list is now
  `PACKAGE_SURFACE.md`; what stays in the section is what a list cannot say, and the two
  claims that carry weight — zero required runtime dependencies, and dev/test dependencies are
  not transitive — are pinned by tests rather than trusted.

- **One `surface-drift` job for every golden.** `tla-coverage-drift` is renamed and runs both
  `mix tla.coverage --check` and `mix tla.api_surface --check`; the goldens #270 still owes
  join it rather than each minting a job. The three-outcome check — current, drifted, missing —
  is now TLA.Surface's `check!/4`, shared by both tasks, so there is one definition of "drifted".
  `TLA_COVERAGE.md`'s header names the new gate and was regenerated.

- **CI's compiled dependencies come from the depdep store alone** (#251, closes #250). depdep
  v0.4.0 never keyed `surfex`, a leaf git dependency, so #247 kept the `mix.lock`-keyed
  `deps`/`_build` cache beside the store to supply it — two sources of truth for one thing.
  v0.5.0 keys it (depdep#70) and hands `--compile-deps` only misses this env is known to build
  (depdep#83); the bump's own pipeline read `pulled 7, skipped 0, not for this env 6` in a job
  with nothing on disk, `surfex` included. The cache is gone; what stays cached is
  `tlc-corpus`'s tla2tools jar, a checksum-verified download.

- **A predicate that returns a non-boolean is refused, not counted as holding** (#235,
  closes #192). Every consumer of an `invariant`, `terminal`, `action_property` or temporal
  state predicate tested its result for truthiness. Elixir's `and`/`or` return their last
  operand, so `invariant :i do n >= 0 and status end` evaluated to `status` and *held* in
  every state where `status` was anything but `nil` or `false` — a run in which it never
  took the value the author meant reported `:ok`. The compiled predicate now raises
  `TLA.Spec.PredicateError`, naming the declaration and the value, wherever it is
  evaluated: the checker, trace replay, coverage, and the ledger. A spec that relied on a
  truthy return passed before and fails loudly now; that is the intended change, and the
  fix is the comparison that was missing.

- **CI restores compiled dependencies from the shared depdep store** (#246, for #245). Every
  job ran `mix deps.get` and compiled the dependency tree from source, with a `mix.lock`-keyed
  cache as the only reuse — per-project, per-runner, and a cold compile of the whole tree on
  any lock change. `scripts/depdep.exs --pull --mix-get --compile-deps` (depdep v0.4.0) now
  stands in for `mix deps.get`: restore what the store has, fetch only what it lacked, decide
  again over the git dependency once its source is on disk, compile exactly the misses; and
  `compile` pushes what it built. Inert until the store credentials exist (#247): with no
  `DEPDEP_*` variables depdep says so, still runs `deps.get`, and exits 0.

- **`mix tla.check --format json` schema 3: `coverage.terminals`** (#256, closes #202). How many
  successor-less states each `terminal` admitted. Additive, and bumped anyway — the policy
  recorded beside the version is that additive changes bump so a reader can refuse a shape it
  does not know; `mix tla.replay --trace` refuses a v2 document naming both versions.

- **Layer 3 process hygiene** (#269, closes #213). The runtime's per-process spec cache matched
  on the module name alone, so a server that outlived a recompile of its spec kept the
  structure it first loaded — an invariant added to the spec was never checked by a running
  server; the cache is keyed on the module's md5. `TLA.Simulation` stopped every server with a
  bare `:kill`, skipping `terminate/2`; it stops as a supervisor would (`:shutdown`, a bounded
  wait, then `:kill`), while failure injection stays a `:kill` because a crash runs no
  `terminate/2`. `run/2` said it stopped at the first finding and ran every trace; it stops.
  And the `constants:` option is gone: the server under test resolves constants from
  application config, and a generator on any other assignment drove it against a different
  specification.

- **Trace rendering and counting edges** (#268, closes #212). `Trace.length` meant three things
  — states, states minus one, action count — and is the number of states everywhere now, the
  lasso splice sharing the cycle head once. A lasso's closing duplicate was labelled
  `[VIOLATION]` like a safety trace's last state; it is labelled as the return to the cycle
  head. The explorer hid an all-error row under `changed_only` while printing its warnings,
  and reported a non-function expression as one runtime error per state; the row is shown and
  the expression refused once. `unreached_params` was computed and surfaced nowhere; an
  enabled action's never-drawn bindings are a warning, and under symmetry the universe is left
  unknown rather than compared against a reduced visit. And the profile's run bracket closed
  after the liveness pass, charging the states it materialized to a denominator whose doc says
  "the window is the search"; it closes before, and the coordinator clears its counters.

- **Four DSL front-end edges are diagnostics, not crashes or silences** (#264, closes #210).
  A `let` could shadow a `params:` name (or `old`/`new`): §1.3 said locals shadow nothing and
  two of three cases were enforced. A variable inside a temporal quantifier's literal list
  killed the compiler with a `FunctionClauseError` from a rewrite mode nothing handled; it is
  the §5.1 diagnostic now. `defmodule __MODULE__.Spec` crashed the hash scanner from inside a
  consumer's compile; a computed name now has no version and `check_stamp!` says why. And
  `params: [k: [1, 1, 2]]` drew three bindings per state and inflated the action's coverage;
  each distinct value is drawn once and a visible repeat warns.

- **Three boundaries that accepted what they should have refused** (#265, closes #211).
  `Trace.from_json!/1` read no structure: `"type": "ok"` became `:ok` through
  `String.to_existing_atom/1` and `format/1` failed later, a non-string type was a raw
  `ArgumentError`, and two states with no actions built a trace nothing could render honestly;
  it now checks the type against the three it knows and the alignment before building
  anything, and `mix tla.replay` refuses an entry with no `violation` key by name instead of a
  `CaseClauseError`. `domain/1` raised `BadMapError` on a list `at/2` applies as a function;
  it answers `1..Len`. `for_each/2` built a function over a list, deduplicating silently; it
  ranges over a set or a range and refuses a sequence or a map by name.

- **The trace reader said "the spec was verified" about a bounded run** (#261, closes #205).
  `mix tla.replay --trace` and `mix tla.explore` matched `"violation" => nil` and said
  verified without reading the `status` beside it, so a `max_states_reached` or `constrained`
  document read as a proof. It now says what the status says — bounded at N states by the
  limit, held back by the constraint, or the status by name — and only `ok` is verified.

- **A raise in one simulation trace orphaned every in-flight server** (#262, closes #206).
  `TLA.Simulation` started each server linked, unlinked it at once, and stopped it only on
  the normal return, so a message generator that raised once took the run down and left
  every started server alive — eight `Impl.Conforming` processes after one failed run. Each
  trace now runs in a process that owns its server: linked for the trace's whole life and
  trapping exits, so a raise anywhere in the trace takes the server with it and a server's
  own crash still arrives as an outcome. `init/1` returning `:ignore` or `{:error, _}` is an
  `:error` outcome naming it, not a `MatchError`.

- **Five small regressions left by recent fixes** (#263, closes #209). `init` was the one
  options-taking macro with no option check, so `init desc: "x", do: …` compiled with the
  keyword dropped (and the `do … end` spelling was an "undefined function init/2" from the
  compiler rather than a diagnostic) — it now takes no options and says so. The liveness-skip
  warning counted declared temporals rather than the selection, so `temporals: [:one]` with
  three declared said three were not checked. The accepted-options comment still said
  `temporal` reads no options. `TLA.Conformance`'s `@type finding` lacked `:nothing_observed`.
  And the liveness-shape fixture tests asserted only `status == :violation`, so a fairness
  regression reporting the unfair cycle would have passed; the cycles are pinned.

- **`Trace.validate/3` accepted a stutter at a live state** (#259, closes #203). A one-state
  cycle with no actions validated `:ok` at a state with enabled steps — and `format/1` then
  rendered it "has no successors: the behaviour stutters there forever" — and a `:deadlock`
  trace whose last state had successors validated too. `validate/3` is the oracle the
  generated-model tests rest on, so a liveness bug producing a stutter in the wrong place
  passed it. Both now require no enabled step at that state, per §5.3, and name the enabled
  action otherwise.

- **`Trace.format/1` truncated a misaligned trace and marked the wrong state** (#260, closes
  #204). `to_diffs/1` zipped states, actions and bindings and stopped at the shortest list:
  one action short — the shape the simulation's restart branch produced — it printed three
  states of four and put `[VIOLATION]` on the wrong one, and a JSON file lacking `bindings`
  rendered as a single-state trace. The alignment invariant `validate/3` already reports is
  now checked in `to_diffs/1`, so `format/1` and every programmatic consumer refuse a trace
  that cannot describe one behaviour, naming the three counts.

- **A liveness pass skipped by the state budget or a safety counterexample is named** (#257,
  closes #208). #167 named the deadlock skip and the constraint skip and left these two
  silent: `check_liveness: true, max_states: 3` on a spec with a temporal reported the bound
  and nothing about the property, and `mix tla.check --allow-incomplete` turned that into
  exit 0 with the property unmentioned. Both now put the same "N temporal properties were not
  checked" warning in the result, with the reason and what to do; a spec with no temporal
  gets no such warning.

- **A stepped range type honours its step** (#258, closes #196). `variable :x, type: 0..10//2`
  compiled and `resolve!` dropped the step to a plain range, so `x = 1` passed `type_ok` —
  while `params: [k: 0..10//2]` evaluates the same literal as Elixir does and never drew a 1.
  Two consumers of one syntax disagreed, and §1.5 had no `//` at all. The step is now honoured
  (a `^constant` step and a negative step included), a zero step is a compile error naming the
  grammar, and §1.5 says so.

- **The ledger enumerated three of five declaration kinds** (#256, closes #202). Under a header
  promising "every item each TLA.Spec declares", `action_property` and `terminal` had no rows
  at all — the kind list was written by hand. It is now `TLA.Spec.Hash.kinds/0`, so a kind
  added to the DSL is a row on the day it is added or a loud failure, never an absence. Fifteen
  rows join `TLA_COVERAGE.md`. Their evidence rests on what the run recorded: an action
  property on the transitions it was checked against (`lock_not_stolen` on 76), a terminal on
  the end states it admitted (`term_bound_exhausted` admitted 655) — and a terminal that
  admitted none reads `NOTHING`, because the run would have reported the same deadlocks without
  it.

- **`Driver.call/3` raised for a dead registered name where a dead pid reported `:stopped`**
  (#255, closes #254). #252 made the `:DOWN` match shape-independent and left the send
  shape-dependent: `send/2` to an atom no longer registered raises `invalid destination`,
  where the same send to a dead pid is a no-op answered by the monitor's `:noproc`. Found by
  the #252 test itself racing on the 2-core runner, which killed the server before the send
  every time and turned `main`'s `test` job red. The server is now resolved once with
  `GenServer.whereis/1` and every `GenServer.server()` shape — pid, name, `{name, node}` — is
  the pid path by construction; a test enumerates the shapes and holds each outcome identical.

- **`Replay.counterexample/3` with `server: <registered name>` reported `:not_followed` instead
  of the violation the server raised** (#252, closes #207). `TLA.GenServer.Driver.call/3`
  matched the `:DOWN` on the server the caller passed; a monitor on a registered name delivers
  `{name, node}` there, never the bare atom, so neither clause matched and the call waited out
  its timeout — 5 seconds, then delivery blamed for a finding that had been made and thrown
  away. The `:DOWN` is now matched on the monitor reference alone, which is unique to the call,
  and a pid and a name give the same outcome.

- **A profile floor over too few evaluations is marked, not presented as reproducible** (#249,
  closes #248). The report's footer said the cost "is exactly reproducible"; for a declaration
  evaluated once it is the one sample, excursion included. `TLA.ProfileTest`'s fixture has
  such an item — `reset` fires in exactly one state — and its "floor" read 2 reductions on a
  quiet machine and 29 under load on three CI pipelines, each costing a retry. An entry now
  carries `settled: false` below four evaluations, its row says `floor over N evaluations,
  not settled`, the footer qualifies its claim, and the stability tests compare settled floors
  and assert the marking rather than silently skipping the item.

- **Nested temporal quantifiers collapsed their vacuity probes onto the last outer
  instance** (#240, closes #200). Coverage rebuilt the instance set with a walk of its own and
  keyed each probe by the innermost binding alone, so for `Enum.all?(^xs, fn x ->
  Enum.all?(^ys, fn y -> P ~> Q end) end)` every `x` landed on the same key and only the last
  survived. An outer instance whose antecedent never held anywhere reported `vacuous == []`
  — while the liveness checker, expanding correctly, checked it as its own property. The two
  now share one expansion, `TLA.Spec.Compiled.temporal_instances/2`, and a probe is keyed by
  the full binding path. `vacuity.binding` is that path (`[:b, :a]`), `[]` for an unquantified
  property, where it was a bare value or `nil`.

- **The ledger's `Version` column was file-scoped, so same-named items shared one hash**
  (#241, closes #201). `TLA.Spec.Version` scanned the whole file and keyed by `{kind, name}`,
  so the seven `tick` actions in `test/support/specs.ex` all showed the last one's hash —
  editing `Broken.tick` moved nothing, editing the last `tick` moved seven rows. The golden
  was stable while wrong, which is the exact failure a drift-gated golden is meant to make
  impossible; nothing cross-checked the two hash modules because `HashTest` compared
  `definition_hash/1`, not the scan. `TLA.Spec.Version` is removed and the ledger reads
  `TLA.Spec.Hash.item_versions/1`, which is module-scoped: one implementation rather than a
  test proving two agree. Nine rows in `TLA_COVERAGE.md` change hash — six `tick`s, a
  `taint`, a `reset`, an `eventually_done` — each to the hash of its own definition.

- **`subseq/3` wrapped on a zero index where `at/2` raises, and crashed on an empty result**
  (#242, closes #197). It was `Enum.slice(seq, m - 1, n - m + 1)`, which has Elixir's index
  semantics: `subseq([1, 2, 3], 0, 1)` returned `[3]`, a negative start counted from the end,
  `subseq([], 2, 0)` was a `FunctionClauseError` where TLC returns `<<>>`, and a `MapSet` was
  accepted where `len/1`, `head/1` and `at/2` all refuse one. An off-by-one in a spec produced
  a valid sequence that was not the one the author wrote, silently — the exact shape `at/2`
  refuses for the same slip. It now follows TLC's `SubSeq`: `n < m` is `[]`, any other bound
  outside `1..Len(s)` is a `DomainError` naming the index and the length, and a property test
  holds `subseq/3` and `at/2` to the same answer on every out-of-domain index.

- **A non-UTF-8 binary in state crashed `--format json` and discarded the counterexample**
  (#244, closes #198). §6 permits binaries; `TLA.State.Json` handed every one to
  `Jason.encode!/1` as a string, which raises `invalid byte 0xFF` on anything that is not
  valid UTF-8. A spec holding `<<255, 0>>` found a real violation, then `mix tla.check
  --format json` exited with a stack trace from the report writer and no document. Such a
  binary now travels as base64 under its own `"b64"` tag; valid UTF-8 keeps `"b"`, so every
  existing trace still decodes. And `TLA.State.validate!/1` now refuses a bitstring that is
  not whole bytes (`<<1::3>>`), which the wire format has no form for — what `validate!`
  admits and what `Json.encode` encodes are the same set.

- **The verification ledger now model-checks temporal properties** (#234, closes #191). The
  ledger ran the checker with `check_liveness: false`, then wrote `checked` in the `:temporal`
  rows anyway — every temporal property in `TLA_COVERAGE.md` was labelled verified on the
  strength of a safety run that never evaluated it. Raft's `eventually_elect_leader`, which
  the differential corpus records TLC *violating*, sat in the golden as `checked`. That is the
  precise over-claim Principle 6 forbids, in the document whose one job is to not make it.

  The ledger now runs one liveness check per temporal (`temporals: [name]`), because the
  checker halts at the first refutation and a single run can only ever produce one verdict
  for a whole spec. Each row carries its own result: `checked` when the property held,
  **REFUTED** when the checker found a counterexample, and the checker's own reason when it
  could not evaluate it (deadlock, incomplete graph). Four rows in the golden flipped to
  REFUTED — Raft's `eventually_elect_leader` (now agreeing with TLC), `Idling.never_two`,
  `IdlingUnfair.eventually_two` and `Stalls.eventually_done`, each a property the fixture
  *intends* to fail. The cost is a ledger run of ~35s instead of ~15s, measured; that is what
  actually checking costs, and the alternative was a column that meant nothing.

- **`always(x = 1)` is now the compile error `x = 1` already was** (#235, closes #192). The
  #150 fix rejected an assignment to a declared variable only when it was a whole statement
  of the block. In a temporal the top level is nearly always `always`, `eventually` or `~>`,
  so the check fired for the one shape nobody writes and `always(status = :done)` compiled
  into a formula the checker reported as holding. `invariant :i do n >= 0 and (status =
  :done) end` compiled too. The rationale for stopping — that a nested `=` is legal Elixir an
  author might mean — was wrong: the check fires only for `=` on a *declared* variable, which
  §1.2 forbids outside `init`/`action` at any depth. The walk is now the whole expression,
  from the one function both paths share.

- **A constants config key naming no constant is refused, not dropped** (#236, closes
  #193). `config :extla, Spec, constants: %{maxx: 100}` against `constant :max, default: 3`
  resolved to `%{max: 3, maxx: 100}`: the override the author wrote was read by nothing,
  the run used the default, and nothing said so. It is the selection defect of #166 through
  the one door that fix did not cover. `Spec.Compiled.resolve_constants/1` now raises,
  naming the key and the declared constants — in the resolver rather than at a call site,
  because `mix tla.check`, `mix tla.simulate`, Layer 3 conformance, the guides and the
  ledger all resolve there and each would otherwise need the rule separately.

- **The module version now moves on every declaration, not only the items** (#237, closes
  #194). Changing an `init` value, a variable's `type:`, a constant's `default:`, or weak to
  strong fairness produced the *identical* `spec_version`; only an action-body edit moved
  it. So a stamp — whose error message asks the author to "confirm that each variable and
  action it names still means what it did" — stayed fresh across a new variable the mapping's
  `abstract/1` could not produce, and the ledger's `Fresh` column with it. The reasoning in
  the code ("not independently verified, so there is nothing for a verdict to go stale
  against") mistook what the number is for: a stamp names the *specification*, not a verdict.

  `fairness` is included, and it was the one to argue: it changes liveness verdicts and
  nothing a mapping reads. But one number names one specification, and a spec under strong
  fairness admits different behaviours from the same spec under weak; a second
  "liveness-relevant" version would be a second stamp nobody maintains.

  Found on the way: `mix tla.stamp` derived the module version by its own copy of the
  formula, so widening it in `TLA.Spec.Hash` alone would have made every stamp stale in the
  task while the compile-time check called it fresh. Both now call
  `Hash.module_version/2`. The one live stamp in the corpus is regenerated; a test enumerates
  the DSL's macros against `kinds/0 ++ declarations/0`, so a declaration added to the DSL and
  to neither is a failing test, not a blind spot.

- **A temporal quantifier named after a state variable is a compile error, not a silent
  misread** (#238, closes #195). With `variable :p` declared, `Enum.all?(^procs, fn p ->
  eventually(pc[p] == :b) end)` compiled — and the body's `p` resolved to the *state
  variable* in every instance, because the rewriter consults declared variables before bound
  names. The instance for `p = 2` came out true on a state where `pc[2]` was wrong, and the
  only mark was a compiler "unused variable" warning. §1.3's rule that a `let` may shadow
  nothing was always a rule about what a bare name means; it now applies to every binder,
  and the quantifier gets the same diagnostic, naming both the local and the declaration.

- **The profile's share under `--workers` counted the workers in the numerator and left them
  out of the denominator** (#239, closes #199). Declaration costs were collected per worker and
  summed; `run_reductions` was the coordinator's own `Process.info(:reductions)`, and under
  `workers: n` the coordinator does almost none of the work. The same spec read 12% at one
  worker and 91% at eight — the bounded-buffer corpus entry 8% against 170%, a share over the
  whole — and `advice/1`'s verdict inverted. The guarding test ran at one worker, where the
  two brackets coincide. Each worker now brackets its own batch and the coordinator's bracket
  is added to the sum rather than replacing it; the new test runs the same spec at one worker
  and four and asks for the same answer.

- **Two tests that could not fail, and the guard for the class** (#175). The test named "one
  restricted to a callback is not checked in the others" did not discriminate on `callbacks:`
  at all — making the restriction a no-op left it green. Its apparent discrimination came from
  declaration *order*: the unrestricted invariant was declared first and raised first, so the
  restricted one never got a chance either way. The new test declares the restricted one
  first, so honouring the option and ignoring it report different violations.

  And **nothing in the repository read `.gitlab-ci.yml`**. No test asserted that the set of
  jobs covers the test tree, so a directory reached by no job was undetectable by
  construction. There is now one that fails when a directory is unreachable.

  Worth stating precisely, because the obvious claim would be wrong: this does *not* catch the
  guide slug cross-check that has never run. That test **is** run by the `test` job; its
  problem is that it needs a built `doc/` and only another job builds one. A coverage check
  cannot see "needs an artifact another job produces". #156 fixes that one by moving it.
- **Half the temporal fragment was refuted by nothing, and the Emerson–Lei strong split was
  dead under test** (#174). Nulling the refutation clauses for a bare predicate, for
  `always(eventually(P))` and for `eventually(always(P))` — so the checker answers "holds" to
  all three — left the whole suite green. `eventually(always(P))` appeared in no spec anywhere
  in the project; `always(eventually(P))` appeared once and was only ever verified.

  Replacing the strong-fairness branch with `[]` was likewise green: the only strong-fairness
  fixture is verified, and `[]` also yields success, so the test named "strong fairness is not
  weak fairness" passed for the wrong reason and never exercised the subset search.

  Four models now refute the four. The fairness one needs the split to find its refutation at
  all: its component is unfair as a whole, and only restricting to the states where the
  constrained action is disabled leaves a fair cycle. Each is small enough to check by
  reading, and each carries a companion assertion that the model is otherwise sound, so the
  refutation is evidence about the property rather than about a broken spec.
- **`--verify-symmetry` did nothing without `symmetry:`, and blamed the declaration when it
  did run** (#170). The one flag whose stated purpose is catching an unsound reduction failed
  in both directions.

  `mix tla.check` dropped both keys when no `symmetry:` was configured, so the flag never
  reached the checker's own guard — the one that exists to refuse exactly this — and a
  misspelled or missing declaration produced `— verified`, exit 0. The task was routing
  around its own refusal.

  And the comparison read a truncation as a verdict difference. The compared fields exclude
  counts but include `status` and `never_enabled`, both bound-sensitive, while both runs get
  the same `max_states` and the reduction's whole purpose is that the unreduced run is
  bigger. So on any model large enough to need symmetry, the unreduced run hit the bound
  first and a **sound** declaration drew a maximally confident accusation. A bounded
  comparison is now refused with a reason rather than answered, which is how
  `TLA.Conformance` already treats a bounded graph.

  The first version of that refusal appended the reason to `warnings` and left the status
  `:ok` — so `mix tla.check` printed `— verified` and exited 0 with "could not be verified"
  as a trailing line. A run that asked for the check came back looking verified to anything
  reading the exit code, which is the defect this fix exists to close. An inconclusive
  verification now carries the bounded run's status and exits 2, and a test reads the exit
  code rather than the warning text.

- **A found violation could be discarded, and a skipped liveness pass said nothing** (#167).
  Four defects in the step that turns a completed search into a reported verdict.

  A **deadlock** silently suppressed liveness checking. The same spec reported a refuted
  temporal property with `check_deadlock: false` and never mentioned it with the default,
  `warnings: []` both times. Skipping is right — a liveness question about a spec with a
  safety failure answers the wrong question — but the run now says so and names the flag that
  asks anyway.

  **`check_liveness:` with `state_constraint:`** crashed with `ArgumentError` *after* the
  search, discarding a safety result that was valid for what it covered. The pairing is only
  conditionally bad: a constraint that never holds a state back leaves a complete graph. So
  the run consults `TLA.ModelChecker.Liveness.answerable?/1` and declines with a reason,
  rather than being refused up front like symmetry and workers, which are unconditionally
  incompatible.

  A **configured** state constraint discarded a lasso the liveness pass had already found,
  because `:constrained` is set whenever a constraint exists rather than when it bound the
  run. Bounded-ness is carried by `coverage.bounded_by`, so the refutation now wins.

  The **parallel merge** kept the first non-`:ok` outcome, so a worker reporting
  `:max_states_reached` before the worker carrying the violation dropped the counterexample —
  roughly 1 run in 80, and never at `workers: 1`, so the verdict was order-dependent. Both
  outcomes occur in the same level because the budget test reads the shared count.
- **A name or flag that matched nothing was accepted, and the check it named never ran**
  (#166). Three places, all verified by running code.

  A **selection** naming a declaration the spec does not have checked nothing and reported
  success — `invariants: [:capp]` for `:cap` gave `status: :ok, warnings: []` where the
  correct name gives `:violation`. That reaches further than a hand-typed config: the TLC
  differential corpus drives its selections from names in `expected.json`, and D-9 makes
  agreement with TLC the acceptance gate, so a misspelled fixture name made the gate pass
  having compared nothing. The corpus was audited after the fix and every name resolves.

  A **`callbacks:` value** naming a callback that does not exist deleted the declaration:
  `invariant :never_negative, callbacks: [:handle_cal]` let a balance reach `-899`. Naming a
  real callback the module does not define now warns instead of failing, since adding it in a
  later edit is legitimate — but silence is not.

  Three tasks parsed argv **leniently** and dropped what they could not parse. Two are CI
  gates: `mix tla.coverage --chek` printed the ledger and exited 0, which is the advisory
  gate on a verification artifact D-10 forbids. All eight now parse strictly, asserted by a
  test that **enumerates** the tasks rather than listing them, so one added later is covered
  on the day it is added.
- **`TLA.Conformance.against_graph/3` passed on a behaviour it never examined** (#168). A
  one-state behaviour has no pairs, so the fold returned its `:ok` seed and `explain/1`
  reported that every observed state is in the graph — about a state that was not in it.
  Reachable in practice: a simulation trace whose first message times out yields one state.

  A single state is now checked on its own, and an empty behaviour is `:nothing_observed`
  rather than a success. This is the module that already refuses a *bounded* graph outright
  rather than report absence from it; the same discipline was missing at the other end of
  the input.

- **Five mix tasks had flags the specification did not mention, `mix tla.simulate` all eight
  of them** (#163). Its section carried usage examples and no option list, so
  `--inject-failures` and `--unrestricted` — two substantial Layer 3 capabilities, crash
  recovery and testing that an implementation refuses what the spec forbids — were
  undiscoverable from the documentation. `mix tla.stamp` had no section anywhere,
  `mix tla.explore` no option list, and `tla.replay` and `tla.visualize` were each missing
  one.

  The gate that would have caught this has existed since #134 and covered one task, because
  the accessor it reads was added to that task and nothing generalised it. All six are now
  held to their documented lists, across both spec files.

  One flag is documented as **not currently read**: `mix tla.replay --init-arg` is accepted
  and ignored, so `TLA.Replay`'s `:server`, `:init_arg` and `:timeout` are unreachable from
  the command line (#165). Stating that is less tidy than omitting it and more useful.

- **The API reference's front page and the specification index both said the library was
  unbuilt** (#148). `TLA`'s moduledoc read "Phase 1 is in progress ... the model checker
  that consumes it does not exist yet", untouched since the third commit of the project, and
  `spec/README.md` said "No implementation exists yet". `TLA` sorts first in ExDoc's sidebar,
  so that was the landing page for anyone asking what the API is, four phases out of date.

  Neither gets a corrected status line, because a second copy is what produced this:
  `06-architecture.md` §Implementation Phases is the only place build status is recorded, and
  both pages now point at it. `TLA`'s moduledoc is rewritten as what it should have been —
  the three layers, a specification that a reader can recognise, and the warning that the DSL
  looks like Elixir and is not — with resolvable links to the getting-started guide and the
  normative semantics rather than the bare paths it carried before.

- **An unknown option on a spec DSL declaration was discarded rather than refused, and
  `temporal` dropped its description** (#160). `variable :n, type: :nat, bogus: 1` compiled.
  Each macro popped the key it wanted and either stored the remainder unread or threw it
  away, so a typo was indistinguishable from an option nobody wrote.

  The accepted set is now per macro and records **what is consumed**, not what looks
  plausible: `constant` takes `default:`, `variable` takes `type:`, `action` takes `params:`,
  and `invariant`, `terminal`, `action_property` and `temporal` take `desc:`.

  `temporal` is the one that changed rather than merely gained a check. It discarded its
  options entirely, so `desc:` there was accepted and silently dropped — the corpus's
  `no_starvation` carried one and nothing kept it. It is now stored the way the other
  predicate forms store theirs, which is what makes accepting it honest.

- **`TLA.Spec.DSL`'s option helper reported every failure as one diagnostic** (#160). It
  wrapped a `case` whose two branches were identical in a bare `rescue _`, so any error
  inside became "options must be a literal keyword list" — in the DSL compiler's own error
  path, where a wrong message costs an author the most. It now asks `Keyword.keyword?/1`
  directly, which answers the question exactly and lets a real bug surface as itself.

- **An unknown option on a Layer 1 declaration was stored and ignored rather than refused**
  (#149). `callbaks: [:handle_cast]` compiled, and the invariant applied to every callback.
  The cause is the one above, one step subtler: options were consulted by name with
  `Keyword.get/3` defaults, so a key nobody reads and a key nobody wrote are the same thing.

  The accepted set is now per declaration kind, and the message names the offending key and
  what that kind takes. `callbacks:` is included for `invariant` and `action_property` and
  **excluded for `precondition` and `postcondition`**, which never consulted it — they are
  emitted from `__tla_conditions__/1` and do not reach the wrapper's `applicable/2`. Accepted
  and ignored is the same defect as unknown, so it is refused rather than documented.

- **`enabled:` on a Layer 1 declaration only ever worked for a literal `false`, so the
  documented way to compile out an expensive check did nothing** (#149). `03-layer1-invariants.md`
  §162 specifies the option as evaluated at compile time, and §634 gives the idiom:

  ```elixir
  invariant :expensive_check, enabled: Mix.env() == :test do
    deep_structural_audit(state)
  end
  ```

  Declaration options are stored with `Macro.escape/1` and read back as literals, which is
  right for `desc:` and `callbacks:`. It is wrong for this one: `Mix.env() == :test` was kept
  as a `{:==, _, _}` tuple, and the filter that decides emission keeps everything but `false`
  and `nil`. A tuple is truthy, so **every expression enabled the declaration**, whatever it
  evaluated to. The option worked for the one input that makes it pointless, since a literal
  `false` is indistinguishable from deleting the declaration.

  **A stock production release was not affected** — `MIX_ENV=prod` resolves the whole layer to
  `:disabled`, so lever 3 saved what lever 1 did not. What paid for it was `:dev`, which is
  `:enabled` by default and is where "compile this out except in test" most obviously means
  something, and any deployment setting `mode:` explicitly, `:log_only` in staging being the
  documented case.

  `enabled:` is now evaluated in the caller at its compile time, so `Mix.env()`, a...
Assets 2
Loading