pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
//! Gate-dispatch conformance — one contract, two surfaces.
//!
//! Authorized by `docs/charters/2026-08-18-gate-dispatch-conformance.md`
//! (RATIFIED — Amos Coletti, 2026-08-18), **shape 1 only**: a conformance suite.
//! Shapes 2 and 3 (`extract-one-decide()` / shared dispatch) are explicitly NOT
//! authorized and nothing here anticipates them (N12).
//!
//! A NEW file per N10. No production file is touched by this pass.
//!
//! **The contract this pins.** `pushkin check` (stdin — the pre-commit floor's
//! path) and `pushkin hook <agent>` (the agent write-time path) render a verdict
//! for the same logical action. They share `CheckResult`/`Violation` and the
//! `verbs.rs` gate helpers, but NOT the path from payload to verdict: each has
//! its own parser, its own malformed branch, and its own rule dispatch. F48
//! proved the copies drift — the identical `Edit`/`MultiEdit` fail-open existed
//! in both and had to be fixed twice. Nothing mechanical held them in step.
//! This file is that mechanism.
//!
//! **Equality is STRUCTURAL, not byte-identical prose** (charter open question
//! #3). Byte-identity is not merely inconvenient, it is impossible today and by
//! design: `check::render` emits `(contract: user)` while
//! `hook::render_ladder_for` emits `(contract: 'user') — attempt 1/3` plus a
//! `waiver:` line, and `CheckResult::duration_ms` is wall-clock and so cannot
//! agree across two process invocations. What must agree is the verdict:
//! the decision, and the set of `file:line [rule]` triples. The surfaces
//! legitimately render differently; they may not legitimately DECIDE
//! differently.
//!
//! **Three decisions, not two.** `allow` and `block` are not the whole space —
//! a surface can also ABSTAIN, failing open on a payload it could not read.
//! Collapsing that into `allow` would score the two real divergences below as
//! agreement-with-different-prose instead of "one surface never rendered a
//! verdict at all". `Decision::FailOpen` keeps them distinct.
//!
//! **Deliberately NOT in scope.** No `--json` verdict for the hook verb (a
//! production change, and therefore a separate charter — see the parser note on
//! `hook_violation_keys`). No `PartialEq` derive on `pushkin_core::envelope`
//! (same reason). No manifest schema change, no `read_only_paths` entry, no
//! git-plane change. No peer-board `claim_violations` row: it needs board state
//! this fixture does not build, so a row for it would prove nothing. No
//! five-agent cross-product — `encode_deny` dialect coverage is
//! `tests/hook_verdicts.rs`'s job, not gate DISPATCH.

use assert_cmd::Command;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;

type TestResult = Result<(), Box<dyn std::error::Error>>;

/// The fixture arms its own gates. This repo's `pushkin.toml` has every
/// `[gates]` line commented out (the git-plane pause, 2026-08-18), so a suite
/// that leaned on the real manifest would assert nothing.
const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]

