Repository navigation
aprender-contracts-macros: #[contract] fails open in four places, and the doc comment claims the opposite #2699
Description
Filed from paiml/rmedia, where #[contract] sat on 16 call sites enforcing nothing for
months while CLAUDE.md advertised "compile-time enforcement". Three of the four causes
were rmedia's own and are being fixed there (paiml/rmedia#401). These four are upstream, and
they are what made the rmedia-side bugs silent rather than loud.
All line numbers are aprender-contracts-macros-0.64.0 as published — verified identical to
crates/aprender-contracts-macros/src/lib.rs in this repo.
1. The doc comment states the opposite of the code
src/lib.rs:26:
//! 2. `#[contract("name", equation = "eq")]` expands to a `const` that reads
//! the corresponding env var via `env!()`. Missing env var = compile error.
src/lib.rs:174 and :200:
const #const_name: Option<&str> = option_env!(#env_key);option_env! yields None. A missing env var is a silent no-op, not a compile error.
This is the sentence a downstream reader relies on when deciding whether the attribute is
load-bearing — and it is the reason nobody in rmedia questioned it for months.
2. read_contract_assertions treats "no contract" and "no data" as the same thing
src/lib.rs:240-246:
let count: usize = std::env::var(&count_key).ok().and_then(|v| v.parse().ok()).unwrap_or(0);Absent var → 0 → zero assertions → the function is exactly as unconstrained as one with no
attribute at all. Its own comment says "If no env vars are found (e.g., crates.io build),
returns empty vec (graceful)." Graceful is right for a crates.io consumer who never ran
build.rs; it is wrong for a workspace whose build.rs ran and emitted the wrong names, which
is precisely what happened in rmedia. The two cases are indistinguishable to the caller.
3. An unparseable condition is skipped silently
src/lib.rs:~258:
// If parsing fails, skip silently (the expression may not be valid Rust)A YAML precondition with a typo, or written in a dialect that is not Rust, produces no
assertion and no diagnostic. The contract still reads as enforced at every call site.
4. make_env_key is private, so every producer must re-derive it
src/lib.rs has:
fn make_env_key(contract: &str, equation: &str) -> String {
let contract_part = contract.to_uppercase().replace(['-', '.'], "_");
let equation_part = equation.to_uppercase().replace(['-', '.'], "_");
format!("CONTRACT_{contract_part}_{equation_part}")
}It is private, and aprender-contracts exports no equivalent (grepped 0.64.0). So every
build.rs that wants to feed this macro must reimplement the key format by hand — two
derivations of one truth, guaranteed to drift.
They did drift. rmedia's build.rs emitted PRE_{stem}_{eq}_{i}; the macro reads
{env_key}_{PRE|POST}_COUNT then {env_key}_{PRE|POST}_{i}. Measured with the registry
present and gaps == 0:
warning: [contract] AllImplemented: 1/1 implemented, 0 partial, 0 gaps
warning: [contract] PRE/POST: 210 preconditions, 335 postconditions emitted
NO PANIC: precondition t.is_finite() not enforced; got Color { r: 0, g: 0, b: 0 }
$ grep -c 'PRE_COUNT' target/debug/build/rmedia-core-*/output
0
545 conditions emitted under names nothing reads, and every layer reported success.
Suggested fixes, roughly in value order
- Export the key builder.
pub fn env_key(contract, equation) -> Stringfrom
aprender-contracts, so a producer imports it instead of copying it. This alone would
have prevented the whole rmedia incident. - Make the doc comment true — either switch to
env!()(breaking for crates.io
consumers), or correct the sentence. Right now it is worse than no documentation. - Distinguish "not built with contracts" from "built and got nothing." An explicit
opt-out (feature flag, or aCONTRACT_ENFORCEMENT=offvar) would let the absent case be
loud by default and quiet on request, instead of always quiet. - Emit a
compile_error!or at minimum a warning when a condition fails to parse,
rather than dropping it.
Note on scope
Also worth stating: the macro emits debug_assert!, so enforcement is debug-only and
compiles out in release. That is a defensible design choice, but the crate description
("Proc macros for compile-time contract enforcement") and §2 of the module doc together
imply something considerably stronger than what ships.
Downstream tracking: paiml/rmedia#401.
Activity
noahgift commentedon Sep 5, 2026
Blocks PP-066 P-1.1 (spec §5 Track P) — parented under #2870 per the epic body (Track P lives under #2870 → #2556).
#2870's ticket map: phase 1.1 = PP-066 P-1.1 (
#[contract]fails closed: generated registry, compile_error!, explicit optional, lib.rs:26 fixed, trybuild table) with P-1.2 repointing the 10 dangling sites — the card that lets R-0's discover() carry a #[contract] that means something. Comment names the card.PP-066 triage 2026-09-05 (issue #2873); ledger
docs/audits/pp-066-triage-2026-09-05.md.noahgift commentedon Sep 23, 2026
triage 2026-09-23 (aprender-dd, standing triage owner): milestoned to 0.70.0, theme Fast Train (CI, gates, pv, release tooling, hygiene); it moved out of
backlog(the 2026-09-17 overflow bucket, not a release).fix(contract): #[contract] refuses what a producer emitted but it can…
fix(contract): a site the registry has no row for is a compile error …
noahgift commentedon Sep 25, 2026
Parked by APR-EPIC-001: over S2 budget (rank 4/38). Reopen = pull. Applied by aprender-d1 under a 3-lane quorum receipt (handoff/balance-quorum-rev4.json).
fix(contract): #[contract] refuses what a producer emitted but it can…
fix(contract): a site the registry has no row for is a compile error …