The specification and storage interface of the invariant surface: record a verified rule, park an aspiration, promote it when it holds, name shared vocabulary, and wire
ct checkinto the build. The only tool that writes the store.
While working, truths about a codebase get established the hard way — a
search here, an outline there, a dependency question answered once and
forgotten. ct-rules records them: each becomes a rule in
.ct/rules.jsonc with the question it answers, the probe that answers
it, and the why that justifies it — verified at the moment it is written,
reviewed in git like any other artifact, and re-verified forever after by
ct-check.
This document is the canonical reference for ct-rules. It is also what the
tool prints for ct-rules --explain (--explain md); ct-rules --explain json prints the equivalent MCP / tool-use definition. The store schema,
probe gate, and bridge are specified in docs/specs/rules.md; a worked,
copy-pasteable rule for each invariant category is in
docs/specs/rules-examples.md; the verifying side is documented in ct-check --explain.
.ct/rules.jsonc under the project root (the .ct directory is the suite's
home for project-local state), discovered by walking upward to the nearest
.ct; --file overrides. --init (or the first --add) scaffolds a
commented store. All mutations go through the suite's own comment-preserving
patch machinery, so hand-written commentary survives every edit — and
anything beyond add/promote/remove/def is an ordinary ct-patch edit.
Two sections:
defs— named vocabulary. A def is a string (expands in place) or a list (expands to multiple argv elements); rules reference them as{def:NAME}instead of re-listing, so shared sets live in exactly one place.rules— the invariants, in run order. Fields:id,question,probe,why,prompt(retained request prose, see below),tags,added,timeout,pending,severity(fail/warn),expect(outcome adapter),network.
The store is written for humans: a standing header comment explains what the file is (re-established on every write if it has gone missing), each rule is recorded one field per line in a stable order with the probe argv inline, and rules are separated by a blank line — the file reads as a ledger of what the project knows about itself.
ct rules --add no-debug-prints \
--question "Are all debug prints removed from src?" \
--why "dbg! output leaked into the 0.1 release notes" \
--tag hygiene \
-- ct-search --base src --name '*.rs' --grep 'dbg!\(' --expect none --detail--add gate-validates the probe and runs it immediately:
- It holds → recorded (with
addeddate) and confirmed. - It is violated → refused (exit
1) with the probe's own violation output — an enforced rule records an established truth, so fix the code first or park it:--pendingrecords an aspiration thatct-checkreports separately and never enforces. - The probe is broken (bad pattern, missing binary, exit
2) → refused (exit2).
Duplicate ids are refused — ids are history, not slots to overwrite.
ct rules --promote no-unwrap-in-lib # re-runs the probe; holds => enforced
ct rules --remove old-rule # delete by exact id
ct rules --def 'core-types=["Parser","Lexer","Emitter"]'
ct rules --def 'domain-layer=src/domain'
ct rules --list
ct rules --flatten # strip retained prompts (see below)A bare probe with no verb runs it and reports — without saving — so you can iterate on a check before committing it:
ct rules -- deps --acyclic --members # runs the built-in deps check now
# prototype: HOLDS (deps: all assertions hold); not saved — add --add ID … to record it
ct rules -- mods --layers 'infra*,domain*' # try a module-layering checkExit status follows the outcome (0 holds / 1 violated / 2 broken), so a
prototype composes in &&/||. When it's right, the same probe with --add ID --question … records it. (deps/mods are the built-in checks — see
ct-check --explain; any gated probe can be prototyped this way.)
When a human asks an agent to record a rule, the prose of that request is
worth keeping: months later it is the difference between editing the rule
confidently and guessing at intent. --prompt TEXT retains the verbatim
request in the rule's prompt field:
ct rules --add no-debug-prints --question "..." \
--prompt "please make sure we never ship debug prints again" \
-- ct-search --base src --grep 'dbg!\(' --expect none --quiet--prompt and --why accept payload schemes — file:PATH reads the prose
verbatim from a file and stdin: reads it verbatim from standard input (both
multi-line, both zero quoting), text:VALUE escapes the prefix. The confirmation says so ("the originating request is retained
in the rule's prompt field"), so the human can edit or drop it immediately
if they prefer. prompt is provenance only — verification never reads it. When the
prose has served its purpose, ct rules --flatten strips every retained
prompt in one pass (naming the rules it touched), leaving only the
mechanical definitions.
A def's VALUE is parsed as JSON when it is valid JSON (lists!), else taken as
a string — the same promotion ct-patch uses.
Probes observe; they never change anything. The gate is compiled in and immutable — a store entry selects from it and can never extend it:
- the suite's read-only tools (
ct-search,ct-outline,ct-tree,ct-view, plusct-test, plusct-eachwithout--mutating); - the built-in checks
deps(crate graph overcargo metadata) andmods(module graph fromuseedges), run in-process from the rule layer; - the bridge:
cargo metadata,cargo tree(hermetic flags enforced),cargo deny check(offline unless the rule opts in with--network— the project's owndeny.tomlremains the policy file), andrust-analyzer search/symbols(search-only) for semantic, AST-aware queries.
Never: the mutating suite tools, ct-check (no self-recursion), ct-rules
itself, shells, or any unlisted external command.
Suite observers speak the exit contract (0 holds / 1 violated / 2
broken) — no adapter needed. Bridge tools don't, so a rule may carry one:
Flag at --add |
Stored as | Meaning |
|---|---|---|
| (none) | — | exit contract (default). |
--expect empty |
"expect": "empty" |
holds iff the probe exits 0 printing nothing (the cargo tree -d shape). |
--expect-ok PATTERN |
{"ok-match": …} |
holds when the pattern appears in the probe's output (e.g. cargo tree -i X saying the crate is absent). |
--expect-err PATTERN |
{"err-match": …} |
violated when the pattern appears. |
Matcher semantics are exactly ct-test's: substring→glob→regex promotion,
unanchored, err decisive over ok, a required ok that is missing is a
violation.
| Option | Meaning |
|---|---|
--question |
Required: what the rule answers. |
--why |
Encouraged: the rationale, printed whenever the rule fails. |
--prompt |
The verbatim human request behind the rule, retained as provenance (strippable with --flatten). |
--tag LIST |
Labels for ct-check --tag selection. |
--severity warn |
Violations report (WARN lane) but never redden the exit — the probation ramp before tightening to fail. |
--network |
Permit network where the bridge entry deems it meaningful (currently cargo deny check, e.g. fresh advisories). A reviewed, per-rule decision visible in the store diff. |
--timeout SECS |
Per-rule probe bound, recorded in the store. |
--pending |
Record an aspiration that does not yet hold. |
ct rules --hook cargo
# writes tests/ct_invariants.rs at the project rootThe shim is plain, reviewed-in-git test code (no build.rs, nothing at
compile time, nothing on dependency builds): under cargo test it runs
ct check --quiet from CARGO_MANIFEST_DIR and fails the test on a
non-zero exit; a missing ct binary fails loudly with instructions
rather than passing silently. ct-rules refuses to overwrite a
tests/ct_invariants.rs it did not generate.
- Show the project's recorded invariants and named defs.
ct rules --list
- Record an invariant whose read-only ct-search probe is run now and must hold.
ct rules --add no-todo --question 'Is the tree free of TODO markers?' -- ct-search --grep TODO --expect none
| Code | Meaning |
|---|---|
0 |
the add/promote/remove/def/list/init/hook succeeded |
1 |
refused on the merits — a strict --add or a --promote whose probe is currently violated |
2 |
usage or store error — duplicate/unknown id, non-gated probe, broken probe, malformed store |
ct-rules writes only the store (and the hook shim) and is on no
allow-gate: nothing in the suite can be driven into running it.
| Option | Effect |
|---|---|
--explain [md|json] |
Print this guide (md, default) or the MCP tool definition (json), then exit. |
-h, --help |
Human help. |
-V, --version |
Version. |