Skip to main content

kranz_engine/
pack.rs

1//! The pack contract (ticket `.kranz/tickets/pack-contract-gates-prompts.md`,
2//! KRZ-313 series): a pack — a directory with a `pack.toml` — declares
3//! DETERMINISTIC gates, role prompts, checklists, and artefact-store adapters
4//! that kranz validates at load and wires into the mission surfaces.
5//!
6//! This module carries the repo's IP boundary: kranz core stays domain-free,
7//! and domain knowledge (house standards, review lenses, evidence stores)
8//! ships in private packs. The contract EXTENDS the existing pack concept
9//! (`packaging/gascity/pack.toml`, schema 2, docs/gascity-citizenship.md)
10//! rather than adding a second mechanism: the schema-2 base manifest
11//! (`[pack]` name + schema) is a valid pack that simply registers nothing,
12//! and schema 3 adds the declaration sections below.
13//!
14//! WHY validation fails closed: every consuming surface (the final gate, the
15//! role-prompt builders, `kranz pack lint`) loads through the same strict
16//! path, and ANY violation — an unknown field, a wrong type, a missing
17//! required key, an empty gate command, a duplicate name, a model-judged
18//! gate kind, an engine-reserved gate name — is a load error naming the
19//! offending field, never a silently-skipped section. A pack the operator
20//! configured but kranz cannot fully account for must not quietly degrade
21//! back to pack-less behavior: the operator believes its gates run.
22//!
23//! WHY gates compose AFTER the engine floor: a pack can add to the floor,
24//! never lower, reorder, or replace it. At the final gate the orchestrator
25//! registers the engine floor gates ([`crate::contract_gates`]) into ONE
26//! [`crate::gate::GatePipeline`] FIRST and pack gates after — registration order IS the
27//! evaluation order within the deterministic section (gate.rs), so the
28//! composition is the guarantee, not a convention. This module closes the
29//! one remaining hole: a pack gate NAMED like an engine floor gate (which
30//! would be indistinguishable in reports) is refused at load against
31//! [`RESERVED_GATE_NAMES`]. Model-judged gates stay engine-only in this
32//! slice — a pack declaring `kind = "model-judged"` is refused at load.
33//!
34//! WHY checklists and artefact stores are declaration-only: this slice
35//! validates their declarations at load and reports them (lint, run-start
36//! decision) but never EXECUTES them — no checklist is checked and no
37//! artefact adapter is invoked. Declaring the shapes now means the future
38//! slices that consume them need no schema rework; executing them is
39//! deliberately out of scope.
40//!
41//! WHY [`PackGate`] carries a pre-computed outcome: [`Gate::evaluate`] is
42//! synchronous by design (gates capture everything they need at
43//! construction), while the engine's bounded shell runner
44//! (`crate::command_exec::run_shell_command_sandboxed`) is async. The orchestrator
45//! therefore runs each pack gate's command at REGISTRATION time — same
46//! cleared contract env, same active root as the contract assertions — and
47//! the gate captures the outcome; the pipeline still owns ordering and
48//! reporting, so a pack gate flows through it exactly like a live
49//! evaluation. This mirrors [`crate::merge_gate::MergeSuiteGate`]'s
50//! capture-at-construction contract.
51//!
52//! No pack configured ⇒ `load_for_config` returns `Ok(None)` and every
53//! surface behaves byte-identically to a pack-less engine.
54
55mod toml;
56
57/// The Flight Rules standards corpus (KRZ-341): the additive schema-4
58/// `[standards]` root, its strict RFC/rule loader, the normalized manifest +
59/// content digest, and the lifecycle transition lint.
60pub mod standards;
61
62/// Flight Rules resolution, approval pinning, and drift refusal (KRZ-342,
63/// design D-D/D-E/D-G): the deterministic applicability predicate over a
64/// loaded corpus, the engine-authored `standardsManifest` plan pin, and the
65/// final-validation/merge drift checks that consume only the pin and the
66/// trusted base.
67pub mod resolution;
68
69/// Flight Rules stage projections (KRZ-345, design D-D/D-F/D-G/D-J): the
70/// compact, stage-filtered renderings of the ONE approval-pinned manifest —
71/// the planning seed, the bounded plan-revision delta, and the
72/// worker/scrutiny/functional session prompts — plus the hard projection
73/// budget approval fails closed against.
74pub mod projection;
75
76use crate::gate::{ArtefactRef, Gate, GateKind, GateOutcome};
77use crate::types::{MissionConfig, Role};
78use std::collections::HashSet;
79use std::path::{Component, Path, PathBuf};
80
81/// The manifest file name inside a pack directory.
82pub const PACK_MANIFEST: &str = "pack.toml";
83
84/// The pre-existing base manifest version (`packaging/gascity`): `[pack]`
85/// name + schema only. A valid pack that registers nothing by convention
86/// (declaration sections are honored uniformly if present).
87pub const SCHEMA_BASE: u32 = 2;
88
89/// The current contract version: the base manifest plus the `[[gate]]`,
90/// `[[prompt]]`, `[[checklist]]`, and `[[artefact_store]]` sections.
91pub const SCHEMA_CONTRACT: u32 = 3;
92
93/// The Flight Rules standards version (KRZ-341): the contract sections plus
94/// the optional `[standards] root = "..."` key. A `[standards]` section at
95/// schema 2/3 is a load error naming the field — the corpus loads only
96/// where its lifecycle can be reasoned about.
97pub const SCHEMA_STANDARDS: u32 = 4;
98
99/// Engine gate names a pack gate may never claim. The first four are the
100/// contract-defect floor gates ([`crate::contract_gates`]); `merge-gate-suite`
101/// is the repo-owned merge gate ([`crate::merge_gate::MergeSuiteGate`]).
102/// Sharing a name would make a pack verdict indistinguishable from a floor
103/// verdict in every report — the one way a pack could appear to displace the
104/// floor — so it fails closed at load.
105pub const RESERVED_GATE_NAMES: &[&str] = &[
106    crate::contract_gates::VACUOUS_FILTER,
107    crate::contract_gates::WRONG_POLARITY,
108    crate::contract_gates::PASSES_ON_BASE,
109    crate::contract_gates::ENV_SENSITIVE,
110    "merge-gate-suite",
111];
112
113/// A validated pack: the manifest's declarations, load-resolved (prompt
114/// `textFile`s already read) and ready for the consuming surfaces.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Pack {
117    pub name: String,
118    pub schema: u32,
119    /// The directory the manifest was loaded from (textFile resolution root,
120    /// reported by lint and the run-start decision).
121    pub dir: PathBuf,
122    pub gates: Vec<PackGateDecl>,
123    pub prompts: Vec<PackPrompt>,
124    pub checklists: Vec<PackChecklist>,
125    pub artefact_stores: Vec<PackArtefactStore>,
126    /// The loaded Flight Rules standards corpus (KRZ-341) — `Some` exactly
127    /// when a schema-4 manifest declares `[standards] root`. Loaded EAGERLY
128    /// at pack load (same fail-closed posture as every other section): a
129    /// configured pack whose corpus cannot be fully accounted for is a load
130    /// error, never a quiet skip.
131    pub standards: Option<standards::StandardsManifest>,
132}
133
134/// One declared deterministic gate. Runs at the final gate (advisory, like
135/// the engine floor gates) against the active tree.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct PackGateDecl {
138    pub name: String,
139    pub command: String,
140    /// Merge-gate-idiom scoping: empty runs unconditionally; otherwise the
141    /// gate runs when at least one changed path equals or sits below a
142    /// prefix. Normalized (`.` components stripped) at load.
143    pub when_paths: Vec<String>,
144}
145
146/// One declared prompt: text appended to the target role's rendered prompt.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct PackPrompt {
149    pub name: String,
150    pub role: Role,
151    /// The resolved text (inline `text` verbatim, or `textFile` read at load).
152    pub text: String,
153    /// Where the text came from, for the lint surface.
154    pub source: PromptSource,
155}
156
157/// How a prompt's text was declared.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum PromptSource {
160    Inline,
161    File(String),
162}
163
164/// One declared checklist. DECLARATION-ONLY in this slice: validated at
165/// load, never executed.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct PackChecklist {
168    pub name: String,
169    pub items: Vec<String>,
170}
171
172/// One declared artefact-store adapter. DECLARATION-ONLY in this slice:
173/// validated at load, never invoked.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct PackArtefactStore {
176    pub name: String,
177    pub kind: String,
178}
179
180impl Pack {
181    /// Load and validate the pack at `dir`. `Ok(None)` means the directory
182    /// is not a pack (no `pack.toml`) — the lint surface says so plainly;
183    /// config-pointed loads ([`load_for_config`]) turn it into an error.
184    /// Any contract violation is an `Err` naming the offending field.
185    ///
186    /// Equivalent to [`Self::load_with_trust`] with
187    /// [`standards::StandardsTrust::External`] — the fail-closed default for
188    /// a directory whose repo relationship the caller has not established.
189    pub fn load(dir: &Path) -> Result<Option<Pack>, String> {
190        Self::load_with_trust(dir, standards::StandardsTrust::External)
191    }
192
193    /// [`Self::load`] with an explicit Flight Rules trust level (KRZ-341
194    /// D-A/D-J): an external/untracked pack may carry approved advisory
195    /// rules, but an effectively ENFORCED rule fails the load naming the
196    /// trust remedy.
197    pub fn load_with_trust(
198        dir: &Path,
199        trust: standards::StandardsTrust,
200    ) -> Result<Option<Pack>, String> {
201        let manifest_path = dir.join(PACK_MANIFEST);
202        let source = match std::fs::read_to_string(&manifest_path) {
203            Ok(source) => source,
204            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
205            Err(e) => return Err(format!("cannot read {}: {e}", manifest_path.display())),
206        };
207        Self::parse_with_trust(dir, &source, trust).map(Some)
208    }
209
210    /// Parse and validate a manifest's text (the load path minus the file
211    /// read, so tests exercise the identical validation). Trust defaults to
212    /// [`standards::StandardsTrust::External`]; the corpus walk still reads
213    /// `dir` (standards root, prompt textFiles).
214    pub fn parse(dir: &Path, source: &str) -> Result<Pack, String> {
215        Self::parse_with_trust(dir, source, standards::StandardsTrust::External)
216    }
217
218    /// [`Self::parse`] with an explicit Flight Rules trust level.
219    pub fn parse_with_trust(
220        dir: &Path,
221        source: &str,
222        trust: standards::StandardsTrust,
223    ) -> Result<Pack, String> {
224        let doc = toml::parse(source).map_err(|e| format!("{PACK_MANIFEST}: {e}"))?;
225        Self::from_document(dir, &doc, trust)
226    }
227
228    /// Semantic validation over the parsed document: strict per-section
229    /// fields, types, uniqueness, and the engine-name reservation.
230    fn from_document(
231        dir: &Path,
232        doc: &toml::Document,
233        trust: standards::StandardsTrust,
234    ) -> Result<Pack, String> {
235        // Unknown SECTIONS fail closed too — a mistyped `[[gates]]` must not
236        // silently register nothing.
237        for section in &doc.sections {
238            let name = match section {
239                toml::Section::Single(t) => t.name.as_str(),
240                toml::Section::Array { name, .. } => name.as_str(),
241            };
242            if ![
243                "pack",
244                "gate",
245                "prompt",
246                "checklist",
247                "artefact_store",
248                "standards",
249            ]
250            .contains(&name)
251            {
252                return Err(format!(
253                    "{PACK_MANIFEST}: unknown section `{name}` (declared sections: [pack], \
254                     [[gate]], [[prompt]], [[checklist]], [[artefact_store]], [standards])"
255                ));
256            }
257        }
258
259        let (name, schema) = manifest_header(doc)?;
260
261        let mut gates = Vec::new();
262        for (idx, item) in doc.array("gate").iter().enumerate() {
263            gates.push(load_gate(item, idx)?);
264        }
265        reject_duplicate_names("gate", gates.iter().map(|g| g.name.as_str()))?;
266
267        let mut prompts = Vec::new();
268        for (idx, item) in doc.array("prompt").iter().enumerate() {
269            prompts.push(load_prompt(item, idx, dir)?);
270        }
271        reject_duplicate_names("prompt", prompts.iter().map(|p| p.name.as_str()))?;
272
273        let mut checklists = Vec::new();
274        for (idx, item) in doc.array("checklist").iter().enumerate() {
275            checklists.push(load_checklist(item, idx)?);
276        }
277        reject_duplicate_names("checklist", checklists.iter().map(|c| c.name.as_str()))?;
278
279        let mut artefact_stores = Vec::new();
280        for (idx, item) in doc.array("artefact_store").iter().enumerate() {
281            artefact_stores.push(load_artefact_store(item, idx)?);
282        }
283        reject_duplicate_names(
284            "artefact_store",
285            artefact_stores.iter().map(|s| s.name.as_str()),
286        )?;
287
288        // Schema 4's additive section: a declared standards root loads its
289        // corpus EAGERLY (checker bindings resolve against this pack's
290        // gates), so every consuming surface sees the fully-accounted pack.
291        let standards = match standards_root_of(doc, schema)? {
292            Some(root) => Some(standards::load_from_pack_dir(dir, &root, &gates, trust)?),
293            None => None,
294        };
295
296        Ok(Pack {
297            name,
298            schema,
299            dir: dir.to_path_buf(),
300            gates,
301            prompts,
302            checklists,
303            artefact_stores,
304            standards,
305        })
306    }
307
308    /// The gates applicable to a diff: unconditional gates plus those whose
309    /// `whenPaths` match at least one changed path (the merge-gate idiom,
310    /// [`crate::merge_gate::when_paths_match`]).
311    pub fn gates_for_paths(&self, changed_paths: &[String]) -> Vec<&PackGateDecl> {
312        self.gates
313            .iter()
314            .filter(|g| crate::merge_gate::when_paths_match(&g.when_paths, changed_paths))
315            .collect()
316    }
317
318    /// The prompt block appended to `role`'s rendered prompt: one marked
319    /// section per pack prompt targeting the role, in declared order. Empty
320    /// when no prompt targets the role — the caller leaves the role prompt
321    /// (and its recorded hash) byte-identical.
322    pub fn prompt_section(&self, role: Role) -> String {
323        let mut section = String::new();
324        for prompt in self.prompts.iter().filter(|p| p.role == role) {
325            section.push_str(&format!(
326                "\n\n---\nPack guidance (pack `{}`, prompt `{}`):\n{}\n",
327                self.name,
328                prompt.name,
329                prompt.text.trim_end()
330            ));
331        }
332        section
333    }
334
335    /// One compact line naming everything the pack registered — the
336    /// run-start audit decision and the lint headline share it.
337    pub fn describe(&self) -> String {
338        let gate_names = self
339            .gates
340            .iter()
341            .map(|g| g.name.as_str())
342            .collect::<Vec<_>>()
343            .join(", ");
344        let prompt_names = self
345            .prompts
346            .iter()
347            .map(|p| format!("{}→{}", p.name, role_target_name(p.role)))
348            .collect::<Vec<_>>()
349            .join(", ");
350        // Standards summary only when a schema-4 pack declared a corpus —
351        // schema 2/3 and no-pack output stay byte-identical.
352        let standards = match &self.standards {
353            Some(m) => format!(
354                ", standards: {} RFC(s)/{} rule(s) digest sha256:{}",
355                m.rfcs.len(),
356                m.rules.len(),
357                m.digest
358            ),
359            None => String::new(),
360        };
361        format!(
362            "pack `{}` (schema {}) at {}: {} gate(s) [{}], {} prompt(s) [{}], \
363             {} checklist(s), {} artefact store(s) (checklists/stores are \
364             declaration-only: validated at load, never executed){standards}",
365            self.name,
366            self.schema,
367            self.dir.display(),
368            self.gates.len(),
369            gate_names,
370            self.prompts.len(),
371            prompt_names,
372            self.checklists.len(),
373            self.artefact_stores.len(),
374        )
375    }
376}
377
378/// Load the pack a mission config points at (`packDir`, repo-relative when
379/// not absolute). No key ⇒ `Ok(None)` — the byte-identical pack-less path.
380/// A key pointing at a non-directory or a non-pack is a misconfiguration
381/// and fails closed, exactly like an invalid manifest.
382///
383/// Flight Rules trust (KRZ-341 D-A/D-J): a repo-relative `packDir` is only
384/// [`standards::StandardsTrust::RepoTracked`] when its manifest is actually
385/// tracked beneath this repository. Absolute, symlink-escaped, and untracked
386/// paths are [`standards::StandardsTrust::External`] — advisory rules load,
387/// enforced ones fail closed naming the remedy. Approval reads repo-relative
388/// packs from the pinned base tree through [`resolution::approval_pin`].
389pub fn load_for_config(cfg: &MissionConfig, repo_root: &Path) -> Result<Option<Pack>, String> {
390    let Some(configured) = cfg.pack_dir.as_deref() else {
391        return Ok(None);
392    };
393    let raw = Path::new(configured);
394    let (dir, trust) = if raw.is_absolute() {
395        (raw.to_path_buf(), standards::StandardsTrust::External)
396    } else {
397        validate_pack_relative_path(configured, "mission config", "packDir")?;
398        let dir = repo_root.join(raw);
399        let trust = standards::trust_for_dir(repo_root, &dir);
400        (dir, trust)
401    };
402    if !dir.is_dir() {
403        return Err(format!(
404            "packDir `{configured}` resolves to {}, which is not a directory",
405            dir.display()
406        ));
407    }
408    let Some(pack) = Pack::load_with_trust(&dir, trust)? else {
409        return Err(format!(
410            "packDir `{configured}` resolves to {}, which has no {PACK_MANIFEST} — \
411             it is not a pack",
412            dir.display()
413        ));
414    };
415    Ok(Some(pack))
416}
417
418/// The multi-line lint report: what the pack registered, with the posture
419/// of each section stated (advisory gates, declaration-only sections).
420pub fn render_lint(pack: &Pack) -> String {
421    let mut out = format!(
422        "pack `{}` (schema {}) at {} — valid\n",
423        pack.name,
424        pack.schema,
425        pack.dir.display()
426    );
427    out.push_str("gates (deterministic; final-gate, after the engine floor, advisory):\n");
428    if pack.gates.is_empty() {
429        out.push_str("  (none)\n");
430    }
431    for gate in &pack.gates {
432        let scoping = if gate.when_paths.is_empty() {
433            "unconditional".to_string()
434        } else {
435            format!("whenPaths: {}", gate.when_paths.join(", "))
436        };
437        out.push_str(&format!(
438            "  - {}: `{}` ({scoping})\n",
439            gate.name, gate.command
440        ));
441    }
442    out.push_str("prompts (appended to the target role's prompt):\n");
443    if pack.prompts.is_empty() {
444        out.push_str("  (none)\n");
445    }
446    for prompt in &pack.prompts {
447        let source = match &prompt.source {
448            PromptSource::Inline => "inline text".to_string(),
449            PromptSource::File(rel) => format!("file {rel}"),
450        };
451        out.push_str(&format!(
452            "  - {} → {} ({source})\n",
453            prompt.name,
454            role_target_name(prompt.role)
455        ));
456    }
457    out.push_str("checklists (declaration-only: validated at load, never executed):\n");
458    if pack.checklists.is_empty() {
459        out.push_str("  (none)\n");
460    }
461    for checklist in &pack.checklists {
462        out.push_str(&format!(
463            "  - {} ({} item(s))\n",
464            checklist.name,
465            checklist.items.len()
466        ));
467    }
468    out.push_str("artefact stores (declaration-only: validated at load, never invoked):\n");
469    if pack.artefact_stores.is_empty() {
470        out.push_str("  (none)\n");
471    }
472    for store in &pack.artefact_stores {
473        out.push_str(&format!("  - {} (kind `{}`)\n", store.name, store.kind));
474    }
475    if let Some(manifest) = &pack.standards {
476        out.push_str(&standards::render_registration(manifest));
477    }
478    out
479}
480
481/// The pack-facing name of a prompt role target (the manifest vocabulary).
482pub fn role_target_name(role: Role) -> &'static str {
483    match role {
484        Role::Worker => "worker",
485        Role::ValidatorScrutiny => "validator-scrutiny",
486        Role::ValidatorFunctional => "validator-functional",
487        Role::Orchestrator => "orchestrator",
488    }
489}
490
491/// A pack-declared deterministic gate adapted to the first-class
492/// [`crate::gate::Gate`] interface, registered into the final gate's shared
493/// pipeline AFTER the engine floor gates. The outcome is captured at
494/// construction (see the module docs for the async/sync bridge); the gate
495/// is boolean-only — it reports no confidence score.
496pub struct PackGate {
497    name: String,
498    outcome: GateOutcome,
499}
500
501impl PackGate {
502    /// Build the gate from its command's already-completed bounded run:
503    /// `ok`/`output` are the engine shell runner's result for `command`.
504    /// The artefact mirrors [`crate::merge_gate::MergeSuiteGate`]: the
505    /// command line is the reference, and a failure carries the output tail.
506    pub fn from_run(name: &str, command: &str, ok: bool, output: String) -> Self {
507        let artefact = ArtefactRef::new(command.to_string());
508        let outcome = if ok {
509            GateOutcome::pass(artefact)
510        } else {
511            GateOutcome::fail(artefact.with_detail(output))
512        };
513        Self {
514            name: name.to_string(),
515            outcome,
516        }
517    }
518
519    /// Attach the stable Flight Rules ids whose checker is this gate. The
520    /// command verdict remains untouched; this is only the structured D-H
521    /// evidence join carried onto `gate.result`.
522    pub fn with_rule_ids(mut self, rule_ids: Vec<String>) -> Self {
523        self.outcome = self.outcome.with_rule_ids(rule_ids);
524        self
525    }
526}
527
528impl Gate for PackGate {
529    fn name(&self) -> &str {
530        &self.name
531    }
532
533    fn kind(&self) -> GateKind {
534        GateKind::Deterministic
535    }
536
537    fn evaluate(&self) -> GateOutcome {
538        self.outcome.clone()
539    }
540}
541
542// ---------------------------------------------------------------------------
543// Per-section validation
544// ---------------------------------------------------------------------------
545
546/// The `[pack]` header: name + schema version, with the strict field/type
547/// checks every load path shares. Factored out of `from_document` so the
548/// Flight Rules base-ref loader ([`standards::load_at_ref`]) validates a
549/// tracked pack.toml through the SAME code as a worktree load.
550fn manifest_header(doc: &toml::Document) -> Result<(String, u32), String> {
551    let header = doc
552        .single("pack")
553        .ok_or_else(|| format!("{PACK_MANIFEST}: missing required table `[pack]`"))?;
554    check_unknown(header, "[pack]", &["name", "schema"])?;
555    let name = required_string(header, "[pack]", "name")?;
556    let schema = match header.get("schema") {
557        Some(toml::Value::Integer(n)) => {
558            let n = *n;
559            if n == i64::from(SCHEMA_BASE)
560                || n == i64::from(SCHEMA_CONTRACT)
561                || n == i64::from(SCHEMA_STANDARDS)
562            {
563                n as u32
564            } else {
565                return Err(format!(
566                    "[pack] field `schema` is {n}: supported versions are {SCHEMA_BASE} \
567                     (base manifest), {SCHEMA_CONTRACT} (contract), and {SCHEMA_STANDARDS} \
568                     (standards)"
569                ));
570            }
571        }
572        Some(v) => {
573            return Err(format!(
574                "[pack] field `schema` must be an integer, got {}",
575                v.type_name()
576            ))
577        }
578        None => return Err("[pack] is missing required field `schema`".to_string()),
579    };
580    Ok((name, schema))
581}
582
583/// The normalized `[standards] root` path, when declared (KRZ-341). The
584/// section is valid ONLY at schema 4 — at schema 2/3 it is a load error
585/// naming the field — and unknown keys inside it fail closed. The root is a
586/// pack-relative path without parent components, normalized like every
587/// other pack path.
588fn standards_root_of(doc: &toml::Document, schema: u32) -> Result<Option<String>, String> {
589    let Some(table) = doc.single("standards") else {
590        return Ok(None);
591    };
592    if schema != SCHEMA_STANDARDS {
593        return Err(format!(
594            "[standards] requires [pack] field `schema` = {SCHEMA_STANDARDS} (this pack \
595             declares schema {schema}) — the standards root is additive at schema \
596             {SCHEMA_STANDARDS} only"
597        ));
598    }
599    check_unknown(table, "[standards]", &["root"])?;
600    let raw = required_string(table, "[standards]", "root")?;
601    validate_pack_relative_path(&raw, "[standards]", "root")?;
602    let normalized = crate::merge_gate::normalize_relative_path(&raw, false);
603    if normalized.is_empty() || normalized == "." {
604        return Err("[standards] field `root` must name a pack-relative directory".to_string());
605    }
606    Ok(Some(normalized))
607}
608
609/// A stable label for one `[[section]]` item, carrying its declared name
610/// when readable so errors point at the entry AND the field.
611fn entry_label(section: &str, index: usize, table: &toml::Table) -> String {
612    match table.get("name") {
613        Some(toml::Value::String(name)) => {
614            format!("[[{section}]] entry {} (name `{name}`)", index + 1)
615        }
616        _ => format!("[[{section}]] entry {}", index + 1),
617    }
618}
619
620/// Refuse any key the section's contract does not declare (the
621/// unknown-field failure class).
622fn check_unknown(table: &toml::Table, section: &str, known: &[&str]) -> Result<(), String> {
623    let unknown = table.unknown_keys(known);
624    if let Some(field) = unknown.first() {
625        return Err(format!(
626            "{section} has unknown field `{field}` (declared fields: {})",
627            known.join(", ")
628        ));
629    }
630    Ok(())
631}
632
633/// A required, non-empty string field.
634fn required_string(table: &toml::Table, section: &str, key: &str) -> Result<String, String> {
635    match table.get(key) {
636        Some(toml::Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
637        Some(toml::Value::String(_)) => Err(format!(
638            "{section} field `{key}` must be a non-empty string"
639        )),
640        Some(v) => Err(format!(
641            "{section} field `{key}` must be a string, got {}",
642            v.type_name()
643        )),
644        None => Err(format!("{section} is missing required field `{key}`")),
645    }
646}
647
648/// An optional string field (absent ⇒ None; present-but-wrong-type ⇒ Err).
649fn optional_string(
650    table: &toml::Table,
651    section: &str,
652    key: &str,
653) -> Result<Option<String>, String> {
654    match table.get(key) {
655        Some(toml::Value::String(s)) => Ok(Some(s.clone())),
656        Some(v) => Err(format!(
657            "{section} field `{key}` must be a string, got {}",
658            v.type_name()
659        )),
660        None => Ok(None),
661    }
662}
663
664/// An optional array-of-non-empty-strings field (absent ⇒ empty vec).
665fn optional_string_array(
666    table: &toml::Table,
667    section: &str,
668    key: &str,
669) -> Result<Vec<String>, String> {
670    match table.get(key) {
671        Some(toml::Value::Array(items)) => {
672            let mut out = Vec::with_capacity(items.len());
673            for (idx, item) in items.iter().enumerate() {
674                match item {
675                    toml::Value::String(s) if !s.trim().is_empty() => out.push(s.clone()),
676                    toml::Value::String(_) => {
677                        return Err(format!(
678                            "{section} field `{key}` element {} must be a non-empty string",
679                            idx + 1
680                        ))
681                    }
682                    v => {
683                        return Err(format!(
684                            "{section} field `{key}` element {} must be a string, got {}",
685                            idx + 1,
686                            v.type_name()
687                        ))
688                    }
689                }
690            }
691            Ok(out)
692        }
693        Some(v) => Err(format!(
694            "{section} field `{key}` must be an array of strings, got {}",
695            v.type_name()
696        )),
697        None => Ok(Vec::new()),
698    }
699}
700
701/// `[[gate]]`: name, command, optional kind (deterministic only) and
702/// whenPaths (merge-gate idiom).
703fn load_gate(table: &toml::Table, index: usize) -> Result<PackGateDecl, String> {
704    let section = entry_label("gate", index, table);
705    check_unknown(table, &section, &["name", "kind", "command", "whenPaths"])?;
706    let name = required_string(table, &section, "name")?;
707    if let Some(kind) = optional_string(table, &section, "kind")? {
708        if kind != "deterministic" {
709            return Err(format!(
710                "{section} field `kind` is `{kind}`: packs may register only deterministic \
711                 gates in this slice — model-judged gates are declared by the engine, \
712                 never by a pack"
713            ));
714        }
715    }
716    if RESERVED_GATE_NAMES.contains(&name.as_str()) {
717        return Err(format!(
718            "{section} field `name` is `{name}`: reserved for an engine floor gate — \
719             a pack can add gates after the floor, never impersonate it"
720        ));
721    }
722    let command = required_string(table, &section, "command")?;
723    if command.contains(['\n', '\r', '\0']) {
724        return Err(format!(
725            "{section} field `command` must be a single non-NUL line"
726        ));
727    }
728    let mut when_paths = Vec::new();
729    for raw in optional_string_array(table, &section, "whenPaths")? {
730        validate_pack_relative_path(&raw, &section, "whenPaths")?;
731        let normalized = crate::merge_gate::normalize_relative_path(&raw, false);
732        if normalized.is_empty() || normalized == "." {
733            return Err(format!(
734                "{section} field `whenPaths` entries must name a repo path — \
735                 omit whenPaths to run unconditionally"
736            ));
737        }
738        when_paths.push(normalized);
739    }
740    Ok(PackGateDecl {
741        name,
742        command,
743        when_paths,
744    })
745}
746
747/// `[[prompt]]`: name, role target, exactly one of text / textFile. The
748/// text is resolved AT LOAD (files read once, here) so every consuming
749/// surface sees identical bytes or the load fails closed.
750fn load_prompt(table: &toml::Table, index: usize, pack_dir: &Path) -> Result<PackPrompt, String> {
751    let section = entry_label("prompt", index, table);
752    check_unknown(table, &section, &["name", "role", "text", "textFile"])?;
753    let name = required_string(table, &section, "name")?;
754    let role_raw = required_string(table, &section, "role")?;
755    let role = match role_raw.as_str() {
756        "worker" => Role::Worker,
757        "validator-scrutiny" => Role::ValidatorScrutiny,
758        "validator-functional" => Role::ValidatorFunctional,
759        other => {
760            return Err(format!(
761                "{section} field `role` is `{other}`: supported targets are `worker`, \
762                 `validator-scrutiny`, `validator-functional` (the session roles whose \
763                 prompts runner.rs builds)"
764            ))
765        }
766    };
767    let inline = optional_string(table, &section, "text")?;
768    let file = optional_string(table, &section, "textFile")?;
769    let (text, source) = match (inline, file) {
770        (Some(_), Some(_)) => {
771            return Err(format!(
772                "{section} declares both `text` and `textFile` — exactly one is required"
773            ))
774        }
775        (None, None) => {
776            return Err(format!(
777                "{section} is missing required field `text` (or `textFile`)"
778            ))
779        }
780        (Some(text), None) => (text, PromptSource::Inline),
781        (None, Some(rel)) => {
782            validate_pack_relative_path(&rel, &section, "textFile")?;
783            let normalized = crate::merge_gate::normalize_relative_path(&rel, false);
784            let text = read_pack_text_file_nofollow(pack_dir, &normalized, &section)?;
785            (text, PromptSource::File(normalized))
786        }
787    };
788    if text.trim().is_empty() {
789        return Err(format!(
790            "{section} field `text` resolves to empty prompt text"
791        ));
792    }
793    Ok(PackPrompt {
794        name,
795        role,
796        text,
797        source,
798    })
799}
800
801/// `[[checklist]]`: name + non-empty items. Declaration-only.
802fn load_checklist(table: &toml::Table, index: usize) -> Result<PackChecklist, String> {
803    let section = entry_label("checklist", index, table);
804    check_unknown(table, &section, &["name", "items"])?;
805    let name = required_string(table, &section, "name")?;
806    if table.get("items").is_none() {
807        return Err(format!("{section} is missing required field `items`"));
808    }
809    let items = optional_string_array(table, &section, "items")?;
810    if items.is_empty() {
811        return Err(format!(
812            "{section} field `items` must list at least one item"
813        ));
814    }
815    Ok(PackChecklist { name, items })
816}
817
818/// `[[artefact_store]]`: name + kind. Declaration-only.
819fn load_artefact_store(table: &toml::Table, index: usize) -> Result<PackArtefactStore, String> {
820    let section = entry_label("artefact_store", index, table);
821    check_unknown(table, &section, &["name", "kind"])?;
822    let name = required_string(table, &section, "name")?;
823    let kind = required_string(table, &section, "kind")?;
824    Ok(PackArtefactStore { name, kind })
825}
826
827/// Duplicate names within one section are a load error (the
828/// duplicate-name failure class): reports and lint name entries, so a
829/// collision would make two registrations indistinguishable.
830fn reject_duplicate_names<'a>(
831    section: &str,
832    names: impl Iterator<Item = &'a str>,
833) -> Result<(), String> {
834    let mut seen = HashSet::new();
835    for name in names {
836        if !seen.insert(name) {
837            return Err(format!("duplicate [[{section}]] name `{name}`"));
838        }
839    }
840    Ok(())
841}
842
843/// A pack-relative path (textFile, whenPaths entry): non-empty, not
844/// absolute, no parent/root components — the same shape the merge-gate
845/// suite demands of its paths, with pack-worded errors.
846fn validate_pack_relative_path(raw: &str, section: &str, field: &str) -> Result<(), String> {
847    let path = Path::new(raw);
848    if raw.trim().is_empty()
849        || path.is_absolute()
850        || path
851            .components()
852            .any(|part| !matches!(part, Component::CurDir | Component::Normal(_)))
853    {
854        return Err(format!(
855            "{section} field `{field}` must be a pack-relative path without parent \
856             components: {raw:?}"
857        ));
858    }
859    Ok(())
860}
861
862/// Read a `[[prompt]]` `textFile` NO-FOLLOW from a pinned pack-directory
863/// capability (12th-pass review): lexical validation
864/// ([`validate_pack_relative_path`]) only sees path COMPONENTS, so a
865/// textFile that is a symlink — or that resolves through a symlinked
866/// parent directory — could load an engine-readable secret from outside
867/// the pack and ship it to a remote model as prompt text. The pack dir is
868/// the operator-chosen anchor (opened ambient — the same trust basis the
869/// engine uses for the repo root in [`crate::paths`]); every parent
870/// component is opened `open_dir_nofollow` and the leaf with
871/// `FollowSymlinks::No`, so a symlink anywhere below the anchor is REFUSED
872/// with an error naming the field, never followed. `rel` reaches here
873/// already normalized ([`crate::merge_gate::normalize_relative_path`]
874/// keeps only `Normal` components), so splitting on '/' yields plain
875/// names.
876fn read_pack_text_file_nofollow(
877    pack_dir: &Path,
878    rel: &str,
879    section: &str,
880) -> Result<String, String> {
881    use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
882    use std::io::Read as _;
883
884    let display = pack_dir.join(rel);
885    let field_error = |message: String| format!("{section} field `textFile` = {rel:?} {message}");
886    let no_follow_refusal = |what: &str| {
887        field_error(format!(
888            "resolves through {what} ({}) — pack prompt files load no-follow so a pack \
889             cannot read outside its own directory",
890            display.display()
891        ))
892    };
893    let mut dir = cap_std::fs::Dir::open_ambient_dir(pack_dir, cap_std::ambient_authority())
894        .map_err(|e| field_error(format!("cannot open pack dir {}: {e}", pack_dir.display())))?;
895    let mut names = rel.split('/').peekable();
896    while let Some(name) = names.next() {
897        if names.peek().is_some() {
898            dir = dir
899                .open_dir_nofollow(name)
900                .map_err(|_| no_follow_refusal("a symlinked or non-directory component"))?;
901        } else {
902            let mut options = cap_std::fs::OpenOptions::new();
903            options.read(true).follow(FollowSymlinks::No);
904            let mut file = dir.open_with(name, &options).map_err(|e| {
905                if e.kind() == std::io::ErrorKind::NotFound {
906                    field_error(format!("cannot be read at {}: {e}", display.display()))
907                } else {
908                    no_follow_refusal("a symlink or other non-regular file")
909                }
910            })?;
911            let mut text = String::new();
912            file.read_to_string(&mut text).map_err(|e| {
913                field_error(format!("cannot be read at {}: {e}", display.display()))
914            })?;
915            return Ok(text);
916        }
917    }
918    // Unreachable: validation guarantees a non-empty path of Normal
919    // components — but fail closed rather than panic if that ever changes.
920    Err(field_error("resolves to no file".to_string()))
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use crate::gate::GatePipeline;
927
928    /// A pack directory in a tempdir; returns the TempDir (kept alive by
929    /// the caller) and the pack dir inside it.
930    fn pack_dir_with(manifest: &str, files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
931        let tmp = tempfile::tempdir().expect("tempdir");
932        let dir = tmp.path().join("pack");
933        std::fs::create_dir_all(&dir).unwrap();
934        std::fs::write(dir.join(PACK_MANIFEST), manifest).unwrap();
935        for (rel, body) in files {
936            let path = dir.join(rel);
937            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
938            std::fs::write(path, body).unwrap();
939        }
940        (tmp, dir)
941    }
942
943    const FULL_MANIFEST: &str = r#"
944[pack]
945name = "zz-synthetic-pack"
946schema = 3
947
948[[gate]]
949name = "zz-gate-one"
950command = "cd ."
951
952[[gate]]
953name = "zz-gate-two"
954command = "cd ."
955whenPaths = ["src/"]
956
957[[prompt]]
958name = "zz-prompt-worker"
959role = "worker"
960text = "zz inline worker guidance"
961
962[[prompt]]
963name = "zz-prompt-scrutiny"
964role = "validator-scrutiny"
965textFile = "prompts/scrutiny.md"
966
967[[checklist]]
968name = "zz-checklist"
969items = ["first", "second"]
970
971[[artefact_store]]
972name = "zz-store"
973kind = "local-dir"
974"#;
975
976    #[test]
977    fn pack_contract_full_pack_loads_all_sections() {
978        let (_tmp, dir) =
979            pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
980        let pack = Pack::load(&dir).expect("load").expect("a pack");
981        assert_eq!(pack.name, "zz-synthetic-pack");
982        assert_eq!(pack.schema, SCHEMA_CONTRACT);
983        assert_eq!(pack.gates.len(), 2);
984        assert_eq!(pack.gates[1].when_paths, vec!["src".to_string()]);
985        assert_eq!(pack.prompts.len(), 2);
986        assert_eq!(pack.prompts[1].text, "zz file text\n");
987        assert_eq!(
988            pack.prompts[1].source,
989            PromptSource::File("prompts/scrutiny.md".to_string())
990        );
991        assert_eq!(pack.checklists[0].items.len(), 2);
992        assert_eq!(pack.artefact_stores[0].kind, "local-dir");
993    }
994
995    /// The existing concept (schema 2, `[pack]` only) loads and registers
996    /// nothing — extension, not a second mechanism.
997    #[test]
998    fn pack_contract_schema_two_base_manifest_registers_nothing() {
999        let (_tmp, dir) = pack_dir_with("[pack]\nname = \"kranz\"\nschema = 2\n", &[]);
1000        let pack = Pack::load(&dir).expect("load").expect("a pack");
1001        assert_eq!(pack.schema, SCHEMA_BASE);
1002        assert!(pack.gates.is_empty());
1003        assert!(pack.prompts.is_empty());
1004        assert!(pack.checklists.is_empty());
1005        assert!(pack.artefact_stores.is_empty());
1006    }
1007
1008    #[test]
1009    fn pack_contract_directory_without_manifest_is_not_a_pack() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        assert_eq!(Pack::load(tmp.path()).expect("load"), None);
1012    }
1013
1014    // ---- failure classes, one test each, each naming the field ---------
1015
1016    #[test]
1017    fn pack_contract_unknown_field_fails_closed() {
1018        let (_tmp, dir) = pack_dir_with(
1019            "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nbogus = 1\n",
1020            &[],
1021        );
1022        let err = Pack::load(&dir).expect_err("must fail");
1023        assert!(err.contains("unknown field `bogus`"), "{err}");
1024        assert!(err.contains("[[gate]]"), "{err}");
1025    }
1026
1027    #[test]
1028    fn pack_contract_unknown_section_fails_closed() {
1029        let (_tmp, dir) = pack_dir_with(
1030            "[pack]\nname = \"x\"\nschema = 3\n\n[[gates]]\nname = \"g\"\ncommand = \"true\"\n",
1031            &[],
1032        );
1033        let err = Pack::load(&dir).expect_err("must fail");
1034        assert!(err.contains("unknown section `gates`"), "{err}");
1035    }
1036
1037    #[test]
1038    fn pack_contract_missing_required_key_fails_closed() {
1039        // gate without command
1040        let (_tmp, dir) = pack_dir_with(
1041            "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\n",
1042            &[],
1043        );
1044        let err = Pack::load(&dir).expect_err("must fail");
1045        assert!(err.contains("missing required field `command`"), "{err}");
1046
1047        // [pack] without schema
1048        let (_tmp2, dir2) = pack_dir_with("[pack]\nname = \"x\"\n", &[]);
1049        let err = Pack::load(&dir2).expect_err("must fail");
1050        assert!(err.contains("missing required field `schema`"), "{err}");
1051    }
1052
1053    #[test]
1054    fn pack_contract_wrong_type_fails_closed() {
1055        let (_tmp, dir) = pack_dir_with("[pack]\nname = \"x\"\nschema = \"3\"\n", &[]);
1056        let err = Pack::load(&dir).expect_err("must fail");
1057        assert!(err.contains("field `schema` must be an integer"), "{err}");
1058
1059        let (_tmp2, dir2) = pack_dir_with(
1060            "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nwhenPaths = \"src\"\n",
1061            &[],
1062        );
1063        let err = Pack::load(&dir2).expect_err("must fail");
1064        assert!(
1065            err.contains("field `whenPaths` must be an array of strings"),
1066            "{err}"
1067        );
1068    }
1069
1070    #[test]
1071    fn pack_contract_duplicate_name_fails_closed() {
1072        let (_tmp, dir) = pack_dir_with(
1073            "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\n\n[[gate]]\nname = \"g\"\ncommand = \"false\"\n",
1074            &[],
1075        );
1076        let err = Pack::load(&dir).expect_err("must fail");
1077        assert!(err.contains("duplicate [[gate]] name `g`"), "{err}");
1078    }
1079
1080    #[test]
1081    fn pack_contract_model_judged_gate_kind_refused() {
1082        let (_tmp, dir) = pack_dir_with(
1083            "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nkind = \"model-judged\"\n",
1084            &[],
1085        );
1086        let err = Pack::load(&dir).expect_err("must fail");
1087        assert!(err.contains("field `kind` is `model-judged`"), "{err}");
1088        assert!(err.contains("deterministic"), "{err}");
1089    }
1090
1091    #[test]
1092    fn pack_contract_engine_floor_gate_names_are_reserved() {
1093        for reserved in RESERVED_GATE_NAMES {
1094            let manifest = format!(
1095                "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"{reserved}\"\ncommand = \"true\"\n"
1096            );
1097            let (_tmp, dir) = pack_dir_with(&manifest, &[]);
1098            let err = Pack::load(&dir).expect_err("must fail");
1099            assert!(err.contains("reserved for an engine floor gate"), "{err}");
1100        }
1101    }
1102
1103    #[test]
1104    fn pack_contract_empty_gate_command_fails_closed() {
1105        let (_tmp, dir) = pack_dir_with(
1106            "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"  \"\n",
1107            &[],
1108        );
1109        let err = Pack::load(&dir).expect_err("must fail");
1110        assert!(
1111            err.contains("field `command` must be a non-empty string"),
1112            "{err}"
1113        );
1114    }
1115
1116    #[test]
1117    fn pack_contract_unsupported_schema_fails_closed() {
1118        let (_tmp, dir) = pack_dir_with("[pack]\nname = \"x\"\nschema = 5\n", &[]);
1119        let err = Pack::load(&dir).expect_err("must fail");
1120        assert!(err.contains("field `schema` is 5"), "{err}");
1121    }
1122
1123    #[test]
1124    fn pack_contract_prompt_text_and_textfile_are_exclusive() {
1125        let (_tmp, dir) = pack_dir_with(
1126            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntext = \"t\"\ntextFile = \"p.md\"\n",
1127            &[],
1128        );
1129        let err = Pack::load(&dir).expect_err("must fail");
1130        assert!(err.contains("both `text` and `textFile`"), "{err}");
1131
1132        let (_tmp2, dir2) = pack_dir_with(
1133            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\n",
1134            &[],
1135        );
1136        let err = Pack::load(&dir2).expect_err("must fail");
1137        assert!(err.contains("missing required field `text`"), "{err}");
1138    }
1139
1140    #[test]
1141    fn pack_contract_prompt_role_must_target_a_session_role() {
1142        let (_tmp, dir) = pack_dir_with(
1143            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"orchestrator\"\ntext = \"t\"\n",
1144            &[],
1145        );
1146        let err = Pack::load(&dir).expect_err("must fail");
1147        assert!(err.contains("field `role` is `orchestrator`"), "{err}");
1148    }
1149
1150    #[test]
1151    fn pack_contract_prompt_textfile_must_stay_inside_the_pack() {
1152        let (_tmp, dir) = pack_dir_with(
1153            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"../escape.md\"\n",
1154            &[],
1155        );
1156        let err = Pack::load(&dir).expect_err("must fail");
1157        assert!(
1158            err.contains("field `textFile` must be a pack-relative path"),
1159            "{err}"
1160        );
1161
1162        let (_tmp2, dir2) = pack_dir_with(
1163            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"missing.md\"\n",
1164            &[],
1165        );
1166        let err = Pack::load(&dir2).expect_err("must fail");
1167        assert!(
1168            err.contains("field `textFile` = \"missing.md\" cannot be read"),
1169            "{err}"
1170        );
1171    }
1172
1173    #[test]
1174    fn pack_contract_checklist_requires_items() {
1175        let (_tmp, dir) = pack_dir_with(
1176            "[pack]\nname = \"x\"\nschema = 3\n\n[[checklist]]\nname = \"c\"\n",
1177            &[],
1178        );
1179        let err = Pack::load(&dir).expect_err("must fail");
1180        assert!(err.contains("missing required field `items`"), "{err}");
1181    }
1182
1183    // ---- consuming-surface behavior ------------------------------------
1184
1185    #[test]
1186    fn pack_contract_prompt_section_targets_only_the_named_role() {
1187        let (_tmp, dir) =
1188            pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
1189        let pack = Pack::load(&dir).unwrap().unwrap();
1190        let worker = pack.prompt_section(Role::Worker);
1191        assert!(worker.contains("zz-prompt-worker"), "{worker}");
1192        assert!(worker.contains("zz inline worker guidance"), "{worker}");
1193        assert!(!worker.contains("zz-prompt-scrutiny"), "{worker}");
1194        let scrutiny = pack.prompt_section(Role::ValidatorScrutiny);
1195        assert!(scrutiny.contains("zz file text"), "{scrutiny}");
1196        assert!(!scrutiny.contains("zz-prompt-worker"), "{scrutiny}");
1197        assert_eq!(pack.prompt_section(Role::ValidatorFunctional), "");
1198    }
1199
1200    #[test]
1201    fn pack_contract_when_paths_scope_gates_like_the_merge_suite() {
1202        let (_tmp, dir) =
1203            pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
1204        let pack = Pack::load(&dir).unwrap().unwrap();
1205        let changed = vec!["crates/engine/src/lib.rs".to_string()];
1206        let applicable: Vec<&str> = pack
1207            .gates_for_paths(&changed)
1208            .iter()
1209            .map(|g| g.name.as_str())
1210            .collect();
1211        assert_eq!(applicable, vec!["zz-gate-one"], "scoped gate skipped");
1212        let changed = vec!["src/widget.ts".to_string()];
1213        let applicable: Vec<&str> = pack
1214            .gates_for_paths(&changed)
1215            .iter()
1216            .map(|g| g.name.as_str())
1217            .collect();
1218        assert_eq!(applicable, vec!["zz-gate-one", "zz-gate-two"]);
1219    }
1220
1221    /// THE floor-composition guarantee: with floor gates registered FIRST
1222    /// and pack gates after, pipeline evaluation order is floor…floor, pack —
1223    /// a pack gate can never precede or displace an engine floor gate.
1224    #[test]
1225    fn pack_contract_gates_never_precede_or_displace_engine_floor_gates() {
1226        use crate::types::{Assertion, AssertionCheck};
1227        let contract = vec![Assertion {
1228            id: "a1".to_string(),
1229            statement: "s".to_string(),
1230            check: AssertionCheck::Command,
1231            command: Some("cargo test --workspace zz_pack_contract_floor 2>&1 | grep -qE 'test result: ok\\. [1-9]'".to_string()),
1232            negative_control: None,
1233            pty_script: None,
1234        }];
1235        let tree = tempfile::tempdir().unwrap();
1236        let mut pipeline = GatePipeline::new();
1237        // The orchestrator's composition: floor FIRST, pack after.
1238        crate::contract_gates::register_contract_gates(&mut pipeline, &contract, None, tree.path());
1239        let floor_len = pipeline.len();
1240        assert!(floor_len > 0, "floor gates registered");
1241        pipeline.register(Box::new(PackGate::from_run(
1242            "zz-pack-gate",
1243            "cd .",
1244            true,
1245            String::new(),
1246        )));
1247        let reports = pipeline.evaluate();
1248        let names: Vec<&str> = reports.iter().map(|r| r.name.as_str()).collect();
1249        assert_eq!(
1250            names.last(),
1251            Some(&"zz-pack-gate"),
1252            "the pack gate evaluates LAST: {names:?}"
1253        );
1254        let floor_names = &names[..floor_len];
1255        assert!(floor_names.contains(&crate::contract_gates::VACUOUS_FILTER));
1256        assert!(floor_names.contains(&crate::contract_gates::ENV_SENSITIVE));
1257        assert!(
1258            !floor_names.contains(&"zz-pack-gate"),
1259            "no pack gate inside the floor section"
1260        );
1261        assert_eq!(
1262            reports.len(),
1263            floor_len + 1,
1264            "the floor is intact — added to, never displaced"
1265        );
1266    }
1267
1268    #[test]
1269    fn pack_contract_pack_gate_carries_the_run_outcome() {
1270        use crate::gate::Gate;
1271        let pass = PackGate::from_run("g", "cd .", true, String::new());
1272        assert_eq!(pass.kind(), GateKind::Deterministic);
1273        assert!(pass.evaluate().passed());
1274        assert_eq!(pass.evaluate().artefact.reference, "cd .");
1275        let fail = PackGate::from_run("g", "cd .", false, "boom".to_string());
1276        assert!(!fail.evaluate().passed());
1277        assert_eq!(fail.evaluate().artefact.detail.as_deref(), Some("boom"));
1278        assert_eq!(fail.evaluate().score, None, "boolean-only gate");
1279    }
1280
1281    #[test]
1282    fn pack_contract_load_for_config_resolves_repo_relative_and_refuses_non_packs() {
1283        let repo = tempfile::tempdir().unwrap();
1284        // No key ⇒ None (the byte-identical pack-less path).
1285        let cfg = MissionConfig::default();
1286        assert_eq!(load_for_config(&cfg, repo.path()).unwrap(), None);
1287
1288        // Relative resolution against the repo root.
1289        let (_tmp, pack_src) = pack_dir_with("[pack]\nname = \"x\"\nschema = 3\n", &[]);
1290        let rel = repo.path().join("my-pack");
1291        std::fs::create_dir_all(&rel).unwrap();
1292        std::fs::copy(pack_src.join(PACK_MANIFEST), rel.join(PACK_MANIFEST)).unwrap();
1293        let cfg = MissionConfig {
1294            pack_dir: Some("my-pack".to_string()),
1295            ..MissionConfig::default()
1296        };
1297        let pack = load_for_config(&cfg, repo.path()).unwrap().expect("a pack");
1298        assert_eq!(pack.name, "x");
1299
1300        // A configured non-pack fails closed.
1301        std::fs::create_dir_all(repo.path().join("not-a-pack")).unwrap();
1302        let cfg = MissionConfig {
1303            pack_dir: Some("not-a-pack".to_string()),
1304            ..MissionConfig::default()
1305        };
1306        let err = load_for_config(&cfg, repo.path()).expect_err("must fail");
1307        assert!(err.contains("it is not a pack"), "{err}");
1308
1309        let cfg = MissionConfig {
1310            pack_dir: Some("missing-dir".to_string()),
1311            ..MissionConfig::default()
1312        };
1313        let err = load_for_config(&cfg, repo.path()).expect_err("must fail");
1314        assert!(err.contains("is not a directory"), "{err}");
1315
1316        // Relative configuration is containment syntax, not a path cleanup
1317        // opportunity: silently dropping `..` could load an external corpus
1318        // while labelling it repo-owned.
1319        let cfg = MissionConfig {
1320            pack_dir: Some("../pack".to_string()),
1321            ..MissionConfig::default()
1322        };
1323        let err = load_for_config(&cfg, repo.path()).expect_err("traversal must fail");
1324        assert!(err.contains("without parent components"), "{err}");
1325    }
1326
1327    // ---- textFile no-follow containment (12th-pass review) --------------
1328    //
1329    // Symlink-creating tests are unix-only, exactly like the paths.rs guard
1330    // tests (`std::os::unix::fs::symlink`); Windows needs privileges to
1331    // create symlinks, so CI coverage there comes from the no-symlink case.
1332
1333    /// A textFile that is a SYMLINK to a file outside the pack would load an
1334    /// engine-readable secret as prompt text and ship it to a remote model —
1335    /// refused at load, naming the field.
1336    #[cfg(unix)]
1337    #[test]
1338    fn pack_textfile_nofollow_refuses_a_symlinked_leaf() {
1339        use std::os::unix::fs::symlink;
1340        let tmp = tempfile::tempdir().unwrap();
1341        let outside_file = tmp.path().join("engine-readable-secret.md");
1342        std::fs::write(&outside_file, "sk-live-secret-value").unwrap();
1343        let pack = tmp.path().join("pack");
1344        std::fs::create_dir_all(pack.join("prompts")).unwrap();
1345        std::fs::write(
1346            pack.join(PACK_MANIFEST),
1347            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1348        )
1349        .unwrap();
1350        symlink(&outside_file, pack.join("prompts").join("scrutiny.md")).unwrap();
1351
1352        let err = Pack::load(&pack).expect_err("a symlinked textFile must be refused");
1353        assert!(err.contains("field `textFile`"), "names the field: {err}");
1354        assert!(err.contains("no-follow"), "says why: {err}");
1355    }
1356
1357    /// A symlinked PARENT directory escapes the pack just as surely as a
1358    /// symlinked leaf — same refusal, same named field.
1359    #[cfg(unix)]
1360    #[test]
1361    fn pack_textfile_nofollow_refuses_a_symlinked_parent_dir() {
1362        use std::os::unix::fs::symlink;
1363        let tmp = tempfile::tempdir().unwrap();
1364        let outside = tmp.path().join("outside");
1365        std::fs::create_dir_all(&outside).unwrap();
1366        std::fs::write(outside.join("scrutiny.md"), "exfiltrated prompt text").unwrap();
1367        let pack = tmp.path().join("pack");
1368        std::fs::create_dir_all(&pack).unwrap();
1369        std::fs::write(
1370            pack.join(PACK_MANIFEST),
1371            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1372        )
1373        .unwrap();
1374        symlink(&outside, pack.join("prompts")).unwrap();
1375
1376        let err = Pack::load(&pack).expect_err("a symlinked parent dir must be refused");
1377        assert!(err.contains("field `textFile`"), "names the field: {err}");
1378        assert!(err.contains("no-follow"), "says why: {err}");
1379    }
1380
1381    /// The honest path: a plain in-pack textFile still loads (the existing
1382    /// `pack_contract_full_pack_loads_all_sections` pins the same behavior
1383    /// through the full manifest).
1384    #[test]
1385    fn pack_textfile_nofollow_plain_in_pack_file_loads() {
1386        let (_tmp, dir) = pack_dir_with(
1387            "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1388            &[("prompts/scrutiny.md", "zz plain in-pack text\n")],
1389        );
1390        let pack = Pack::load(&dir).expect("load").expect("a pack");
1391        assert_eq!(pack.prompts[0].text, "zz plain in-pack text\n");
1392        assert_eq!(
1393            pack.prompts[0].source,
1394            PromptSource::File("prompts/scrutiny.md".to_string())
1395        );
1396    }
1397}