Skip to main content

car_server_core/coder/
no_change.rs

1//! The gate behind `CoderState::Reported` — when a session may conclude that
2//! **no code should change**, and when it may not.
3//!
4//! ## Nomination is not adjudication
5//!
6//! The model's `report_no_change` action records a *candidate* finding. It never
7//! transitions the session. Everything that decides the outcome is checked here,
8//! outside inference, against facts the runtime collected itself.
9//!
10//! That split is the whole defence. A verdict the model can reach by giving up
11//! is an escape hatch from every hard contract, and that failure mode is worse
12//! than the gap #1070 describes. So the interesting code in this module is all
13//! refusal.
14//!
15//! ## Mutation history is monotonic
16//!
17//! [`MutationLedger`] latches on the first successful mutating tool call and
18//! never clears. Editing and later reverting does **not** restore eligibility.
19//!
20//! This is deliberately unforgiving, because the alternative is a laundry: try
21//! to fix it, fail, revert, declare the premise wrong, exit 0. The price is real
22//! and worth paying — a session that explored by editing has to keep coding or
23//! start over. A clean worktree is necessary but not sufficient; the ledger is
24//! what makes it not sufficient.
25//!
26//! ## What the runtime can actually verify
27//!
28//! Only one of the three no-change shapes, and only narrowly:
29//!
30//! - [`NoChangeKind::PremiseWrong`] — verifiable *in the operational sense that
31//!   a trusted contract already passed against the untouched baseline*. The
32//!   verdict this licenses reads "the contract was already satisfied before any
33//!   work", and NOT "the model's explanation is correct".
34//! - [`NoChangeKind::DeliberateBehavior`] — not verifiable. Passing tests
35//!   establish what the code does now, never that maintainers intended it or
36//!   still want it.
37//! - [`NoChangeKind::NonCodeDecision`] — not verifiable. Depends on policy,
38//!   ownership, rollout and priorities that live outside the worktree.
39//!
40//! A red baseline proves none of them. Source inspection and model reasoning are
41//! evidence for a human reviewer, never an independent gate.
42//!
43//! ## What the observation still cannot see, and why that is survivable
44//!
45//! [`worktree_fingerprint`] samples at shell-call *boundaries*, so a mutation
46//! made and undone inside a single invocation — `sed -i …; cargo test;
47//! git checkout -- .` — leaves `before == after` and records nothing. Writes to
48//! `.gitignore`d paths are invisible too, deliberately: `--ignored` would make
49//! every `cargo build` register as a mutation and put the terminal permanently
50//! out of reach.
51//!
52//! Both falsify the weaker claim ("this session edited nothing"). Neither
53//! reaches the terminal that matters, and the reason is worth being precise
54//! about, because it is what the design actually rests on:
55//!
56//! **the autonomous path is gated on the baseline, and the baseline is computed
57//! before the loop and held in memory.** No amount of editing, reverting,
58//! committing or hiding during the session can retro-change it. A genuinely
59//! hard task has a red baseline, so `baseline_gates_nothing` is false and the
60//! nomination routes to a human at bucket 3 — a park, never an exit-0 success.
61//! The give-up scenario this module exists to prevent therefore cannot be
62//! reached by defeating the fingerprint; it can only be reached by defeating the
63//! baseline, which the model never gets to touch.
64//!
65//! What the fingerprint and the ledger buy on top is the integrity of the
66//! *report*: a session that says "I changed nothing" should not have changed
67//! something. That is worth having, and it is a weaker claim than the terminal.
68
69use super::contract::{baseline_gates_nothing, CheckResult};
70use super::session::{ContractProvenance, NoChangeKind};
71
72/// Upper bound on the nominated text. Generous for a real finding, small enough
73/// that a transcript dump cannot be laundered into an issue body.
74pub const MAX_FINDING_TEXT: usize = 4096;
75
76/// Latches once a mutating tool has succeeded, and never clears.
77///
78/// See the module docs on why this does not reset when the worktree is restored.
79#[derive(Debug, Default)]
80pub struct MutationLedger {
81    mutated: std::sync::atomic::AtomicBool,
82}
83
84impl MutationLedger {
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Record that a mutating tool call succeeded. Idempotent, and one-way.
90    pub fn record_mutation(&self) {
91        self.mutated
92            .store(true, std::sync::atomic::Ordering::SeqCst);
93    }
94
95    pub fn has_mutated(&self) -> bool {
96        self.mutated.load(std::sync::atomic::Ordering::SeqCst)
97    }
98}
99
100/// A cheap content-sensitive summary of the worktree's state.
101///
102/// Two git reads: `git status --porcelain -uall` (which paths are modified,
103/// staged, or untracked) and `git diff HEAD` (the tracked content itself). Their
104/// hash changes whenever a tool edits, creates or deletes anything git can see.
105///
106/// **Known gap**: editing the *contents* of a file that is already untracked
107/// does not move the hash, because `status` lists the path and `diff HEAD` does
108/// not cover it. That is a narrow hole and it is not the one that matters —
109/// a nomination also requires a clean worktree, and an untracked file's mere
110/// existence already shows up in `status`, so a session cannot arrive at a
111/// "clean" fingerprint while carrying an edit git would report.
112///
113/// Returns `None` when git cannot answer, which callers must treat as
114/// "cannot establish cleanliness" rather than as clean.
115pub fn worktree_fingerprint(dir: &std::path::Path) -> Option<String> {
116    use sha2::{Digest, Sha256};
117    let mut hasher = Sha256::new();
118    for part in observation(dir)? {
119        hasher.update(&part);
120        hasher.update([0u8]);
121    }
122    Some(format!("{:x}", hasher.finalize()))
123}
124
125/// The four git reads that together define "this session changed nothing".
126///
127/// Each one closes a hole the others do not, and all four were found by
128/// reproducing the bypass against a real repository rather than by reasoning:
129///
130/// - `rev-parse HEAD` — **commit identity**. Without it,
131///   `sed -i … && git add -A && git commit` leaves `status` and `diff HEAD`
132///   both empty, so a real, persistent edit is invisible. The only thing
133///   standing against that was a line in the system prompt asking the model not
134///   to commit, and a prompt is not a control: under this feature's own threat
135///   model the reader is a model looking for a non-loss exit.
136/// - `status --porcelain -uall` — modified, staged and untracked paths.
137/// - `diff HEAD` — tracked content, so an edit staged *and* unstaged still shows.
138/// - `ls-files -v` — **index flags**. `git update-index --assume-unchanged f`
139///   changes neither status nor diff, and every later edit to that path is then
140///   invisible to both, permanently. The flag lives in `.git/`, outside
141///   everything else here. Lowercase tags and `S` mark it.
142fn observation(dir: &std::path::Path) -> Option<Vec<Vec<u8>>> {
143    let read = |args: &[&str]| -> Option<Vec<u8>> {
144        let out = std::process::Command::new("git")
145            .current_dir(dir)
146            .args(args)
147            .output()
148            .ok()?;
149        out.status.success().then_some(out.stdout)
150    };
151    Some(vec![
152        read(&["rev-parse", "HEAD"])?,
153        read(&["status", "--porcelain", "-uall"])?,
154        read(&["diff", "HEAD"])?,
155        read(&["ls-files", "-v"])?,
156    ])
157}
158
159/// Whether any path carries an index flag that hides later edits from
160/// `status`/`diff` — `assume-unchanged` (lowercase tag) or `skip-worktree`
161/// (`S`). `git ls-files -v` prefixes each path with its tag.
162fn has_hiding_index_flags(ls_files_v: &[u8]) -> bool {
163    String::from_utf8_lossy(ls_files_v).lines().any(|line| {
164        line.chars()
165            .next()
166            .is_some_and(|c| c == 'S' || c.is_ascii_lowercase())
167    })
168}
169
170/// Whether the worktree currently carries no change git can see.
171///
172/// `None` when git cannot answer. A caller must treat that as "cannot establish
173/// cleanliness", never as clean — the whole point of the check is that the
174/// burden of proof sits on the session claiming it changed nothing.
175pub fn worktree_is_pristine(dir: &std::path::Path, expected_head: &str) -> Option<bool> {
176    let parts = observation(dir)?;
177    let head = String::from_utf8_lossy(&parts[0]).trim().to_string();
178    Some(
179        head == expected_head.trim()
180            && parts[1].is_empty()
181            && parts[2].is_empty()
182            && !has_hiding_index_flags(&parts[3]),
183    )
184}
185
186/// The commit a session started on, captured before any model turn so a later
187/// `git commit` cannot pass itself off as a clean tree.
188pub fn head_commit(dir: &std::path::Path) -> Option<String> {
189    let out = std::process::Command::new("git")
190        .current_dir(dir)
191        .args(["rev-parse", "HEAD"])
192        .output()
193        .ok()?;
194    out.status
195        .success()
196        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
197}
198
199/// Why a nomination was refused. Each variant is a distinct thing to tell the
200/// model, because "try again differently" and "you cannot get here from where
201/// you are" are different instructions.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum NominationRefusal {
204    /// `summary` or `evidence` was empty, whitespace, or over the cap.
205    UnusableText(&'static str),
206    /// A mutating tool succeeded earlier in this session.
207    WorktreeWasMutated,
208    /// The worktree differs from the captured baseline right now.
209    WorktreeNotClean,
210    /// The baseline did not complete — starved by the session deadline, or a
211    /// check killed by infrastructure. A baseline that did not finish is not
212    /// evidence of anything.
213    BaselineIncomplete,
214}
215
216impl NominationRefusal {
217    /// The message handed back to the model as the tool result.
218    pub fn message(&self) -> String {
219        match self {
220            Self::UnusableText(which) => format!(
221                "report_no_change refused: `{which}` must be non-empty and under \
222                 {MAX_FINDING_TEXT} characters. State the conclusion and what you \
223                 examined to reach it."
224            ),
225            Self::WorktreeWasMutated => {
226                "report_no_change refused: this session already made a successful edit. \
227                 Reverting does not restore eligibility — a no-change finding is only \
228                 available to a session that never changed anything. Continue toward a \
229                 diff, or start a fresh session to investigate."
230                    .to_string()
231            }
232            Self::WorktreeNotClean => {
233                "report_no_change refused: the worktree differs from the baseline. \
234                 Restore it before concluding that nothing should change."
235                    .to_string()
236            }
237            Self::BaselineIncomplete => {
238                "report_no_change refused: the baseline evaluation did not complete, so \
239                 there is nothing to conclude from. This is not something you can fix — \
240                 the session needs more time or a working check environment."
241                    .to_string()
242            }
243        }
244    }
245}
246
247/// What the runtime decided to do with a nomination.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum NominationVerdict {
250    /// Terminal, with no human in the loop. The narrow path.
251    Autonomous,
252    /// Recorded, and parked for a human to accept or reject.
253    NeedsHuman,
254    /// Not recorded at all.
255    Refused(NominationRefusal),
256}
257
258/// Whether a baseline run actually finished.
259///
260/// An empty result set is not a clean baseline — it is a baseline that never
261/// ran. A check the *session clock* killed (`deadline_clamped` alongside
262/// `timed_out`) is not a verdict on the work either, which is the same
263/// distinction `code-task`'s event stream draws.
264pub fn baseline_completed(results: &[CheckResult]) -> bool {
265    !results.is_empty() && !results.iter().any(|r| r.timed_out && r.deadline_clamped)
266}
267
268/// The facts a nomination is judged against. All collected by the runtime; none
269/// asserted by the model.
270#[derive(Debug, Clone, Copy)]
271pub struct NominationContext<'a> {
272    pub kind: NoChangeKind,
273    pub summary: &'a str,
274    pub evidence: &'a str,
275    pub baseline: &'a [CheckResult],
276    pub provenance: ContractProvenance,
277    /// The worktree matches the captured baseline right now.
278    pub worktree_clean: bool,
279    /// A mutating tool has succeeded at some point this session.
280    pub mutated: bool,
281}
282
283/// Judge a nomination.
284///
285/// The ordering matters: the refusals that describe the *session's* history come
286/// before the ones about the finding's content, so a model that has already
287/// disqualified itself is told that rather than being invited to reword.
288pub fn evaluate_nomination(ctx: NominationContext<'_>) -> NominationVerdict {
289    use NominationRefusal::*;
290
291    if ctx.mutated {
292        return NominationVerdict::Refused(WorktreeWasMutated);
293    }
294    if !ctx.worktree_clean {
295        return NominationVerdict::Refused(WorktreeNotClean);
296    }
297    if !baseline_completed(ctx.baseline) {
298        return NominationVerdict::Refused(BaselineIncomplete);
299    }
300    if !usable(ctx.summary) {
301        return NominationVerdict::Refused(UnusableText("summary"));
302    }
303    if !usable(ctx.evidence) {
304        return NominationVerdict::Refused(UnusableText("evidence"));
305    }
306
307    // The one autonomous path. Every conjunct is load-bearing:
308    //   - PremiseWrong is the only shape a green baseline can speak to at all.
309    //   - baseline_gates_nothing means EVERY check passed untouched.
310    //   - is_trusted means the model did not author the checks it is citing.
311    // Drop any one and this becomes self-approval.
312    let autonomous = matches!(ctx.kind, NoChangeKind::PremiseWrong)
313        && baseline_gates_nothing(ctx.baseline)
314        && ctx.provenance.is_trusted();
315
316    if autonomous {
317        NominationVerdict::Autonomous
318    } else {
319        NominationVerdict::NeedsHuman
320    }
321}
322
323fn usable(text: &str) -> bool {
324    let trimmed = text.trim();
325    !trimmed.is_empty() && trimmed.len() <= MAX_FINDING_TEXT
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn check(name: &str, passed: bool) -> CheckResult {
333        CheckResult {
334            name: name.to_string(),
335            passed,
336            exit_code: Some(if passed { 0 } else { 1 }),
337            duration_ms: 1,
338            output_tail: String::new(),
339            timed_out: false,
340            deadline_clamped: false,
341        }
342    }
343
344    fn ctx<'a>(
345        kind: NoChangeKind,
346        baseline: &'a [CheckResult],
347        provenance: ContractProvenance,
348    ) -> NominationContext<'a> {
349        NominationContext {
350            kind,
351            summary: "the code already handles this",
352            evidence: "read handler.rs and ran the suite",
353            baseline,
354            provenance,
355            worktree_clean: true,
356            mutated: false,
357        }
358    }
359
360    #[test]
361    fn the_narrow_path_terminates_autonomously() {
362        let green = vec![check("a", true), check("b", true)];
363        assert_eq!(
364            evaluate_nomination(ctx(
365                NoChangeKind::PremiseWrong,
366                &green,
367                ContractProvenance::OperatorSupplied
368            )),
369            NominationVerdict::Autonomous
370        );
371    }
372
373    #[test]
374    fn a_model_authored_contract_never_self_approves() {
375        // The whole escape hatch, in one assertion: same green baseline, same
376        // kind, and the only difference is who wrote the checks.
377        let green = vec![check("a", true)];
378        assert_eq!(
379            evaluate_nomination(ctx(
380                NoChangeKind::PremiseWrong,
381                &green,
382                ContractProvenance::ModelDerived
383            )),
384            NominationVerdict::NeedsHuman
385        );
386    }
387
388    #[test]
389    fn a_runtime_generated_reproduction_is_trusted() {
390        // This is what lets autonomous self-triage work at all: the contract
391        // came from telemetry CAR collected before the session existed.
392        let green = vec![check("repro", true)];
393        assert_eq!(
394            evaluate_nomination(ctx(
395                NoChangeKind::PremiseWrong,
396                &green,
397                ContractProvenance::RuntimeGenerated
398            )),
399            NominationVerdict::Autonomous
400        );
401    }
402
403    #[test]
404    fn the_two_judgement_shapes_always_reach_a_human() {
405        let green = vec![check("a", true)];
406        for kind in [
407            NoChangeKind::DeliberateBehavior,
408            NoChangeKind::NonCodeDecision,
409        ] {
410            assert_eq!(
411                evaluate_nomination(ctx(kind, &green, ContractProvenance::OperatorSupplied)),
412                NominationVerdict::NeedsHuman,
413                "{kind:?} is not runtime-verifiable"
414            );
415        }
416    }
417
418    #[test]
419    fn a_red_baseline_proves_nothing_and_parks() {
420        let mixed = vec![check("a", true), check("b", false)];
421        assert_eq!(
422            evaluate_nomination(ctx(
423                NoChangeKind::PremiseWrong,
424                &mixed,
425                ContractProvenance::OperatorSupplied
426            )),
427            NominationVerdict::NeedsHuman
428        );
429    }
430
431    #[test]
432    fn editing_then_reverting_does_not_restore_eligibility() {
433        let green = vec![check("a", true)];
434        let mut c = ctx(
435            NoChangeKind::PremiseWrong,
436            &green,
437            ContractProvenance::OperatorSupplied,
438        );
439        // Worktree is clean again — the revert worked. The ledger still says no.
440        c.worktree_clean = true;
441        c.mutated = true;
442        assert_eq!(
443            evaluate_nomination(c),
444            NominationVerdict::Refused(NominationRefusal::WorktreeWasMutated)
445        );
446    }
447
448    #[test]
449    fn a_dirty_worktree_is_refused() {
450        let green = vec![check("a", true)];
451        let mut c = ctx(
452            NoChangeKind::PremiseWrong,
453            &green,
454            ContractProvenance::OperatorSupplied,
455        );
456        c.worktree_clean = false;
457        assert_eq!(
458            evaluate_nomination(c),
459            NominationVerdict::Refused(NominationRefusal::WorktreeNotClean)
460        );
461    }
462
463    #[test]
464    fn a_starved_baseline_is_not_evidence() {
465        let mut killed = check("a", false);
466        killed.timed_out = true;
467        killed.deadline_clamped = true;
468        let results = vec![killed];
469        assert_eq!(
470            evaluate_nomination(ctx(
471                NoChangeKind::PremiseWrong,
472                &results,
473                ContractProvenance::OperatorSupplied
474            )),
475            NominationVerdict::Refused(NominationRefusal::BaselineIncomplete)
476        );
477    }
478
479    #[test]
480    fn an_empty_baseline_is_not_a_clean_one() {
481        assert!(!baseline_completed(&[]));
482        assert_eq!(
483            evaluate_nomination(ctx(
484                NoChangeKind::PremiseWrong,
485                &[],
486                ContractProvenance::OperatorSupplied
487            )),
488            NominationVerdict::Refused(NominationRefusal::BaselineIncomplete)
489        );
490    }
491
492    #[test]
493    fn empty_or_oversized_text_is_refused() {
494        let green = vec![check("a", true)];
495        let mut c = ctx(
496            NoChangeKind::PremiseWrong,
497            &green,
498            ContractProvenance::OperatorSupplied,
499        );
500        c.summary = "   ";
501        assert_eq!(
502            evaluate_nomination(c),
503            NominationVerdict::Refused(NominationRefusal::UnusableText("summary"))
504        );
505
506        let huge = "x".repeat(MAX_FINDING_TEXT + 1);
507        let mut c2 = ctx(
508            NoChangeKind::PremiseWrong,
509            &green,
510            ContractProvenance::OperatorSupplied,
511        );
512        c2.evidence = &huge;
513        assert_eq!(
514            evaluate_nomination(c2),
515            NominationVerdict::Refused(NominationRefusal::UnusableText("evidence"))
516        );
517    }
518
519    #[test]
520    fn session_history_is_reported_before_content() {
521        // A disqualified session must be told it is disqualified, not invited to
522        // reword its way in.
523        let green = vec![check("a", true)];
524        let mut c = ctx(
525            NoChangeKind::PremiseWrong,
526            &green,
527            ContractProvenance::OperatorSupplied,
528        );
529        c.mutated = true;
530        c.summary = "";
531        assert_eq!(
532            evaluate_nomination(c),
533            NominationVerdict::Refused(NominationRefusal::WorktreeWasMutated)
534        );
535    }
536
537    /// Helper: a real git repo with one committed file.
538    fn git_repo() -> tempfile::TempDir {
539        let dir = tempfile::tempdir().unwrap();
540        let git = |args: &[&str]| {
541            std::process::Command::new("git")
542                .current_dir(dir.path())
543                .args(args)
544                .output()
545                .unwrap();
546        };
547        git(&["init", "-q"]);
548        git(&["config", "user.email", "t@t"]);
549        git(&["config", "user.name", "t"]);
550        std::fs::write(dir.path().join("f.txt"), "original\n").unwrap();
551        git(&["add", "-A"]);
552        git(&["commit", "-qm", "init"]);
553        dir
554    }
555
556    fn git(dir: &std::path::Path, args: &[&str]) {
557        std::process::Command::new("git")
558            .current_dir(dir)
559            .args(args)
560            .output()
561            .unwrap();
562    }
563
564    /// Committing an edit leaves `status` and `diff HEAD` both empty, so an
565    /// observation built only from those two reports a pristine tree over a
566    /// real, persistent change. Binding HEAD is what closes it.
567    ///
568    /// The only other thing standing against this is a line in the system
569    /// prompt asking the model not to commit — and under this feature's own
570    /// threat model, a prompt is not a control.
571    #[test]
572    fn a_committed_edit_is_not_a_pristine_tree() {
573        let dir = git_repo();
574        let start = head_commit(dir.path()).expect("HEAD readable");
575        let before = worktree_fingerprint(dir.path()).unwrap();
576        assert_eq!(worktree_is_pristine(dir.path(), &start), Some(true));
577
578        std::fs::write(dir.path().join("f.txt"), "mutated\n").unwrap();
579        git(dir.path(), &["add", "-A"]);
580        git(dir.path(), &["commit", "-qm", "sneak"]);
581
582        assert_ne!(
583            worktree_fingerprint(dir.path()).unwrap(),
584            before,
585            "a commit must move the fingerprint"
586        );
587        assert_eq!(
588            worktree_is_pristine(dir.path(), &start),
589            Some(false),
590            "HEAD moved, so the tree is not the one this session started on"
591        );
592    }
593
594    /// `git update-index --assume-unchanged` changes neither status nor diff,
595    /// and every later edit to that path is then invisible to both —
596    /// permanently, from metadata living in `.git/`. `ls-files -v` is the only
597    /// one of the four reads that sees it.
598    #[test]
599    fn an_assume_unchanged_flag_is_not_a_pristine_tree() {
600        let dir = git_repo();
601        let start = head_commit(dir.path()).expect("HEAD readable");
602        let before = worktree_fingerprint(dir.path()).unwrap();
603
604        git(dir.path(), &["update-index", "--assume-unchanged", "f.txt"]);
605
606        assert_ne!(
607            worktree_fingerprint(dir.path()).unwrap(),
608            before,
609            "setting the flag must itself register as a mutation"
610        );
611        assert_eq!(
612            worktree_is_pristine(dir.path(), &start),
613            Some(false),
614            "a tree that can hide later edits is not pristine"
615        );
616
617        // And the edit it was hiding stays invisible to status/diff — which is
618        // exactly why the flag itself has to be what disqualifies the session.
619        std::fs::write(dir.path().join("f.txt"), "tampered\n").unwrap();
620        assert_eq!(worktree_is_pristine(dir.path(), &start), Some(false));
621    }
622
623    #[test]
624    fn skip_worktree_is_caught_too() {
625        let dir = git_repo();
626        let start = head_commit(dir.path()).expect("HEAD readable");
627        git(dir.path(), &["update-index", "--skip-worktree", "f.txt"]);
628        assert_eq!(worktree_is_pristine(dir.path(), &start), Some(false));
629    }
630
631    #[test]
632    fn an_ordinary_edit_still_shows() {
633        let dir = git_repo();
634        let start = head_commit(dir.path()).expect("HEAD readable");
635        std::fs::write(dir.path().join("f.txt"), "edited\n").unwrap();
636        assert_eq!(worktree_is_pristine(dir.path(), &start), Some(false));
637    }
638
639    #[test]
640    fn a_head_that_does_not_match_is_never_pristine() {
641        let dir = git_repo();
642        assert_eq!(
643            worktree_is_pristine(dir.path(), "0000000000000000000000000000000000000000"),
644            Some(false)
645        );
646    }
647
648    #[test]
649    fn the_ledger_is_one_way() {
650        let ledger = MutationLedger::new();
651        assert!(!ledger.has_mutated());
652        ledger.record_mutation();
653        ledger.record_mutation();
654        assert!(ledger.has_mutated());
655    }
656
657    #[test]
658    fn trusted_provenance_has_exactly_one_untrusted_member() {
659        assert!(ContractProvenance::OperatorSupplied.is_trusted());
660        assert!(ContractProvenance::HumanConfirmed.is_trusted());
661        assert!(ContractProvenance::RuntimeGenerated.is_trusted());
662        assert!(
663            !ContractProvenance::ModelDerived.is_trusted(),
664            "a contract the session's own model wrote is never trusted — widening \
665             this is how the gate stops working"
666        );
667    }
668}