Skip to main content

kranz_engine/
workspace_contract.rs

1//! Tracked workspace contract (`.kranz/workspace.json`) — schema + validation.
2//!
3//! Design `docs/scoping/workspace-contract.md` D-A: a base-branch-owned,
4//! schema-versioned, additive contract describing the runnable workspace a
5//! mission expects — bootstrap, services, readiness, optional data hooks,
6//! previews, secret *names*, disk hints, and mounts. Keeping it on the base
7//! branch (like `.kranz/merge-gates.json`) prevents a mission branch from
8//! weakening the contract that judges it.
9//!
10//! Ownership of behavior:
11//! - **Missing contract ⇒ `Ok(None)`** — today's worktree-only behavior,
12//!   never an error.
13//! - **Present-but-invalid ⇒ fail closed** at draft/approve with the
14//!   violation named and the owner identified as repo setup.
15//!
16//! This module is schema + validation only. Provisioning, bootstrap, and the
17//! provider seam are later tickets (`workspace-bootstrap-preflight`,
18//! `workspace-provider-seam`); nothing here starts services or injects
19//! secrets, and secret *values* never appear in the contract, the event log,
20//! or any error message.
21//!
22//! Mount/cache-dir convention (acceptance note 2): `mounts[]` names paths
23//! the provider must make writable OUTSIDE the worktree — package caches,
24//! DB dirs — so container bootstraps do not die on the tier-3 `--read-only`
25//! boundary. v1 entries are plain strings mapping to `extra_write` grants on
26//! sandboxed runs, so they must be absolute paths without parent components.
27
28use crate::error::{EngineError, Result};
29use crate::git_ops::GitRepo;
30use serde::Deserialize;
31use std::path::Path;
32
33pub const WORKSPACE_CONTRACT_PATH: &str = ".kranz/workspace.json";
34pub const SCHEMA_VERSION: u32 = 1;
35
36#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct WorkspaceContract {
39    /// Schema version; only `1` is understood. Missing or any other value
40    /// fails closed — a newer contract must not be silently half-read.
41    #[serde(default)]
42    pub schema_version: u32,
43    /// Ordered setup commands, cwd relative to the workspace root.
44    #[serde(default)]
45    pub bootstrap: Vec<String>,
46    #[serde(default)]
47    pub services: Vec<ServiceSpec>,
48    /// Checks that must pass before the first worker turn.
49    #[serde(default)]
50    pub readiness: Vec<String>,
51    #[serde(default)]
52    pub data: Option<DataHooks>,
53    #[serde(default)]
54    pub previews: Vec<PreviewSpec>,
55    /// Names the provider must inject — never values.
56    #[serde(default)]
57    pub secrets: Vec<String>,
58    #[serde(default)]
59    pub disk: Option<DiskHints>,
60    /// Paths the provider must make writable outside the worktree (see the
61    /// module docs' mount/cache-dir convention).
62    #[serde(default)]
63    pub mounts: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct ServiceSpec {
69    pub name: String,
70    pub start: String,
71    #[serde(default)]
72    pub health_check: Option<String>,
73    pub port: ServicePort,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct ServicePort {
79    pub policy: PortPolicy,
80}
81
82/// `dynamic` — the provider allocates a free port; `{"fixed": N}` — the
83/// service must bind exactly N, colliding with any other fixed N in the
84/// same contract (refused at validation).
85#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub enum PortPolicy {
88    Dynamic,
89    Fixed(u16),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct PreviewSpec {
95    pub name: String,
96    pub url_template: String,
97}
98
99/// Optional golden-data hooks (commands). Free-form strings; secret *names*
100/// only inside them, never values.
101///
102/// Execution order (design D-D, ticket `golden-data-hooks`): `clone` runs
103/// after provision, before bootstrap; `migrate` after clone; `skewCheck` is
104/// the last readiness step — its failure is the SKEW case (Blocked, owner
105/// repo-setup, the action naming the declared migrate/reset hook), never a
106/// readiness flake. `reset` runs before each validation round when
107/// `resetBetweenRounds` opts in.
108#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct DataHooks {
111    #[serde(default)]
112    pub clone: Option<String>,
113    #[serde(default)]
114    pub migrate: Option<String>,
115    #[serde(default)]
116    pub reset: Option<String>,
117    #[serde(default)]
118    pub skew_check: Option<String>,
119    /// Opt-in to re-seeding the golden dataset before every validation
120    /// round. Requires a declared `reset` hook (validated below) — the flag
121    /// without the hook would be dead config.
122    #[serde(default)]
123    pub reset_between_rounds: bool,
124}
125
126/// Optional provider cleanup hints.
127#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129pub struct DiskHints {
130    #[serde(default)]
131    pub prune: Option<String>,
132    #[serde(default)]
133    pub retain: Option<String>,
134}
135
136/// Load and validate the contract from the repo ROOT (never the mission
137/// branch — base-branch-owned, mirroring merge-gates ownership). Missing
138/// file ⇒ `Ok(None)`; present-but-invalid ⇒ an [`EngineError`] naming the
139/// workspace contract, the specific violation, and the repo-setup owner.
140pub fn load_workspace_contract(repo_root: &Path) -> Result<Option<WorkspaceContract>> {
141    let path = repo_root.join(WORKSPACE_CONTRACT_PATH);
142    let bytes = match std::fs::read(&path) {
143        Ok(bytes) => bytes,
144        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
145        Err(e) => return Err(EngineError::Io(e)),
146    };
147    parse_workspace_contract(&bytes)
148        .map(Some)
149        .map_err(|violation| {
150            EngineError::Config(format!(
151                "workspace contract {WORKSPACE_CONTRACT_PATH} is invalid (owner: repo-setup): {violation}"
152            ))
153        })
154}
155
156/// Load the contract as COMMITTED on `ref_name` (the run-time read — design
157/// D-C/D-A): the workspace bootstrap/readiness gate reads the LIVE BASE
158/// BRANCH, mirroring merge.rs's `live_base_sha` idiom, so the contract
159/// holds in BOTH isolation modes (a mission branch cannot weaken the
160/// contract that gates its own spend — checkout mode's working tree IS the
161/// mission branch mid-run), and an operator's committed contract fix on the
162/// base branch is picked up on resume. Missing ⇒ `Ok(None)`;
163/// present-but-invalid ⇒ the same fail-closed [`EngineError`] shape as
164/// [`load_workspace_contract`].
165pub fn load_workspace_contract_at_ref(
166    repo: &GitRepo,
167    ref_name: &str,
168) -> Result<Option<WorkspaceContract>> {
169    match repo.show_file(ref_name, WORKSPACE_CONTRACT_PATH)? {
170        None => Ok(None),
171        Some(bytes) => parse_workspace_contract(&bytes)
172            .map(Some)
173            .map_err(|violation| {
174                EngineError::Config(format!(
175                    "workspace contract {WORKSPACE_CONTRACT_PATH} at {ref_name} is invalid (owner: repo-setup): {violation}"
176                ))
177            }),
178    }
179}
180
181/// Parse and validate contract bytes. Every validation failure names the
182/// specific rule it broke; parse failures carry the serde location.
183pub fn parse_workspace_contract(bytes: &[u8]) -> std::result::Result<WorkspaceContract, String> {
184    let contract: WorkspaceContract = serde_json::from_slice(bytes)
185        .map_err(|e| format!("invalid JSON in {WORKSPACE_CONTRACT_PATH}: {e}"))?;
186    validate_workspace_contract(&contract)?;
187    Ok(contract)
188}
189
190fn validate_workspace_contract(contract: &WorkspaceContract) -> std::result::Result<(), String> {
191    if contract.schema_version != SCHEMA_VERSION {
192        return Err(format!(
193            "unsupported schemaVersion {} (expected {SCHEMA_VERSION})",
194            contract.schema_version
195        ));
196    }
197
198    for (i, command) in contract.bootstrap.iter().enumerate() {
199        if command.trim().is_empty() {
200            return Err(format!("bootstrap[{i}] has an empty command string"));
201        }
202    }
203    for (i, command) in contract.readiness.iter().enumerate() {
204        if command.trim().is_empty() {
205            return Err(format!("readiness[{i}] has an empty command string"));
206        }
207    }
208
209    if let Some(data) = &contract.data {
210        for (field, hook) in [
211            ("clone", &data.clone),
212            ("migrate", &data.migrate),
213            ("reset", &data.reset),
214            ("skewCheck", &data.skew_check),
215        ] {
216            if let Some(command) = hook {
217                if command.trim().is_empty() {
218                    return Err(format!("data.{field} has an empty command string"));
219                }
220            }
221        }
222        // The flag without the hook is dead config — fail closed rather
223        // than silently never resetting.
224        if data.reset_between_rounds && data.reset.is_none() {
225            return Err("data.resetBetweenRounds requires a declared data.reset hook".to_string());
226        }
227        // The skew Block's action names the migrate/reset hook to run
228        // (design D-D) — a skewCheck with neither declared could not carry
229        // that actionable message.
230        if data.skew_check.is_some() && data.migrate.is_none() && data.reset.is_none() {
231            return Err(
232                "data.skewCheck requires a declared data.migrate or data.reset hook \
233                 (the skew Block action names it)"
234                    .to_string(),
235            );
236        }
237    }
238
239    for (i, name) in contract.secrets.iter().enumerate() {
240        if !is_secret_name(name) {
241            return Err(format!(
242                "secrets[{i}] {name:?} is not a secret NAME (expected ^[A-Z][A-Z0-9_]*$); \
243                 secret values never belong in the tracked contract"
244            ));
245        }
246    }
247
248    for (i, mount) in contract.mounts.iter().enumerate() {
249        // Mount paths describe the TARGET runtime (a Linux container or the
250        // host), not the validation host: a POSIX-absolute path is valid even
251        // when kranz itself runs on Windows, where Path::is_absolute would
252        // reject it for lacking a drive letter. Accept both forms, and check
253        // '..' across both separators.
254        if !is_contract_absolute(mount) || has_parent_components(mount) {
255            return Err(format!(
256                "mounts[{i}] {mount:?} must be an absolute path without '..' components"
257            ));
258        }
259    }
260
261    for (i, service) in contract.services.iter().enumerate() {
262        if contract.services[..i]
263            .iter()
264            .any(|s| s.name == service.name)
265        {
266            return Err(format!("duplicate service name {:?}", service.name));
267        }
268        if let PortPolicy::Fixed(fixed) = &service.port.policy {
269            if let Some(other) = contract.services[..i]
270                .iter()
271                .find(|s| matches!(&s.port.policy, PortPolicy::Fixed(f) if f == fixed))
272            {
273                return Err(format!(
274                    "fixed port {fixed} collides between services {:?} and {:?}",
275                    other.name, service.name
276                ));
277            }
278        }
279    }
280
281    for (i, preview) in contract.previews.iter().enumerate() {
282        if contract.previews[..i]
283            .iter()
284            .any(|p| p.name == preview.name)
285        {
286            return Err(format!("duplicate preview name {:?}", preview.name));
287        }
288    }
289
290    Ok(())
291}
292
293/// A secret NAME (env-var shape), not a value: `^[A-Z][A-Z0-9_]*$`.
294fn is_secret_name(name: &str) -> bool {
295    let mut chars = name.chars();
296    match chars.next() {
297        Some(first) if first.is_ascii_uppercase() => {}
298        _ => return false,
299    }
300    chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
301}
302
303/// True when a mount path is absolute in EITHER the POSIX form (`/var/…`)
304/// or the Windows form (`C:\…`, `C:/…`, or a UNC `\\host\…`). Contract paths
305/// describe the target runtime, not the validation host, so both forms are
306/// valid on every platform.
307fn is_contract_absolute(mount: &str) -> bool {
308    if mount.starts_with('/') {
309        return true;
310    }
311    if mount.starts_with("\\\\") {
312        return true;
313    }
314    let bytes = mount.as_bytes();
315    bytes.len() >= 3
316        && bytes[0].is_ascii_alphabetic()
317        && bytes[1] == b':'
318        && (bytes[2] == b'\\' || bytes[2] == b'/')
319}
320
321/// True when any path component is `..` (either separator).
322fn has_parent_components(mount: &str) -> bool {
323    mount.split(['/', '\\']).any(|component| component == "..")
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn full_contract_json() -> &'static [u8] {
331        br#"{
332            "schemaVersion": 1,
333            "bootstrap": ["cargo fetch", "npm ci"],
334            "services": [
335                {
336                    "name": "api",
337                    "start": "cargo run -p api",
338                    "healthCheck": "curl -sf localhost:8080/health",
339                    "port": { "policy": "dynamic" }
340                },
341                {
342                    "name": "db",
343                    "start": "docker compose up db",
344                    "port": { "policy": { "fixed": 5432 } }
345                }
346            ],
347            "readiness": ["curl -sf localhost:8080/health", "pg_isready"],
348            "data": {
349                "clone": "pg_dump golden | psql workspace",
350                "migrate": "sqlx migrate run",
351                "reset": "dropdb workspace && createdb workspace",
352                "skewCheck": "sqlx migrate info --check",
353                "resetBetweenRounds": true
354            },
355            "previews": [
356                { "name": "app", "urlTemplate": "http://localhost:{port}/" }
357            ],
358            "secrets": ["DATABASE_URL", "STRIPE_API_KEY"],
359            "disk": { "prune": "docker system prune -f", "retain": "7d" },
360            "mounts": ["/var/cache/cargo", "/var/lib/postgres"]
361        }"#
362    }
363
364    #[test]
365    fn workspace_contract_full_schema_round_trip() {
366        let contract = parse_workspace_contract(full_contract_json()).expect("valid contract");
367        assert_eq!(contract.schema_version, 1);
368        assert_eq!(contract.bootstrap, ["cargo fetch", "npm ci"]);
369        assert_eq!(contract.services.len(), 2);
370        assert_eq!(contract.services[0].name, "api");
371        assert_eq!(
372            contract.services[0].health_check.as_deref(),
373            Some("curl -sf localhost:8080/health")
374        );
375        assert_eq!(contract.services[0].port.policy, PortPolicy::Dynamic);
376        assert_eq!(contract.services[1].port.policy, PortPolicy::Fixed(5432));
377        assert_eq!(contract.readiness.len(), 2);
378        let data = contract.data.expect("data hooks");
379        assert_eq!(data.migrate.as_deref(), Some("sqlx migrate run"));
380        assert_eq!(
381            data.skew_check.as_deref(),
382            Some("sqlx migrate info --check")
383        );
384        assert!(data.reset_between_rounds);
385        assert_eq!(contract.previews.len(), 1);
386        assert_eq!(
387            contract.previews[0].url_template,
388            "http://localhost:{port}/"
389        );
390        assert_eq!(contract.secrets, ["DATABASE_URL", "STRIPE_API_KEY"]);
391        let disk = contract.disk.expect("disk hints");
392        assert_eq!(disk.prune.as_deref(), Some("docker system prune -f"));
393        assert_eq!(contract.mounts.len(), 2);
394    }
395
396    #[test]
397    fn workspace_contract_minimal_is_valid() {
398        let contract = parse_workspace_contract(br#"{"schemaVersion": 1}"#).expect("minimal");
399        assert!(contract.bootstrap.is_empty());
400        assert!(contract.services.is_empty());
401        assert!(contract.mounts.is_empty());
402        assert!(contract.data.is_none());
403    }
404
405    #[test]
406    fn workspace_contract_missing_file_is_none_not_error() {
407        let dir = tempfile::tempdir().expect("tempdir");
408        let loaded = load_workspace_contract(dir.path()).expect("load must not error");
409        assert!(loaded.is_none());
410    }
411
412    /// Composition audit (ticket `config-fail-open-audit`): the contract's
413    /// two postures are exactly the documented pair — MISSING is `Ok(None)`
414    /// (worktree-only behavior: no bootstrap promises exist to weaken), and
415    /// PRESENT-BUT-INVALID fails closed naming the violation. Nothing in
416    /// between: a contract that parses but breaks a rule is never half-read.
417    #[test]
418    fn composition_audit_workspace_contract_missing_is_none_and_invalid_fails_closed() {
419        let dir = tempfile::tempdir().expect("tempdir");
420        assert!(load_workspace_contract(dir.path())
421            .expect("a missing contract loads as None, never an error")
422            .is_none());
423
424        for (json, needle) in [
425            // A newer schema version is never half-read.
426            (r#"{"schemaVersion": 2}"#, "unsupported schemaVersion 2"),
427            // A missing version defaults to 0 and fails closed too.
428            (r#"{"bootstrap": ["true"]}"#, "unsupported schemaVersion 0"),
429            // A secret VALUE shape in the names-only list.
430            (
431                r#"{"schemaVersion": 1, "secrets": ["sk-live-abc"]}"#,
432                "not a secret NAME",
433            ),
434            // A mount escaping the absolute-without-'..' rule.
435            (
436                r#"{"schemaVersion": 1, "mounts": ["/var/../etc"]}"#,
437                "absolute path without '..'",
438            ),
439        ] {
440            let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
441            assert!(err.contains(needle), "{json}: {err}");
442        }
443    }
444
445    #[test]
446    fn workspace_contract_loads_from_repo_root() {
447        let dir = tempfile::tempdir().expect("tempdir");
448        let kranz = dir.path().join(".kranz");
449        std::fs::create_dir_all(&kranz).unwrap();
450        std::fs::write(kranz.join("workspace.json"), full_contract_json()).unwrap();
451        let loaded = load_workspace_contract(dir.path())
452            .expect("load")
453            .expect("present");
454        assert_eq!(loaded.services.len(), 2);
455    }
456
457    #[test]
458    fn workspace_contract_invalid_json_refused() {
459        let err = parse_workspace_contract(b"{ not json").unwrap_err();
460        assert!(err.contains("invalid JSON"), "{err}");
461    }
462
463    #[test]
464    fn workspace_contract_wrong_schema_version_refused() {
465        let err = parse_workspace_contract(br#"{"schemaVersion": 2}"#).unwrap_err();
466        assert!(err.contains("unsupported schemaVersion 2"), "{err}");
467        // Missing version defaults to 0 and fails closed too.
468        let err = parse_workspace_contract(br#"{"bootstrap": ["true"]}"#).unwrap_err();
469        assert!(err.contains("unsupported schemaVersion 0"), "{err}");
470    }
471
472    #[test]
473    fn workspace_contract_duplicate_service_names_refused() {
474        let err = parse_workspace_contract(
475            br#"{"schemaVersion": 1, "services": [
476                {"name": "api", "start": "a", "port": {"policy": "dynamic"}},
477                {"name": "api", "start": "b", "port": {"policy": "dynamic"}}
478            ]}"#,
479        )
480        .unwrap_err();
481        assert!(err.contains("duplicate service name \"api\""), "{err}");
482    }
483
484    #[test]
485    fn workspace_contract_duplicate_preview_names_refused() {
486        let err = parse_workspace_contract(
487            br#"{"schemaVersion": 1, "previews": [
488                {"name": "app", "urlTemplate": "http://a/"},
489                {"name": "app", "urlTemplate": "http://b/"}
490            ]}"#,
491        )
492        .unwrap_err();
493        assert!(err.contains("duplicate preview name \"app\""), "{err}");
494    }
495
496    #[test]
497    fn workspace_contract_fixed_port_collision_refused() {
498        let err = parse_workspace_contract(
499            br#"{"schemaVersion": 1, "services": [
500                {"name": "db", "start": "a", "port": {"policy": {"fixed": 5432}}},
501                {"name": "db-replica", "start": "b", "port": {"policy": {"fixed": 5432}}}
502            ]}"#,
503        )
504        .unwrap_err();
505        assert!(
506            err.contains("fixed port 5432 collides between services \"db\" and \"db-replica\""),
507            "{err}"
508        );
509        // Same port on a dynamic sibling is fine (dynamic never collides).
510        parse_workspace_contract(
511            br#"{"schemaVersion": 1, "services": [
512                {"name": "db", "start": "a", "port": {"policy": {"fixed": 5432}}},
513                {"name": "api", "start": "b", "port": {"policy": "dynamic"}}
514            ]}"#,
515        )
516        .expect("dynamic + fixed mix is valid");
517    }
518
519    #[test]
520    fn workspace_contract_empty_bootstrap_command_refused() {
521        let err = parse_workspace_contract(
522            br#"{"schemaVersion": 1, "bootstrap": ["cargo fetch", "  "]}"#,
523        )
524        .unwrap_err();
525        assert!(
526            err.contains("bootstrap[1] has an empty command string"),
527            "{err}"
528        );
529    }
530
531    #[test]
532    fn workspace_contract_empty_readiness_command_refused() {
533        let err =
534            parse_workspace_contract(br#"{"schemaVersion": 1, "readiness": [""]}"#).unwrap_err();
535        assert!(
536            err.contains("readiness[0] has an empty command string"),
537            "{err}"
538        );
539    }
540
541    /// Data-hook validation (design D-D, ticket golden-data-hooks): empty
542    /// hook commands are refused field by field; `resetBetweenRounds`
543    /// requires the reset hook it gates; `skewCheck` requires a declared
544    /// migrate or reset hook so the skew Block's action can name it.
545    #[test]
546    fn workspace_contract_data_hooks_validate_shape_and_cross_references() {
547        for (field, json) in [
548            ("clone", r#"{"schemaVersion": 1, "data": {"clone": "  "}}"#),
549            (
550                "migrate",
551                r#"{"schemaVersion": 1, "data": {"migrate": "  "}}"#,
552            ),
553            ("reset", r#"{"schemaVersion": 1, "data": {"reset": "  "}}"#),
554            // migrate satisfies the skewCheck cross-reference so the empty
555            // command is the rule that fires.
556            (
557                "skewCheck",
558                r#"{"schemaVersion": 1, "data": {"migrate": "m", "skewCheck": "  "}}"#,
559            ),
560        ] {
561            let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
562            assert!(
563                err.contains(&format!("data.{field} has an empty command string")),
564                "{field}: {err}"
565            );
566        }
567
568        // resetBetweenRounds without a reset hook is dead config — refused.
569        let err = parse_workspace_contract(
570            br#"{"schemaVersion": 1, "data": {"resetBetweenRounds": true}}"#,
571        )
572        .unwrap_err();
573        assert!(
574            err.contains("data.resetBetweenRounds requires a declared data.reset hook"),
575            "{err}"
576        );
577        parse_workspace_contract(
578            br#"{"schemaVersion": 1, "data": {"reset": "seed", "resetBetweenRounds": true}}"#,
579        )
580        .expect("resetBetweenRounds with a reset hook is valid");
581
582        // skewCheck with neither migrate nor reset could not name the
583        // remedy in its Block action — refused; either hook suffices.
584        let err =
585            parse_workspace_contract(br#"{"schemaVersion": 1, "data": {"skewCheck": "check"}}"#)
586                .unwrap_err();
587        assert!(
588            err.contains("data.skewCheck requires a declared data.migrate or data.reset hook"),
589            "{err}"
590        );
591        parse_workspace_contract(
592            br#"{"schemaVersion": 1, "data": {"skewCheck": "check", "migrate": "m"}}"#,
593        )
594        .expect("skewCheck with migrate is valid");
595        parse_workspace_contract(
596            br#"{"schemaVersion": 1, "data": {"skewCheck": "check", "reset": "r"}}"#,
597        )
598        .expect("skewCheck with reset is valid");
599
600        // A data block may declare any subset of hooks without the flag;
601        // resetBetweenRounds defaults to false (additive serde-default).
602        let contract =
603            parse_workspace_contract(br#"{"schemaVersion": 1, "data": {"clone": "seed"}}"#)
604                .expect("clone-only data block is valid");
605        let data = contract.data.expect("data hooks");
606        assert!(!data.reset_between_rounds);
607    }
608
609    #[test]
610    fn workspace_contract_secret_value_shaped_entries_refused() {
611        for bad in [
612            "database_url",
613            "DATABASE-URL",
614            "sk-live-abc123",
615            "9LIVES",
616            "",
617        ] {
618            let json = format!(r#"{{"schemaVersion": 1, "secrets": ["DATABASE_URL", "{bad}"]}}"#);
619            let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
620            assert!(
621                err.contains("secrets[1]") && err.contains("not a secret NAME"),
622                "entry {bad:?}: {err}"
623            );
624        }
625        // data hook commands stay free-form — no NAME-shape policing there.
626        parse_workspace_contract(
627            br#"{"schemaVersion": 1, "data": {"clone": "pg_dump $golden | psql -h db workspace"}}"#,
628        )
629        .expect("data hooks are free-form commands");
630    }
631
632    #[test]
633    fn workspace_contract_mount_must_be_absolute_without_parent_components() {
634        for (bad, why) in [
635            ("relative/cache", "non-absolute"),
636            ("~/.cargo", "tilde is not absolute to Path"),
637            ("/var/cache/../outside", "parent component"),
638            ("../outside", "relative parent component"),
639        ] {
640            let json = format!(r#"{{"schemaVersion": 1, "mounts": ["{bad}"]}}"#);
641            let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
642            assert!(
643                err.contains("mounts[0]") && err.contains("absolute path without '..'"),
644                "{why}: {err}"
645            );
646        }
647        parse_workspace_contract(br#"{"schemaVersion": 1, "mounts": ["/var/cache/cargo"]}"#)
648            .expect("absolute mount without '..' is valid");
649        // Contract paths describe the target runtime, so BOTH absolute forms
650        // validate on every host platform (Path::is_absolute is host-biased).
651        for good in ["/var/cache/cargo", "C:\\cache\\cargo", "C:/cache/cargo"] {
652            let json = serde_json::json!({ "schemaVersion": 1, "mounts": [good] }).to_string();
653            parse_workspace_contract(json.as_bytes())
654                .unwrap_or_else(|e| panic!("{good:?} must validate on every platform: {e}"));
655        }
656    }
657
658    #[test]
659    fn workspace_contract_load_error_names_owner_and_path() {
660        let dir = tempfile::tempdir().expect("tempdir");
661        let kranz = dir.path().join(".kranz");
662        std::fs::create_dir_all(&kranz).unwrap();
663        std::fs::write(kranz.join("workspace.json"), br#"{"schemaVersion": 9}"#).unwrap();
664        let err = load_workspace_contract(dir.path()).unwrap_err();
665        let msg = err.to_string();
666        assert!(msg.contains("workspace contract"), "{msg}");
667        assert!(msg.contains(WORKSPACE_CONTRACT_PATH), "{msg}");
668        assert!(msg.contains("repo-setup"), "{msg}");
669        assert!(msg.contains("unsupported schemaVersion 9"), "{msg}");
670    }
671
672    /// The run-time read comes from the COMMITTED ref (the live base
673    /// branch), not the working tree: an uncommitted working-tree edit is
674    /// invisible, and another branch's copy is never read (D-A).
675    #[test]
676    fn workspace_contract_at_ref_reads_the_committed_ref_not_the_tree() {
677        let dir = tempfile::tempdir().expect("tempdir");
678        let root = dir.path();
679        let git = |args: &[&str]| {
680            let out = std::process::Command::new("git")
681                .args(args)
682                .current_dir(root)
683                .output()
684                .expect("spawn git");
685            assert!(
686                out.status.success(),
687                "git {args:?} failed: {}",
688                String::from_utf8_lossy(&out.stderr)
689            );
690        };
691        if std::process::Command::new("git")
692            .arg("--version")
693            .output()
694            .is_err()
695        {
696            crate::test_capability::skip(
697                crate::test_capability::capability::GIT,
698                "git is not on PATH",
699            );
700            return;
701        }
702        git(&["init", "-b", "main"]);
703        git(&["config", "user.name", "test"]);
704        git(&["config", "user.email", "test@example.com"]);
705        let kranz = root.join(".kranz");
706        std::fs::create_dir_all(&kranz).unwrap();
707        std::fs::write(
708            kranz.join("workspace.json"),
709            br#"{"schemaVersion": 1, "readiness": ["pg_isready"]}"#,
710        )
711        .unwrap();
712        git(&["add", "-A"]);
713        git(&["commit", "-m", "contract"]);
714
715        let repo = GitRepo::open(root).expect("open repo");
716        let loaded = load_workspace_contract_at_ref(&repo, "main")
717            .expect("load")
718            .expect("present on main");
719        assert_eq!(loaded.readiness, ["pg_isready"]);
720
721        // Uncommitted working-tree edits do not leak into the ref read.
722        std::fs::write(kranz.join("workspace.json"), br#"{"schemaVersion": 1}"#).unwrap();
723        let loaded = load_workspace_contract_at_ref(&repo, "main")
724            .expect("load")
725            .expect("still the committed contract");
726        assert_eq!(loaded.readiness, ["pg_isready"]);
727
728        // A ref without the file is `None` (missing, never an error). The
729        // orphan checkout keeps the index, so clear it before committing —
730        // otherwise the "empty" branch would still carry the contract.
731        git(&["checkout", "--orphan", "empty"]);
732        git(&["rm", "-rf", "."]);
733        git(&["commit", "--allow-empty", "-m", "empty"]);
734        assert!(load_workspace_contract_at_ref(&repo, "empty")
735            .expect("load")
736            .is_none());
737    }
738
739    /// Present-but-invalid at the ref fails closed, naming the owner.
740    #[test]
741    fn workspace_contract_at_ref_invalid_fails_closed() {
742        let dir = tempfile::tempdir().expect("tempdir");
743        let root = dir.path();
744        let git = |args: &[&str]| {
745            let out = std::process::Command::new("git")
746                .args(args)
747                .current_dir(root)
748                .output()
749                .expect("spawn git");
750            assert!(
751                out.status.success(),
752                "git {args:?} failed: {}",
753                String::from_utf8_lossy(&out.stderr)
754            );
755        };
756        if std::process::Command::new("git")
757            .arg("--version")
758            .output()
759            .is_err()
760        {
761            crate::test_capability::skip(
762                crate::test_capability::capability::GIT,
763                "git is not on PATH",
764            );
765            return;
766        }
767        git(&["init", "-b", "main"]);
768        git(&["config", "user.name", "test"]);
769        git(&["config", "user.email", "test@example.com"]);
770        let kranz = root.join(".kranz");
771        std::fs::create_dir_all(&kranz).unwrap();
772        std::fs::write(kranz.join("workspace.json"), br#"{"schemaVersion": 9}"#).unwrap();
773        git(&["add", "-A"]);
774        git(&["commit", "-m", "broken contract"]);
775
776        let repo = GitRepo::open(root).expect("open repo");
777        let err = load_workspace_contract_at_ref(&repo, "main").unwrap_err();
778        let msg = err.to_string();
779        assert!(msg.contains("workspace contract"), "{msg}");
780        assert!(msg.contains("repo-setup"), "{msg}");
781        assert!(msg.contains("unsupported schemaVersion 9"), "{msg}");
782    }
783}