[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"

[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
read_only_paths = ["crates/**/tests/**"]
retrieval_paths = ["crates/**/*.rs"]
retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval"
"#;

const PROTECTED: &str = "pushkin.toml";
/// Committed, so the read-only predicate (glob AND present in HEAD) holds.
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
/// Under `retrieval_paths` — the read contract's target.
const SOURCE: &str = "crates/pushkin-cli/src/main.rs";
/// Mapped to a contract requiring boundary-validation.
const MAPPED: &str = "app/api/users/route.ts";
/// Neither gated nor mapped.
const UNGATED: &str = "docs/notes.md";

const RULE_UNVALIDATED: &str = "contract.boundary.unvalidated_input";
const RULE_PROTECTED: &str = "pushkin.protected_path";
const RULE_READ_ONLY: &str = "pushkin.read_only_path";
const RULE_RAW_READ: &str = "pushkin.retrieval.raw_read";

/// Both surfaces print this exact phrase on their fail-open branch —
/// `check::run` and `hook::handle_malformed`.
const FAIL_OPEN_MARKER: &str = "failing open";

// ---------------------------------------------------------------- fixture

fn git(dir: &Path, args: &[&str]) -> TestResult {
    let status = StdCommand::new("git")
        .current_dir(dir)
        .args(args)
        .status()?;
    assert!(status.success(), "git {args:?} failed");
    Ok(())
}

fn write_fixture_tree(dir: &Path) -> TestResult {
    fs::write(dir.join(PROTECTED), MANIFEST)?;
    fs::create_dir_all(dir.join("contracts"))?;
    fs::write(
        dir.join("contracts/user.zod.ts"),
        "export const user = 1;\n",
    )?;
    fs::create_dir_all(dir.join("app/api/users"))?;
    fs::write(dir.join(MAPPED), "export const handler = 1;\n")?;
    fs::create_dir_all(dir.join("crates/pushkin-cli/tests"))?;
    fs::write(dir.join(COMMITTED_TEST), "// committed suite\n")?;
    fs::create_dir_all(dir.join("crates/pushkin-cli/src"))?;
    fs::write(dir.join(SOURCE), "fn main() {}\n")?;
    fs::create_dir_all(dir.join("docs"))?;
    fs::write(dir.join(UNGATED), "notes\n")?;
    Ok(())
}

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    write_fixture_tree(dir.path())?;
    git(dir.path(), &["init", "-q", "."])?;
    git(dir.path(), &["add", "-A"])?;
    git(
        dir.path(),
        &[
            "-c",
            "user.name=Conformance Suite",
            "-c",
            "user.email=conformance@test",
            "commit",
            "-qm",
            "fixture",
        ],
    )?;
    Ok(dir)
}

// ---------------------------------------------------------------- verdict

/// What a surface DID, independent of how it said so.
///
/// `FailOpen` is a third state and not a flavour of `Allow`: the surface never
/// reached a rule at all. Two of the corpus rows below differ precisely here,
/// and a two-valued decision would report them as agreeing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
    Allow,
    Block,
    FailOpen,
}

/// The comparable verdict. `duration_ms` is absent by construction — it is
/// wall-clock, so two invocations can never agree on it (envelope.rs).
#[derive(Debug, Clone, PartialEq, Eq)]
struct Verdict {
    decision: Decision,
    /// Sorted, deduped `file:line [rule]` keys.
    violations: Vec<String>,
}

/// One logical action, rendered into both surfaces' payload dialects.
///
/// The two renderings live BESIDE the corpus (charter open question #4). The
/// alternative — "in each verb's module" — means editing
/// `crates/pushkin-cli/src/verbs/check.rs` and `.../hook.rs`, which the
/// charter's scope ruling forbids outright.
struct Case {
    name: &'static str,
    check_payload: &'static str,
    agent: &'static str,
    hook_payload: &'static str,
    /// `PUSHKIN_DAEMON` for both surfaces — the S1c warm/cold parity lever. A
    /// gate that holds cold and not warm is not a gate.
    daemon: &'static str,
    expect: Expect,
}

/// Not a bool: the diverging arm carries WHY, so the assertion message names
/// the finding instead of just failing.
enum Expect {
    Agree,
    /// The surfaces disagree today and the charter forbids the production
    /// change that would reconcile them. The row pins WHICH WAY they disagree,
    /// not merely that they do: bare inequality would still pass if `check`
    /// started emitting an allow envelope instead of abstaining, which is a
    /// different finding wearing the same test result. It also asserts the
    /// disagreement itself, so a future convergence fails loudly and forces
    /// this row to be revisited rather than drifting shut unnoticed.
    Diverges {
        check: Decision,
        hook: Decision,
        why: &'static str,
    },
}

