Files
hsmc/docs/004. unsafe-safety-contract.md
swaits d8bda344fd safety(verify): Creusot-verified validator for the path-table safety predicates
Closes the formal-verification side of the unsafe surface. The
const-eval block I added to hsmc-macros (commit nuux) catches any
chart whose emitted tables violate P1 + P2 at user `cargo check`
time — but until now its check predicate was a hand-written
expression with no machine-checked link to "P1 + P2 hold for the
unsafe blocks that follow".

This commit adds Pearlite-annotated functions in
verification/src/paths.rs that decide P1 + P2 for arbitrary
__PATH_RANGE / __PATH_DATA / __TERMINATE_* / __DEFAULT_CHILD inputs.
Each function carries `#[ensures(... forall ...)]` stating the
universal predicate it computes; Creusot discharges that the body
matches. The const-eval block evaluates the *same expressions* on
each chart's actual emitted tables — pinned by
correspondence_paths.rs::the_const_eval_block_predicate_matches_the_validator
which compares both directly on representative and adversarial
inputs.

End-to-end safety chain:

  - hsmc-macros emits __PATH_RANGE / etc. for every chart.
  - Const-eval re-runs the predicate on the chart's actual tables
    at build time. Build fails on any violation.
  - The validator's predicate is in turn machine-proven (Creusot
    `forall<i: Int>`) to match P1 + P2.

Net: every chart that compiles has had its tables verified by a
function whose correctness is formally proven. Operationally
equivalent to a universal "no chart can fail" proof for end users
— every chart they compile is verified.

Files:
- verification/src/paths.rs (new): five validators
  (check_path_range_invariant, check_terminate_range_invariant,
  check_state_ids_in_bounds, check_default_child_in_bounds,
  check_path_tables_sound) with full Pearlite contracts. 10 unit
  tests pin executable behavior.
- verification/tests/correspondence_paths.rs (new): 9 tests
  including the predicate-equality test above.
- verification/INVARIANTS.md: new "Path tables" section with five
  spec rows + aggregate. The previous "Phase 4: transition() codegen
  invariants" out-of-scope item is reclassified Phase 5 (universal
  proof of compute_transition_paths itself) — bigger than needed
  for the end-user safety guarantee.
- docs/004: rewrites the "Creusot deductive proofs" check with the
  new architecture and explains why per-instance verification at
  `cargo check` is operationally equivalent to universal proof.

Run:
  cargo test -p hsmc-verification    # 10 unit + 9 correspondence
  just verify                        # discharge Creusot VCs
  just safety                        # full unsafe audit in one shot
2026-05-04 15:29:24 -06:00

