Skip to main content

tsafe_core/
run_evidence.rs

1//! Run evidence — typed-evidence artifact for a single command execution.
2//!
3//! # Provenance
4//!
5//! Lifted from `algol/src/model.rs` @ `6956cfd347cd8ce492231ba5aaa4952227d72689`
6//! (commit `6956cfd`, branch `master`, repo `0ryant/algol`).
7//!
8//! Re-licensed `AGPL-3.0-or-later` per:
9//!
10//! - `ecosystem-catalog/docs/adr/draft-algol-into-tsafe-merge.md`
11//! - `ecosystem-catalog/portfolio-algol-tsafe-migration-2026-05-21.md`
12//! - `ecosystem-catalog/portfolio-algol-tsafe-phase0-audit-2026-05-21.md`
13//! - operator decision 2026-05-21 (sole legal copyright holder per algol/LICENSE
14//!   + git log; co-founder courtesy gate is not a legal blocker)
15//!
16//! # Scope
17//!
18//! `RunEvidence` and the supporting sub-structs (`ContractRef`,
19//! `InjectedSecretEvidence`, `DeniedSensitiveEnvEvidence`,
20//! `EnvironmentEvidence`, `ProcessEvidence`, `MachineEvidence`,
21//! `RiskDelta`, `EnforcementResult`).
22//!
23//! `AttestContract` lives in [`crate::attest_contract`] as a distinct type
24//! per ec Phase 0 audit §1.5.1 — `tsafe-core::contracts::AuthorityContract`
25//! (vault-policy semantics) and the algol-merged attestation contract
26//! (env-injection semantics) have zero field overlap and must not be merged
27//! at the field level.
28//!
29//! # Phase 4 wire-format changes (ec ADR-0003 + schema rename)
30//!
31//! Hash family converges to BLAKE3 (`blake3:<64 hex>`) for all four
32//! fingerprint slots in `RunEvidence`:
33//!
34//! - `contract.hash`
35//! - `environment.secrets_injected[].hash`
36//! - `environment.sensitive_env_denied[].hash`
37//! - `machine.{hostname_hash, username_hash}`
38//!
39//! Schema renames:
40//!
41//! - `algol.run.v1` -> `tsafe.run.v1` (Phase 4)
42//! - `tsafe.run.v1` -> `axiom.receipt.v1` (v2.0.0 — cohort convergence)
43//! - Field rename `algol_version` -> `tsafe_attest_version`
44//!
45//! Backward-compat deserialization: any of the prior `tsafe.run.v1` or
46//! original `algol.run.v1` schema names, the `sha256:` hash prefix on
47//! inputs, or the `algol_version` field name is still accepted on parse
48//! so existing audit-trail consumers can migrate incrementally. v2.0.0
49//! keeps the READ side open (a v2 binary verifies an old receipt) but
50//! HARD-CUTS the EMIT side: every new artifact tags
51//! `schema = "axiom.receipt.v1"` + BLAKE3 and is signed under the v2
52//! domain tag ([`crate::sign::DOMAIN_TAG_V2`]). A v1-tag signature does
53//! NOT verify under the v2 tag and vice versa (cross-protocol forgery
54//! guard); the signer/verifier dispatch keys off the parsed schema
55//! string via [`is_legacy_run_schema`].
56//!
57//! # Platform scope
58//!
59//! Linux + macOS first per ec Phase 0 audit §3 (Windows scope option (c)).
60//! Windows callers opt in via the `windows-experimental` Cargo feature
61//! (see crate `Cargo.toml`).
62
63use chrono::{DateTime, Utc};
64use serde::{Deserialize, Serialize};
65
66use crate::sign::SignaturePayload;
67
68/// Canonical schema name for the `RunEvidence` artifact (v2 — current).
69///
70/// v2.0.0 converges the signed-body schema label onto the cohort-wide
71/// `axiom.receipt.v1` name (the tflip/tdep precedent). All NEW emissions
72/// tag `schema = "axiom.receipt.v1"`. The prior [`RUN_SCHEMA`]
73/// (`tsafe.run.v1`) and the original [`LEGACY_RUN_SCHEMA`]
74/// (`algol.run.v1`) are demoted to READ-compat: still accepted on
75/// [`RunEvidence::validation_errors`] / verify so a v2 binary keeps
76/// reading old audit trails, but never emitted.
77pub const RECEIPT_SCHEMA: &str = "axiom.receipt.v1";
78
79/// Prior canonical schema name, demoted to READ-compat in v2.0.0.
80///
81/// Phase 4 renamed `algol.run.v1` -> `tsafe.run.v1`. v2.0.0 supersedes
82/// `tsafe.run.v1` with [`RECEIPT_SCHEMA`] (`axiom.receipt.v1`).
83/// `tsafe.run.v1` is still accepted on parse / verify during the v2.x
84/// read-compat window; new emission is [`RECEIPT_SCHEMA`]. See
85/// `CHANGELOG.md`.
86pub const RUN_SCHEMA: &str = "tsafe.run.v1";
87
88/// Original legacy schema name accepted during the read-compat window.
89///
90/// Phase 4 rename: callers reading older `algol.run.v1` artifacts continue
91/// to parse cleanly. New emission is [`RECEIPT_SCHEMA`]. See `CHANGELOG.md`.
92pub const LEGACY_RUN_SCHEMA: &str = "algol.run.v1";
93
94/// Version string embedded in `RunEvidence.tsafe_attest_version`.
95///
96/// Phase 4 rename: the wire-shape field name on new emissions is
97/// `tsafe_attest_version`. Legacy `algol_version` is accepted on parse via
98/// the `serde(alias)` declaration on the field below.
99pub const RUN_EVIDENCE_VERSION: &str = env!("CARGO_PKG_VERSION");
100
101/// Reference to a written contract artifact.
102///
103/// Carries the on-disk path the contract was loaded from and a BLAKE3
104/// hash of the contract bytes so consumers can detect divergence. Legacy
105/// `sha256:` hashes are accepted on parse during the v1.x compat window;
106/// new emissions are always `blake3:`.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct ContractRef {
109    pub path: String,
110    pub hash: String,
111}
112
113/// Evidence that a single secret was injected into the child process env.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115pub struct InjectedSecretEvidence {
116    pub name: String,
117    pub source: String,
118    pub hash: String,
119    pub redacted_value: String,
120    pub required: bool,
121}
122
123/// Evidence that a sensitive parent-env variable was denied (stripped).
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct DeniedSensitiveEnvEvidence {
126    pub name: String,
127    pub hash: String,
128    pub reason: String,
129}
130
131/// Aggregate environment diff between parent and child processes.
132///
133/// `parent_env_count` and `child_env_count` are total entry counts in each
134/// process's env block. `removed_env_count` is the count of entries that
135/// existed in the parent but were stripped from the child — this must
136/// satisfy the invariant `removed_env_count == parent_env_count -
137/// child_env_count` (see [`RunEvidence::validation_errors`]).
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct EnvironmentEvidence {
140    pub parent_env_count: usize,
141    pub child_env_count: usize,
142    pub removed_env_count: usize,
143    pub safe_baseline_injected: Vec<String>,
144    pub secrets_injected: Vec<InjectedSecretEvidence>,
145    pub sensitive_env_denied: Vec<DeniedSensitiveEnvEvidence>,
146}
147
148/// Evidence of the child process's lifecycle.
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150pub struct ProcessEvidence {
151    pub pid: u32,
152    pub exit_code: i32,
153    pub duration_ms: u128,
154    pub cwd: String,
155}
156
157/// Evidence of the host machine the run happened on.
158///
159/// Hostname and username are recorded as BLAKE3 hashes only — the
160/// plaintext values must not appear in the artifact. The os/arch
161/// strings are platform-neutral identifiers (`linux`, `darwin`,
162/// `x86_64`, `aarch64`, etc.).
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164pub struct MachineEvidence {
165    pub hostname_hash: String,
166    pub username_hash: String,
167    pub os: String,
168    pub arch: String,
169}
170
171/// Before/after risk score for the enforcement window.
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
173pub struct RiskDelta {
174    pub before_score: u32,
175    pub after_score: u32,
176}
177
178/// Enforcement outcome — did the contract hold, and what (if anything) failed?
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180pub struct EnforcementResult {
181    pub contract_enforced: bool,
182    pub violations: Vec<String>,
183    pub risk_delta: RiskDelta,
184}
185
186/// Typed-evidence artifact for a single attested command execution.
187///
188/// `RunEvidence` is the post-run truth record: every field is observed
189/// after the child process exits. It carries:
190///
191/// - Identity of the run (schema, version, timestamps, repo, command)
192/// - A pointer to the contract the run was enforced against (`contract`)
193/// - The parent-vs-child env diff with per-var BLAKE3 hashes
194///   (`environment`)
195/// - Process lifecycle observations (`process`)
196/// - Host fingerprint (hashed only) (`machine`)
197/// - Enforcement verdict (`result`)
198///
199/// Construction is the caller's responsibility — this module owns the
200/// type definition and validation only.
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202pub struct RunEvidence {
203    pub schema: String,
204    /// Producing tool version (BLAKE3-converged `tsafe-attest` since v1.2.0).
205    ///
206    /// Legacy `algol_version` field name is accepted on parse via
207    /// `serde(alias)` for the v1.x compat window; new emissions use
208    /// `tsafe_attest_version`.
209    #[serde(alias = "algol_version", rename = "tsafe_attest_version")]
210    pub tsafe_attest_version: String,
211    pub started_at: DateTime<Utc>,
212    pub finished_at: DateTime<Utc>,
213    pub repo_path: String,
214    pub repo_commit: Option<String>,
215    pub command: Vec<String>,
216    pub contract: ContractRef,
217    pub environment: EnvironmentEvidence,
218    pub process: ProcessEvidence,
219    pub machine: MachineEvidence,
220    pub result: EnforcementResult,
221    /// Optional Ed25519 signature over the canonical form of every
222    /// other field on this artifact (Phase 5; see [`crate::sign`]).
223    ///
224    /// `None` on legacy / unsigned emissions; `Some(..)` when the
225    /// producer had a signing key available and the operator did not
226    /// opt out via `--no-sign`. Skipped from the wire form when absent
227    /// so existing readers parse old artifacts unchanged.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub signature: Option<SignaturePayload>,
230}
231
232impl RunEvidence {
233    /// Return all validation errors for this artifact.
234    ///
235    /// Empty vector indicates the artifact is structurally and
236    /// semantically valid. Used by [`Self::ensure_valid`].
237    ///
238    /// During the v2.x read-compat window the canonical
239    /// `axiom.receipt.v1` plus the prior `tsafe.run.v1` and original
240    /// `algol.run.v1` schema names are all accepted for the schema
241    /// field, and any hash field accepts either `blake3:` (canonical) or
242    /// `sha256:` (legacy) prefixes.
243    pub fn validation_errors(&self) -> Vec<String> {
244        let mut errors = Vec::new();
245
246        if !is_supported_run_schema(&self.schema) {
247            errors.push(format!("unsupported schema {}", self.schema));
248        }
249        if self.tsafe_attest_version.trim().is_empty() {
250            errors.push("tsafe_attest_version must not be empty".to_string());
251        }
252        if self.repo_path.trim().is_empty() {
253            errors.push("repo_path must not be empty".to_string());
254        }
255        if self.command.is_empty() || self.command.iter().any(|part| part.trim().is_empty()) {
256            errors.push("command must contain non-empty argv entries".to_string());
257        }
258        if self.contract.path.trim().is_empty() {
259            errors.push("contract.path must not be empty".to_string());
260        }
261        if !is_supported_hash(&self.contract.hash) {
262            errors.push(
263                "contract.hash must be a blake3 hash (or sha256 during compat window)".to_string(),
264            );
265        }
266        for item in &self.environment.secrets_injected {
267            if !is_supported_hash(&item.hash) {
268                errors.push(format!(
269                    "secrets_injected {} hash must be a blake3 hash (or sha256 during compat window)",
270                    item.name
271                ));
272            }
273        }
274        for item in &self.environment.sensitive_env_denied {
275            if !is_supported_hash(&item.hash) {
276                errors.push(format!(
277                    "sensitive_env_denied {} hash must be a blake3 hash (or sha256 during compat window)",
278                    item.name
279                ));
280            }
281        }
282        if !is_supported_hash(&self.machine.hostname_hash) {
283            errors.push(
284                "machine.hostname_hash must be a blake3 hash (or sha256 during compat window)"
285                    .to_string(),
286            );
287        }
288        if !is_supported_hash(&self.machine.username_hash) {
289            errors.push(
290                "machine.username_hash must be a blake3 hash (or sha256 during compat window)"
291                    .to_string(),
292            );
293        }
294        if self.environment.child_env_count > self.environment.parent_env_count {
295            errors.push("child_env_count must not exceed parent_env_count".to_string());
296        }
297        let expected_removed = self
298            .environment
299            .parent_env_count
300            .saturating_sub(self.environment.child_env_count);
301        if self.environment.removed_env_count != expected_removed {
302            errors.push(format!(
303                "removed_env_count must equal parent_env_count - child_env_count ({expected_removed})"
304            ));
305        }
306
307        errors
308    }
309
310    /// Convert the validation-error list into a `Result`.
311    pub fn ensure_valid(&self) -> Result<(), String> {
312        let errors = self.validation_errors();
313        if errors.is_empty() {
314            Ok(())
315        } else {
316            Err(errors.join("; "))
317        }
318    }
319}
320
321/// Test whether `schema` is one of the supported `RunEvidence` schema names.
322///
323/// Accepts the canonical [`RECEIPT_SCHEMA`] (`axiom.receipt.v1`, v2) plus
324/// the read-compat [`RUN_SCHEMA`] (`tsafe.run.v1`) and
325/// [`LEGACY_RUN_SCHEMA`] (`algol.run.v1`) during the v2.x read-compat
326/// window.
327pub fn is_supported_run_schema(schema: &str) -> bool {
328    schema == RECEIPT_SCHEMA || schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
329}
330
331/// Test whether `schema` is a v1-era (pre-v2) read-compat schema name.
332///
333/// True for [`RUN_SCHEMA`] (`tsafe.run.v1`) and [`LEGACY_RUN_SCHEMA`]
334/// (`algol.run.v1`). These artifacts were signed under the v1 domain tag
335/// ([`crate::sign::DOMAIN_TAG`]); [`RECEIPT_SCHEMA`] artifacts are signed
336/// under the v2 tag ([`crate::sign::DOMAIN_TAG_V2`]). The signer/verifier
337/// dispatch keys off this distinction so the two protocols stay
338/// non-interchangeable (cross-protocol forgery guard).
339pub fn is_legacy_run_schema(schema: &str) -> bool {
340    schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
341}
342
343/// Test whether a string is a valid BLAKE3 hash with the
344/// `blake3:<64-hex-chars>` prefix convention (canonical Phase 4).
345pub fn is_blake3_hash(value: &str) -> bool {
346    let Some(hex) = value.strip_prefix("blake3:") else {
347        return false;
348    };
349    hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
350}
351
352/// Test whether a string is a valid SHA-256 hash with the
353/// `sha256:<64-hex-chars>` prefix convention (legacy / compat).
354pub fn is_sha256_hash(value: &str) -> bool {
355    let Some(hex) = value.strip_prefix("sha256:") else {
356        return false;
357    };
358    hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
359}
360
361/// Test whether a hash string is one of the supported families.
362///
363/// Accepts BLAKE3 (canonical Phase 4) or SHA-256 (legacy) during the
364/// v1.x compat window. New emission code should call [`blake3_hash`]
365/// directly.
366pub fn is_supported_hash(value: &str) -> bool {
367    is_blake3_hash(value) || is_sha256_hash(value)
368}
369
370/// Build a `blake3:<hex>` hash from raw bytes.
371///
372/// This is the canonical content-hash helper per ec ADR-0003.
373pub fn blake3_hash(value: impl AsRef<[u8]>) -> String {
374    let digest = blake3::hash(value.as_ref());
375    format!("blake3:{}", digest.to_hex())
376}
377
378/// Build a `sha256:<hex>` hash from raw bytes.
379///
380/// **Deprecated.** Retained for the v1.x compat window so callers
381/// reading existing `algol.run.v1` artifacts (which carry `sha256:`
382/// hashes) can validate against them. New emissions use
383/// [`blake3_hash`].
384#[deprecated(
385    since = "1.2.0",
386    note = "Use `blake3_hash` per ec ADR-0003. SHA-256 retained only for \
387            compat-window reads of legacy `algol.run.v1` artifacts."
388)]
389pub fn sha256_hash(value: impl AsRef<[u8]>) -> String {
390    use sha2::{Digest, Sha256};
391    let digest = Sha256::digest(value.as_ref());
392    format!("sha256:{}", hex_encode(&digest))
393}
394
395/// Minimal lowercase hex encoder so this module does not pull in a new
396/// `hex` crate dependency just for the hash helper.
397fn hex_encode(bytes: &[u8]) -> String {
398    const HEX: &[u8; 16] = b"0123456789abcdef";
399    let mut out = String::with_capacity(bytes.len() * 2);
400    for byte in bytes {
401        out.push(HEX[(byte >> 4) as usize] as char);
402        out.push(HEX[(byte & 0x0f) as usize] as char);
403    }
404    out
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn valid_run() -> RunEvidence {
412        RunEvidence {
413            schema: RECEIPT_SCHEMA.to_string(),
414            tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
415            started_at: Utc::now(),
416            finished_at: Utc::now(),
417            repo_path: ".".to_string(),
418            repo_commit: None,
419            command: vec!["true".to_string()],
420            contract: ContractRef {
421                path: "tsafe.contract.json".to_string(),
422                hash: blake3_hash("contract"),
423            },
424            environment: EnvironmentEvidence {
425                parent_env_count: 3,
426                child_env_count: 1,
427                removed_env_count: 2,
428                safe_baseline_injected: vec!["PATH".to_string()],
429                secrets_injected: vec![InjectedSecretEvidence {
430                    name: "DATABASE_URL".to_string(),
431                    source: "literal://demo/DATABASE_URL".to_string(),
432                    hash: blake3_hash("database"),
433                    redacted_value: "po***se".to_string(),
434                    required: true,
435                }],
436                sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
437                    name: "AWS_SECRET_ACCESS_KEY".to_string(),
438                    hash: blake3_hash("secret"),
439                    reason: "test".to_string(),
440                }],
441            },
442            process: ProcessEvidence {
443                pid: 1,
444                exit_code: 0,
445                duration_ms: 1,
446                cwd: ".".to_string(),
447            },
448            machine: MachineEvidence {
449                hostname_hash: blake3_hash("host"),
450                username_hash: blake3_hash("user"),
451                os: "linux".to_string(),
452                arch: "x86_64".to_string(),
453            },
454            result: EnforcementResult {
455                contract_enforced: true,
456                violations: Vec::new(),
457                risk_delta: RiskDelta {
458                    before_score: 10,
459                    after_score: 0,
460                },
461            },
462            signature: None,
463        }
464    }
465
466    #[test]
467    fn run_rejects_empty_and_blank_command_entries() {
468        let mut empty = valid_run();
469        empty.command.clear();
470        assert!(empty
471            .validation_errors()
472            .iter()
473            .any(|error| error.contains("command must contain")));
474
475        let mut blank = valid_run();
476        blank.command = vec!["true".to_string(), "".to_string()];
477        assert!(blank
478            .validation_errors()
479            .iter()
480            .any(|error| error.contains("command must contain")));
481    }
482
483    #[test]
484    fn run_rejects_env_count_edges() {
485        let mut child_exceeds_parent = valid_run();
486        child_exceeds_parent.environment.parent_env_count = 2;
487        child_exceeds_parent.environment.child_env_count = 3;
488        child_exceeds_parent.environment.removed_env_count = 0;
489        let errors = child_exceeds_parent.validation_errors().join("; ");
490        assert!(errors.contains("child_env_count must not exceed parent_env_count"));
491
492        let mut equal_counts = valid_run();
493        equal_counts.environment.parent_env_count = 2;
494        equal_counts.environment.child_env_count = 2;
495        equal_counts.environment.removed_env_count = 1;
496        let errors = equal_counts.validation_errors().join("; ");
497        assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
498        assert!(errors.contains("removed_env_count must equal"));
499
500        let mut removed_mismatch = valid_run();
501        removed_mismatch.environment.parent_env_count = 5;
502        removed_mismatch.environment.child_env_count = 3;
503        removed_mismatch.environment.removed_env_count = 1;
504        let errors = removed_mismatch.validation_errors().join("; ");
505        assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
506        assert!(errors.contains("removed_env_count must equal"));
507    }
508
509    #[test]
510    fn run_rejects_short_or_non_hex_hashes() {
511        let mut short_hash = valid_run();
512        short_hash.contract.hash = "blake3:abc123".to_string();
513        assert!(short_hash
514            .validation_errors()
515            .iter()
516            .any(|error| error.contains("contract.hash must be a blake3 hash")));
517
518        let mut non_hex_hash = valid_run();
519        non_hex_hash.machine.hostname_hash = format!("blake3:{}", "g".repeat(64));
520        assert!(non_hex_hash
521            .validation_errors()
522            .iter()
523            .any(|error| error.contains("machine.hostname_hash must be a blake3 hash")));
524    }
525
526    #[test]
527    fn run_accepts_a_well_formed_artifact() {
528        let run = valid_run();
529        assert!(
530            run.ensure_valid().is_ok(),
531            "valid_run() should pass validation: {:?}",
532            run.validation_errors()
533        );
534    }
535
536    #[test]
537    fn blake3_hash_produces_prefixed_64_hex_string() {
538        let hash = blake3_hash("any payload");
539        assert!(is_blake3_hash(&hash), "rejected own output: {hash}");
540        assert_eq!(hash.len(), "blake3:".len() + 64);
541        assert!(hash.starts_with("blake3:"));
542    }
543
544    #[test]
545    fn blake3_hash_is_deterministic_and_distinct() {
546        assert_eq!(blake3_hash("same input"), blake3_hash("same input"));
547        assert_ne!(blake3_hash("input one"), blake3_hash("input two"));
548    }
549
550    #[test]
551    fn is_blake3_hash_rejects_wrong_prefix_and_length() {
552        assert!(!is_blake3_hash(
553            "sha256:0000000000000000000000000000000000000000000000000000000000000000"
554        ));
555        assert!(!is_blake3_hash("blake3:short"));
556        assert!(!is_blake3_hash("blake3:"));
557        assert!(!is_blake3_hash(""));
558        assert!(!is_blake3_hash(&format!("blake3:{}", "z".repeat(64))));
559    }
560
561    #[test]
562    fn compat_legacy_schema_and_sha256_hashes_accepted() {
563        let mut legacy = valid_run();
564        legacy.schema = LEGACY_RUN_SCHEMA.to_string();
565        // SHA-256 hash on a legacy artifact must remain accepted during compat.
566        #[allow(deprecated)]
567        let legacy_hash = sha256_hash("contract");
568        legacy.contract.hash = legacy_hash;
569        // Replace all hashes with sha256 to model an actual legacy artifact.
570        #[allow(deprecated)]
571        {
572            for item in &mut legacy.environment.secrets_injected {
573                item.hash = sha256_hash(&item.name);
574            }
575            for item in &mut legacy.environment.sensitive_env_denied {
576                item.hash = sha256_hash(&item.name);
577            }
578            legacy.machine.hostname_hash = sha256_hash("host");
579            legacy.machine.username_hash = sha256_hash("user");
580        }
581        assert!(
582            legacy.ensure_valid().is_ok(),
583            "legacy artifact must remain valid during compat: {:?}",
584            legacy.validation_errors()
585        );
586    }
587
588    #[test]
589    fn compat_legacy_algol_version_field_name_deserializes() {
590        // Round-trip from a legacy `algol_version`-keyed JSON document.
591        let blob = serde_json::json!({
592            "schema": LEGACY_RUN_SCHEMA,
593            "algol_version": "0.1.0",
594            "started_at": "2026-05-21T00:00:00Z",
595            "finished_at": "2026-05-21T00:00:01Z",
596            "repo_path": ".",
597            "repo_commit": null,
598            "command": ["true"],
599            "contract": {
600                "path": "algol.contract.json",
601                "hash": format!("sha256:{}", "a".repeat(64)),
602            },
603            "environment": {
604                "parent_env_count": 1,
605                "child_env_count": 1,
606                "removed_env_count": 0,
607                "safe_baseline_injected": ["PATH"],
608                "secrets_injected": [],
609                "sensitive_env_denied": [],
610            },
611            "process": {
612                "pid": 1,
613                "exit_code": 0,
614                "duration_ms": 1u64,
615                "cwd": ".",
616            },
617            "machine": {
618                "hostname_hash": format!("sha256:{}", "b".repeat(64)),
619                "username_hash": format!("sha256:{}", "c".repeat(64)),
620                "os": "linux",
621                "arch": "x86_64",
622            },
623            "result": {
624                "contract_enforced": true,
625                "violations": [],
626                "risk_delta": {"before_score": 10, "after_score": 0},
627            },
628        });
629        let parsed: RunEvidence = serde_json::from_value(blob).expect("legacy json parses");
630        assert_eq!(parsed.tsafe_attest_version, "0.1.0");
631        assert!(parsed.ensure_valid().is_ok());
632    }
633}