/// The `file:line [rule]` prefix both renderers emit identically.
///
/// `check::render`          -> `  {file}:{line} [{rule}] (contract: {name})`
/// `hook::render_ladder_for`-> `  {file}:{line} [{rule}] (contract: '{name}') — attempt {n}/3`
///
/// Everything after `]` is rendering and is deliberately not compared. The
/// segment before `[` must end in `:{digits}` so a `try:` suggestion line
/// carrying brackets cannot masquerade as a violation.
fn key_from_line(line: &str) -> Option<String> {
    let (head, rest) = line.split_once(" [")?;
    let rule = rest.split_once(']')?.0;
    let head = head.trim();
    let (file, line_no) = head.rsplit_once(':')?;
    if file.is_empty() || line_no.is_empty() || !line_no.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    Some(format!("{file}:{line_no} [{rule}]"))
}

fn violation_keys(prose: &str) -> Vec<String> {
    let mut keys: Vec<String> = prose.lines().filter_map(key_from_line).collect();
    keys.sort();
    keys.dedup();
    keys
}

// ---------------------------------------------------------------- surfaces

#[derive(Debug)]
struct Raw {
    code: i32,
    stdout: String,
    stderr: String,
}

impl Raw {
    fn failed_open(&self) -> bool {
        self.stderr.contains(FAIL_OPEN_MARKER)
    }
}

/// One binary invocation, bundled so `run` stays at two arguments.
struct Invocation<'a> {
    args: &'a [&'a str],
    daemon: &'a str,
    payload: &'a str,
}

fn run(dir: &Path, call: &Invocation) -> Result<Raw, Box<dyn std::error::Error>> {
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .env("PUSHKIN_DAEMON", call.daemon)
        .args(call.args)
        .write_stdin(call.payload.to_owned())
        .output()?;
    Ok(Raw {
        code: output.status.code().unwrap_or(-1),
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
    })
}

/// The third state. `check --json` returns from its fail-open branch BEFORE
/// `emit()` runs, so an empty stdout is the abstention's signature — but only
/// when the fail-open marker is on stderr. Empty stdout without it would mean
/// the envelope went missing for some other reason, which must not be laundered
/// into a verdict.
fn abstained(raw: &Raw, case: &str) -> Verdict {
    assert!(
        raw.failed_open(),
        "{case}: check produced no envelope and did not fail open: {raw:?}"
    );
    Verdict {
        decision: Decision::FailOpen,
        violations: Vec::new(),
    }
}

/// `pushkin check --json` emits the literal §8.3 `CheckResult` envelope on
/// stdout, so the check side needs no prose parsing at all. The fail-open
/// branch returns before `emit()` is reached, so stdout is EMPTY there — which
/// is what makes the third state detectable rather than inferred.
fn check_verdict(dir: &Path, case: &Case) -> Result<Verdict, Box<dyn std::error::Error>> {
    let raw = run(
        dir,
        &Invocation {
            args: &["check", "--json"],
            daemon: case.daemon,
            payload: case.check_payload,
        },
    )?;
    if raw.stdout.trim().is_empty() {
        return Ok(abstained(&raw, case.name));
    }
    let envelope: serde_json::Value = serde_json::from_str(&raw.stdout)?;
    let decision = match envelope["decision"].as_str() {
        Some("allow") => Decision::Allow,
        Some("block") => Decision::Block,
        other => {
            return Err(format!("{}: unknown check decision {other:?}: {raw:?}", case.name).into())
        }
    };
    assert_code_corroborates(&raw, decision);
    Ok(Verdict {
        decision,
        violations: envelope_keys(&envelope),
    })
}

/// `check::emit` derives the exit code from the same result it serializes, so
/// the two can only disagree if the serialization fallback fired. That fallback
/// is deny-shaped BY DESIGN (M3: a serialization bug must never launder a block
/// into an allow), which makes an allow-carrying-exit-2 the exact shape of a
/// laundered verdict. Cheap to check here, and nothing else checks it.
fn assert_code_corroborates(raw: &Raw, decision: Decision) {
    let expected = if decision == Decision::Block { 2 } else { 0 };
    assert_eq!(
        raw.code, expected,
        "check's exit code contradicts its own envelope: {raw:?}"
    );
}