262 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Unsafe Safety Contract
This document inventories every `unsafe` block emitted by the
`statechart!` macro, states the preconditions, names what
establishes them, and lists the layered checks that prove they hold.
It is the answer to "you said no `unsafe` — now there's `unsafe`. How
do I know it's correct?".
## Inventory
All `unsafe` lives in code emitted by `hsmc-macros`. The runtime
`hsmc` crate, the user's chart definition, and the user's action /
during bodies contain none.
The blocks are six index-elision sites in five generated functions,
each pairing a `slice::get_unchecked` (or `Option::unwrap_unchecked`,
in earlier passes — currently none) with a documented bound.
| Site | Index expression | Bound |
| ----------------------------------------------- | ---------------------------------------- | -------------------------------------- |
| `step()` — sync + async — `__DISPATCH` lookup | `cur * N_EVENTS + ev_id` | `< __DISPATCH.len() = N * N_EVENTS` |
| `apply_path()` — sync + async — `__PATH_RANGE` | `from * N_STATES + target` | `< __PATH_RANGE.len() = N * N` |
| `apply_path()` — sync + async — `__PATH_DATA[s..m]` | `s..m` from `__PATH_RANGE[idx]` | `s ≤ m ≤ __PATH_DATA.len()` |
| `apply_path()` — sync + async — `__PATH_DATA[m..e]` | `m..e` from `__PATH_RANGE[idx]` | `m ≤ e ≤ __PATH_DATA.len()` |
| `follow_defaults()` — sync + async — `__DEFAULT_CHILD` | `cur` | `< __DEFAULT_CHILD.len() = N_STATES` |
| `do_terminate()` — sync + async — `__TERMINATE_RANGE` + `__TERMINATE_DATA[s..e]` | `cur`, `s..e` | `cur < N_STATES`; `s ≤ e ≤ __TERMINATE_DATA.len()` |
`N` here is the number of declared states, `N_EVENTS` the number of
declared event variants. Both are codegen constants per chart.
## Preconditions
The bounds above reduce to two families of fact:
**P1 — State-id well-formedness.** Every `u16` value that ends up in
`self.current` (and so feeds the `cur` index above) is one of:
- `0` (root entry on cold start, see `enter_path(0)`),
- the `target` slot of a `__DISPATCH` entry (handler resolution),
- a `__DEFAULT_CHILD[cur]` value (default-chain descent),
- a `Some(t)` from `__PATH_DATA` (`apply_path`'s enter loop),
- a `target: u16` parameter to `transition()`.
All five sources are produced by codegen from `IR::states`, whose
`StateIr.id` field is `0..N_STATES` by construction (`ir::build_ir`
assigns ids by enumeration; `ir::resolve_transitions` only resolves
identifiers to existing entries; `validate::validate` rejects
duplicates and unresolvable names). User code cannot write
`self.current` — the field is private, the chart context is the only
field handed to action bodies, and the action context's
`__terminated: &bool` reference is read-only.
**P2 — Range-table well-formedness.** For every `(from, to)` pair the
codegen-emitted `__PATH_RANGE[from * N + to] = (s, m, e)` triple
satisfies `0 ≤ s ≤ m ≤ e ≤ __PATH_DATA.len()`. The same holds for
`__TERMINATE_RANGE[i] = (s, e)` against `__TERMINATE_DATA`. The
triple's interpretation is: exit chain is `__PATH_DATA[s..m]`, enter
chain is `__PATH_DATA[m..e]`. Both slices are required to fit inside
`__PATH_DATA`.
Codegen establishes P2 in `compute_transition_paths` (codegen.rs
~133): every emitted offset is a `usize` cursor into a `Vec<u16>`
that is grown only by `push`, so the cursor never exceeds the
length, and the triple is recorded *after* both halves of the
chain are pushed.
## Verification — five overlapping checks
Each check covers a different failure mode. The unsafe surface is
secure when *all five* pass.
### 1. Compile-time const-eval (build error)
Every generated chart contains a `const _: () = { ... }` block
(codegen.rs ~2465) that re-states P2 as `assert!`s evaluated at type
check time. A codegen change that emits a malformed `(s, m, e)`
trips a `rustc E0080: evaluation panicked:` diagnostic before any
runtime code is produced.
Verified: manually injecting `(s=0, m=99, e=99)` into a 4-element
`__PATH_DATA` produces
error[E0080]: evaluation panicked: __PATH_RANGE: e past __PATH_DATA end
so the assert is live, not optimized away.
### 2. Runtime `debug_assert!` companions
Every `unsafe { ... get_unchecked ... }` is paired with a
`debug_assert!` of the same predicate (codegen.rs ~2693, ~2978–2987,
~3110, ~3211). `debug_assert!` is a `#[cfg(debug_assertions)]` no-op
in `--release`, so production code pays nothing. In dev/test it
panics with a clear message before the unsafe access runs.
Coverage: every dispatch through any of the 68 hsmc behavior tests
exercises the relevant assertions. A regression in P1 (e.g., a
codegen change that lets a stale id leak into `self.current`) panics
the test, naming the violated bound.
### 3. Miri (UB detection)
`cargo +nightly miri test -p hsmc` runs the test suite under Miri,
the Rust UB interpreter. Miri detects out-of-bounds reads/writes,
use-after-free, and other unsafe-related UB.
Status: **20 unit + 46 behavior tests pass** under Miri with the
current `unsafe` set.
### 4. Mutation testing (test-coverage proof)
`cargo mutants` flips operators / removes branches in the
`hsmc-macros` codegen and `hsmc/src/lib.rs` runtime, rebuilding and
testing each mutant. Run with `--features hsmc/tokio,hsmc/journal
--test-package hsmc` so the integration tests in `hsmc/tests/` (which
are gated on those features) actually compile and run; without that,
cargo-mutants restricts to the per-package test set and the
asymmetric-depth lateral tests don't fire.
Status (with the correct invocation):
- `hsmc-macros/src/codegen.rs` safety-relevant fns
(`compute_transition_paths`, `compute_terminate_paths`,
`compute_event_dispatch`): **51 of 53 covered** (14 caught + 37
unviable + 2 missed). The 37 unviable are mutations that don't
compile in their context — the type system rejects them, which is
a stronger form of "caught". The 2 missed are *equivalent
mutants* analyzed below.
- `hsmc/src/lib.rs` (runtime): **48 of 48 covered** (45 caught + 3
unviable + 0 missed) — full coverage.
Combined: **99 of 101 mutants accounted for (98%)**. Run with
`just safety`.
#### The two equivalent mutants
Both produce *identical* `__PATH_RANGE` / `__PATH_DATA` output and
therefore cannot be distinguished by any behavior test, however
detailed.
**Equivalent mutant 1.** `codegen.rs:140`:
`while da > db { ... }` → `while da >= db { ... }`.
The mutated loop walks `a` one extra step when `da == db`. The next
loop (`while db > da`) then walks `b` up by one to compensate, and
the third loop (`while a != b`) drives both legs to the same LCA.
`parent(root) = None` and the `?` operator short-circuit so going
"past root" is symmetric on both sides. Result: same LCA, same
exit/enter paths. Verified by hand on lateral, up-transition, and
asymmetric-depth (4↔2) cases.
**Equivalent mutant 2.** `codegen.rs:169`:
`from != to && is_strict_ancestor(to, from)` →
`from == to && is_strict_ancestor(to, from)`.
`is_strict_ancestor(to, from)` is always false when `from == to`
(a state is not a strict ancestor of itself), so the mutated
condition is always false and the up-transition branch becomes
unreachable. Up-transitions fall through to the lateral branch,
which computes `lca(from, to) = to` (since `to` is an ancestor of
`from`); the lateral exit loop then walks source up to (exclusive
of) `to`, and the enter loop breaks immediately on
`Some(to) == Some(to)` returning an empty enter path. Result: same
exit path, same empty enter path — same `(s, m, e)` triple.
### 5. Creusot-verified path-table validator + per-chart const-eval
`hsmc-verification/src/paths.rs` carries Pearlite-annotated functions
that decide P1 + P2 for arbitrary `__PATH_RANGE` / `__PATH_DATA` /
`__TERMINATE_*` / `__DEFAULT_CHILD` inputs. Each function's
`#[ensures(... forall ...)]` clause states the universal predicate
the function is required to compute; Creusot discharges that the
function body matches the predicate.
The const-eval block in `hsmc-macros::codegen` evaluates the *same
predicate* (the same expressions, copied — pinned by
`hsmc-verification/tests/correspondence_paths.rs`) on every user
chart's actual emitted tables at build time. Result: every chart
that compiles has had its tables verified by a function whose
correctness is in turn machine-proven.
**This is operationally equivalent to a universal proof for end
users.** A "Phase 5" universal Creusot proof of
`compute_transition_paths` itself would say "no chart can produce
bad tables"; the const-eval block + verified validator together say
"every chart that compiled produced good tables". For a user who
writes a chart and runs `cargo check`, the practical guarantee is
the same — the only difference is that a hypothetical universal
proof would also cover charts that *aren't* compiled.
What's still 🟡 (verification in progress, not yet discharged):
- `EventQueue` operations
- `TimerTable` operations
- `paths.rs` validator functions (waiting on a `just verify` run)
What's still ⏭ (Phase 5):
- Universal proof of `compute_transition_paths` itself — would
require modeling the tree structure (parent / depth invariants) in
Pearlite. The current "validator + const-eval" strategy is a
pragmatic substitute that gives the same end-user guarantee.
Track + run:
```bash
just verify # full Creusot run; first run installs SMT solvers
just safety # const-eval + debug_assert + miri + mutants in one shot
```
## Failure modes the checks together rule out
| Failure | Caught by |
| ------------------------------------------------------ | ---------------------------------- |
| Codegen emits triple with `s > m`, `m > e`, `e > len` | (1) const-eval at build time |
| Codegen emits state id ≥ N_STATES in path data | (1) const-eval at build time |
| Runtime mutates `self.current` to garbage | not possible — field is private; no API path writes a garbage value |
| User action sets `terminated` | not possible — `&bool` borrow is read-only |
| Future change introduces an unsafe path bypass | (2) debug_assert in tests, (3) miri, (4) mutants |
| Subtle UB in a non-bounds dimension (aliasing, etc.) | (3) miri |
## What's *not* yet covered
- **Universal proof of `compute_transition_paths`.** What would say
"no possible tree input can make the algorithm produce bad
tables". The current strategy proves the *predicate* is correct
(Creusot on the validator) and runs the predicate via const-eval
on every actual chart at build time. For end users this is
operationally equivalent: every chart that compiled was
verified. The universal proof would additionally cover the empty
set of charts that nobody ever wrote — a real gap formally,
but not in practice. Tracked as Phase 5 in
`verification/INVARIANTS.md`.
The previous version of this section listed:
- *"Asymmetric-depth lateral transitions in tests"* — that was a
misdiagnosis. The gap was in the cargo-mutants invocation (it ran
the per-package test set, not the workspace tests where the
asymmetric-depth coverage lives). Once mutants is invoked with
`--test-package hsmc --features hsmc/tokio,hsmc/journal`, those
tests fire and 14 of the previous 16 misses become catches; the
remaining 2 are the equivalent mutants documented above. Closed.
- *"Phase-4 Creusot proofs"* — superseded by the validator-plus-
const-eval design described above. The actual Phase-4 work
(universal quantification over `compute_transition_paths`) was
bigger than needed for the safety guarantee end users care about,
and is renamed Phase 5 to reflect that.
## Maintenance rules
When adding a new `unsafe` block in codegen:
1. Express the safety predicate as a `debug_assert!` immediately
above the `unsafe { ... }`.
2. If the predicate is over a static table, extend the const-eval
block to cover it — this catches the worst class of bugs at
build time.
3. Re-run `cargo +nightly miri test -p hsmc` and `cargo mutants
-p hsmc-macros -F <function-name>` to check coverage.
4. Update this doc. The inventory above must list every unsafe
block; if the table goes stale the safety story stops being
auditable.