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