/// The same `file:line [rule]` key `violation_keys` recovers from prose, read
/// structurally instead. A field the envelope does not carry renders as `?`
/// rather than being skipped, so a schema change surfaces as a mismatch in the
/// calibration test instead of as a silently shorter violation set.
fn envelope_keys(envelope: &serde_json::Value) -> Vec<String> {
    let mut keys: Vec<String> = envelope["violations"]
        .as_array()
        .into_iter()
        .flatten()
        .map(|v| {
            format!(
                "{}:{} [{}]",
                v["file"].as_str().unwrap_or("?"),
                v["line"].as_u64().unwrap_or(0),
                v["rule"].as_str().unwrap_or("?")
            )
        })
        .collect();
    keys.sort();
    keys.dedup();
    keys
}

/// The hook verb has no `--json` verdict, so its violation set is recovered
/// from the ladder prose carried inside the agent-native deny envelope. That
/// couples this suite to `render_ladder_for`'s line format — the honest fix
/// would be a machine-readable hook verdict, which is production code and
/// therefore a separate charter. `check_prose_and_json_agree` below calibrates
/// this parser against ground truth so it is proven, not trusted.
fn hook_verdict(dir: &Path, case: &Case) -> Result<Verdict, Box<dyn std::error::Error>> {
    let raw = run(
        dir,
        &Invocation {
            args: &["hook", case.agent],
            daemon: case.daemon,
            payload: case.hook_payload,
        },
    )?;
    let Some(prose) = deny_prose(&raw.stdout) else {
        return Ok(Verdict {
            decision: if raw.failed_open() {
                Decision::FailOpen
            } else {
                Decision::Allow
            },
            violations: Vec::new(),
        });
    };
    let violations = violation_keys(&prose);
    assert!(
        !violations.is_empty(),
        "{}: hook denied but no violation line parsed — the ladder format moved: {prose}",
        case.name
    );
    Ok(Verdict {
        decision: Decision::Block,
        violations,
    })
}

/// `None` for every `encode_allow` shape (empty string, `{}`,
/// `{"decision":"allow"}`); the prose for every `encode_deny` dialect.
fn deny_prose(stdout: &str) -> Option<String> {
    let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
    for field in [
        "/hookSpecificOutput/permissionDecisionReason",
        "/reason",
        "/message",
    ] {
        if let Some(prose) = value.pointer(field).and_then(serde_json::Value::as_str) {
            return Some(prose.to_owned());
        }
    }
    None
}

// ---------------------------------------------------------------- the assertion

fn assert_conformance(dir: &Path, case: &Case) -> TestResult {
    let check = check_verdict(dir, case)?;
    let hook = hook_verdict(dir, case)?;
    match case.expect {
        Expect::Agree => assert_eq!(
            check, hook,
            "{}: the two surfaces rendered DIFFERENT verdicts for one logical \
             action. check={check:?} hook={hook:?}",
            case.name
        ),
        Expect::Diverges {
            check: want_check,
            hook: want_hook,
            why,
        } => {
            assert_ne!(
                check, hook,
                "{}: the surfaces now AGREE, but this row is recorded as \
                 divergent ({why}). If the divergence was fixed, reclassify this \
                 row to Expect::Agree and close the finding. check={check:?}",
                case.name
            );
            assert_eq!(
                (check.decision, hook.decision),
                (want_check, want_hook),
                "{}: the surfaces still disagree, but not in the recorded \
                 SHAPE ({why}). check={check:?} hook={hook:?}",
                case.name
            );
        }
    }
    Ok(())
}

// ---------------------------------------------------------------- the corpus
//
// Every row is one logical action in two dialects. A new rule class adds one
// row here and is thereby required to behave identically on both surfaces from
// the day it lands — the charter's stated purpose for this file.

const WRITE_NONCONFORMING: Case = Case {
    name: "write_nonconforming_to_mapped_glob",
    check_payload: r#"{"session_id":"gdc-01","tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"export async function POST(req){ const b = await req.json(); return Response.json(b); }"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-01","tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"export async function POST(req){ const b = await req.json(); return Response.json(b); }"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

/// The same action with the daemon eligible. S1c: a gate that holds cold and
/// not warm is not a gate, so the conformance claim is made on both paths.
const WRITE_NONCONFORMING_WARM: Case = Case {
    name: "write_nonconforming_to_mapped_glob_warm",
    daemon: "auto",
    ..WRITE_NONCONFORMING
};

const WRITE_CONFORMING: Case = Case {
    name: "write_conforming_to_mapped_glob",
    check_payload: r#"{"session_id":"gdc-02","tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"import { UserCreateSchema } from \"contracts/user.zod\";\nexport async function POST(req){ const b = UserCreateSchema.parse(await req.json()); return Response.json(b); }"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-02","tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"import { UserCreateSchema } from \"contracts/user.zod\";\nexport async function POST(req){ const b = UserCreateSchema.parse(await req.json()); return Response.json(b); }"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

const WRITE_UNGATED: Case = Case {
    name: "write_to_ungated_unmapped_path",
    check_payload: r#"{"session_id":"gdc-03","tool_name":"Write","tool_input":{"file_path":"docs/notes.md","content":"hello"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-03","tool_name":"Write","tool_input":{"file_path":"docs/notes.md","content":"hello"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

const WRITE_PROTECTED: Case = Case {
    name: "write_to_protected_path",
    check_payload: r#"{"session_id":"gdc-04","tool_name":"Write","tool_input":{"file_path":"pushkin.toml","content":"version = 1"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-04","tool_name":"Write","tool_input":{"file_path":"pushkin.toml","content":"version = 1"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

/// F48's own class: content absent, target named. Both surfaces must run the
/// path rules from the payload rather than treating it as malformed.
const EDIT_READ_ONLY: Case = Case {
    name: "edit_a_committed_read_only_test",
    check_payload: r#"{"session_id":"gdc-05","tool_name":"Edit","tool_input":{"file_path":"crates/pushkin-cli/tests/committed_suite.rs","old_string":"// committed suite","new_string":"// tampered"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-05","tool_name":"Edit","tool_input":{"file_path":"crates/pushkin-cli/tests/committed_suite.rs","old_string":"// committed suite","new_string":"// tampered"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

const MULTIEDIT_READ_ONLY: Case = Case {
    name: "multiedit_a_committed_read_only_test",
    check_payload: r#"{"session_id":"gdc-06","tool_name":"MultiEdit","tool_input":{"file_path":"crates/pushkin-cli/tests/committed_suite.rs","edits":[{"old_string":"// committed suite","new_string":"// tampered"}]}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-06","tool_name":"MultiEdit","tool_input":{"file_path":"crates/pushkin-cli/tests/committed_suite.rs","edits":[{"old_string":"// committed suite","new_string":"// tampered"}]}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

/// The one branch both surfaces reach through the SAME function
/// (`check::sweep_repo`, which `hook::evaluate` imports).
const STOP_SWEEP: Case = Case {
    name: "stop_payload_sweeps_the_repo",
    check_payload: r#"{"session_id":"gdc-07","stop_hook_active":true}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-07","stop_hook_active":true}"#,
    daemon: "off",
    expect: Expect::Agree,
};

/// F60's class: a payload neither surface can read, naming an unwaivable path.
const UNREADABLE_NAMING_PROTECTED: Case = Case {
    name: "unreadable_payload_naming_a_protected_path",
    check_payload: r#"{"session_id":"gdc-08","tool_name":"SomeToolPushkinHasNeverHeardOf","tool_input":{"target":"pushkin.toml"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-08","tool_name":"SomeToolPushkinHasNeverHeardOf","tool_input":{"target":"pushkin.toml"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

/// Cross-dialect, and agreement by two DIFFERENT routes: `hook codex` parses
/// the patch and runs `gate_delete`; `check`'s Claude-shaped parser cannot read
/// it at all and arrives via F60's token scan. Same verdict, no shared code
/// path — so nothing but this row holds them together.
const CODEX_DELETE_READ_ONLY: Case = Case {
    name: "codex_apply_patch_delete_of_a_read_only_test",
    check_payload: r#"{"session_id":"gdc-09","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Delete File: crates/pushkin-cli/tests/committed_suite.rs\n*** End Patch\n"}}"#,
    agent: "codex",
    hook_payload: r#"{"session_id":"gdc-09","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Delete File: crates/pushkin-cli/tests/committed_suite.rs\n*** End Patch\n"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

/// Cross-dialect again, and the same accidental agreement: an auggie fragment
/// reaches `pushkin.protected_path` on the hook side through
/// `normalize_auggie`, and on the check side through F60's scan.
const AUGGIE_FRAGMENT_PROTECTED: Case = Case {
    name: "auggie_fragment_targeting_a_protected_path",
    check_payload: r#"{"session_id":"gdc-10","tool_name":"str-replace-editor","tool_input":{"path":"pushkin.toml","new_str_1":"version = 2"}}"#,
    agent: "auggie",
    hook_payload: r#"{"session_id":"gdc-10","tool_name":"str-replace-editor","tool_input":{"path":"pushkin.toml","new_str_1":"version = 2"}}"#,
    daemon: "off",
    expect: Expect::Agree,
};

/// F67, arm 1. The read contract exists only on the hook surface:
/// `hook::evaluate` routes `Intent::ReadWhole` to `gate_raw_read`, while
/// `check::parse_payload` requires `tool_input.content` and so lands in the
/// malformed branch. F60's fallback cannot rescue it, because `retrieval_paths`
/// is the ONE family F60 deliberately excluded ("a READ rule — any payload
/// mentioning a source file would deny, which is intolerable noise").
/// Every step is correct alone; the aggregate is a fail-open the floor inherits.
const READ_WHOLE_GATED_SOURCE: Case = Case {
    name: "unbounded_read_of_a_retrieval_gated_source_file",
    check_payload: r#"{"session_id":"gdc-11","tool_name":"Read","tool_input":{"file_path":"crates/pushkin-cli/src/main.rs"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-11","tool_name":"Read","tool_input":{"file_path":"crates/pushkin-cli/src/main.rs"}}"#,
    daemon: "off",
    expect: Expect::Diverges {
        check: Decision::FailOpen,
        hook: Decision::Block,
        why: "F67 arm 1 — hook denies pushkin.retrieval.raw_read; check fails \
              open. The read contract has no check-verb counterpart.",
    },
};

/// F67, arm 2. Same gap reached through the shell: `hook::evaluate` matches the
/// target inside the command string via `gate_shell_read`; `check` has no
/// `Intent::Shell` branch at all.
const BASH_READ_GATED_SOURCE: Case = Case {
    name: "shell_read_of_a_retrieval_gated_source_file",
    check_payload: r#"{"session_id":"gdc-12","tool_name":"Bash","tool_input":{"command":"cat crates/pushkin-cli/src/main.rs"}}"#,
    agent: "claude",
    hook_payload: r#"{"session_id":"gdc-12","tool_name":"Bash","tool_input":{"command":"cat crates/pushkin-cli/src/main.rs"}}"#,
    daemon: "off",
    expect: Expect::Diverges {
        check: Decision::FailOpen,
        hook: Decision::Block,
        why: "F67 arm 2 — hook denies pushkin.retrieval.raw_read via \
              gate_shell_read; check has no Intent::Shell branch and fails open.",
    },
};

const CORPUS: &[&Case] = &[
    &WRITE_NONCONFORMING,
    &WRITE_NONCONFORMING_WARM,
    &WRITE_CONFORMING,
    &WRITE_UNGATED,
    &WRITE_PROTECTED,
    &EDIT_READ_ONLY,
    &MULTIEDIT_READ_ONLY,
    &STOP_SWEEP,
    &UNREADABLE_NAMING_PROTECTED,
    &CODEX_DELETE_READ_ONLY,
    &AUGGIE_FRAGMENT_PROTECTED,
    &READ_WHOLE_GATED_SOURCE,
    &BASH_READ_GATED_SOURCE,
];

// ---------------------------------------------------------------- the rows

#[test]
fn write_nonconforming_to_mapped_glob() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &WRITE_NONCONFORMING)
}

#[test]
fn write_nonconforming_to_mapped_glob_warm() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &WRITE_NONCONFORMING_WARM)
}

#[test]
fn write_conforming_to_mapped_glob() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &WRITE_CONFORMING)
}

#[test]
fn write_to_ungated_unmapped_path() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &WRITE_UNGATED)
}

#[test]
fn write_to_protected_path() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &WRITE_PROTECTED)
}

#[test]
fn edit_a_committed_read_only_test() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &EDIT_READ_ONLY)
}

#[test]
fn multiedit_a_committed_read_only_test() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &MULTIEDIT_READ_ONLY)
}

#[test]
fn stop_payload_sweeps_the_repo() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &STOP_SWEEP)
}

#[test]
fn unreadable_payload_naming_a_protected_path() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &UNREADABLE_NAMING_PROTECTED)
}

#[test]
fn codex_apply_patch_delete_of_a_read_only_test() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &CODEX_DELETE_READ_ONLY)
}

#[test]
fn auggie_fragment_targeting_a_protected_path() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &AUGGIE_FRAGMENT_PROTECTED)
}

#[test]
fn unbounded_read_of_a_retrieval_gated_source_file_diverges() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &READ_WHOLE_GATED_SOURCE)
}

#[test]
fn shell_read_of_a_retrieval_gated_source_file_diverges() -> TestResult {
    let dir = repo()?;
    assert_conformance(dir.path(), &BASH_READ_GATED_SOURCE)
}

// ---------------------------------------------------------------- instrument checks
//
// A conformance suite whose own comparator is unverified is an assertion with
// extra steps. These four pin the instrument rather than the subject.

/// Calibrates the prose parser against ground truth.
///
/// The check surface renders BOTH a typed `CheckResult` envelope (`--json`,
/// stdout) and the ladder-shaped prose (default, stderr) for the same payload.
/// So for every corpus row, the keys `violation_keys` recovers from prose must
/// equal the keys read structurally from the envelope. That is the evidence
/// that the identical parser, applied to the hook surface where no envelope
/// exists, is reporting the truth.
#[test]
fn check_prose_and_json_agree_on_every_row() -> TestResult {
    for case in CORPUS {
        let dir = repo()?;
        let structural = check_verdict(dir.path(), case)?;
        let raw = run(
            dir.path(),
            &Invocation {
                args: &["check"],
                daemon: case.daemon,
                payload: case.check_payload,
            },
        )?;
        let parsed = violation_keys(&raw.stderr);
        assert_eq!(
            structural.violations, parsed,
            "{}: the prose parser disagrees with the JSON envelope on the SAME \
             payload, so it cannot be trusted on the hook surface. stderr={}",
            case.name, raw.stderr
        );
    }
    Ok(())
}

/// The hook surface must actually be gating in this fixture. `hook::run`
/// returns `Ok(0)` silently in a detached worktree BEFORE reading the manifest,
/// so a worktree-policy change could turn every row into a vacuous allow and
/// the whole suite green. One known-block row asserted directly stops that.
#[test]
fn the_hook_surface_is_not_vacuously_allowing() -> TestResult {
    let dir = repo()?;
    let verdict = hook_verdict(dir.path(), &WRITE_PROTECTED)?;
    assert_eq!(verdict.decision, Decision::Block);
    assert!(
        verdict
            .violations
            .iter()
            .any(|k| k.contains(RULE_PROTECTED)),
        "hook must deny a protected-path write: {verdict:?}"
    );
    Ok(())
}

/// The corpus is the specification of equivalence. Under N10 this file is
/// read-only once committed, so rows cannot be quietly dropped by an agent —
/// but the floor makes that mechanical rather than a matter of review
/// attention, and names every rule the corpus claims to exercise.
#[test]
fn the_corpus_covers_every_rule_it_claims() -> TestResult {
    assert!(
        CORPUS.len() >= 13,
        "corpus rows may be added, never removed"
    );
    let names: Vec<&str> = CORPUS.iter().map(|c| c.name).collect();
    let mut sorted = names.clone();
    sorted.sort_unstable();
    sorted.dedup();
    assert_eq!(sorted.len(), names.len(), "corpus row names must be unique");

    let dir = repo()?;
    let mut seen: Vec<String> = Vec::new();
    for case in CORPUS {
        seen.extend(hook_verdict(dir.path(), case)?.violations);
    }
    for rule in [
        RULE_UNVALIDATED,
        RULE_PROTECTED,
        RULE_READ_ONLY,
        RULE_RAW_READ,
    ] {
        assert!(
            seen.iter().any(|k| k.contains(rule)),
            "no corpus row exercises {rule}; the suite would agree vacuously on it"
        );
    }
    Ok(())
}

/// `key_from_line` must reject the decorations that surround a violation line
/// in both renderers. A parser that accepted a `try:` line would inflate both
/// verdicts equally and hide a real difference underneath.
#[test]
fn the_key_parser_rejects_ladder_decoration() {
    assert_eq!(
        key_from_line(
            "  app/api/users/route.ts:1 [contract.boundary.unvalidated_input] (contract: user)"
        ),
        Some("app/api/users/route.ts:1 [contract.boundary.unvalidated_input]".to_owned()),
    );
    assert_eq!(
        key_from_line("  app/api/users/route.ts:1 [contract.boundary.unvalidated_input] (contract: 'user') — attempt 1/3"),
        Some("app/api/users/route.ts:1 [contract.boundary.unvalidated_input]".to_owned()),
        "the check and hook renderings of one violation must key identically",
    );
    // The hook renderer decorates a denial with lines the check renderer never
    // emits — the waiver pointer, the nudge, and the contract slice. The parser
    // meets those only on the hook surface, where no envelope exists to
    // calibrate against, so they are pinned here by literal instead.
    for decoration in [
        "    try: import { UserCreateSchema } from \"contracts/user.zod\"",
        "    fix: Parse the request body with UserCreateSchema [not a rule]",
        "    waiver: a human (not you) can run `pushkin waive pushkin.read_only_path`",
        "    nudge: the contract can be shown instead of searched — run `pushkin instructions` before retrying.",
        "    contract 'user' slice:",
        "      export const user = 1;",
        "pushkin: write blocked. Fix the violations below and retry.",
        "pushkin: read blocked AGAIN. Do not retry the same read — the result will be identical.",
        "pushkin: STOP. This write has been blocked 3 times. Do not attempt it again.",
        "",
    ] {
        assert_eq!(
            key_from_line(decoration),
            None,
            "decoration parsed as a violation: {decoration}"
        );
    }

    // A denial carrying two violations must yield two keys, sorted and deduped,
    // with every decoration between them discarded. This is the shape the hook
    // surface produces and the check envelope cannot calibrate.
    let ladder = "pushkin: write blocked. Fix the violations below and retry.\n          pushkin.toml:1 [pushkin.protected_path] — attempt 1/3\n            fix: a human must make it.\n            waiver: a human (not you) can run `pushkin waive pushkin.protected_path`\n          app/api/users/route.ts:1 [contract.boundary.unvalidated_input] (contract: 'user') — attempt 1/3\n            try: contract_show user";
    assert_eq!(
        violation_keys(ladder),
        vec![
            "app/api/users/route.ts:1 [contract.boundary.unvalidated_input]".to_owned(),
            "pushkin.toml:1 [pushkin.protected_path]".to_owned(),
        ],
    );
}