Skip to main content

arkhe_forge_platform/
manifest.rs

1//! Shell Manifest TOML loader.
2//!
3//! The manifest is the single source of ground-truth shell policy: which
4//! audit signature class to require, which compliance tier applies, what
5//! the runtime version bound is. This module ships the loader surface:
6//!
7//! 1. [`ManifestSnapshot`] — typed view of a parsed manifest.
8//! 2. [`ManifestLoader::load`] — parse + validate + BLAKE3 digest in one
9//!    step, returning the snapshot and a canonical digest suitable for the
10//!    `RuntimeBootstrap` chain anchor (E12).
11//! 3. [`emit_runtime_bootstrap`] — helper that turns a loaded snapshot into
12//!    an `Op::EmitEvent`-bound [`RuntimeBootstrap`] event on an
13//!    [`ActionContext`].
14//!
15//! The canonical digest is computed as
16//! `blake3::keyed_hash(derive_key("arkhe-forge-manifest-digest", &[]),
17//! toml_canonical_bytes)` — domain-separated.
18
19use arkhe_forge_core::context::{ActionContext, ActionError};
20use arkhe_forge_core::event::{RuntimeBootstrap, SemVer};
21use arkhe_kernel::abi::{Tick, TypeCode};
22use serde::{Deserialize, Serialize};
23
24/// 32-byte canonical digest of a [`ManifestSnapshot`] (anchor C5).
25pub type ManifestDigest = [u8; 32];
26
27/// BLAKE3 domain separator for canonical manifest digest.
28const DIGEST_DOMAIN: &str = "arkhe-forge-manifest-digest";
29
30// ===================== Sections =====================
31
32/// Shell identity + public presentation metadata.
33#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct ShellSection {
36    /// Shell identifier — stable across releases (max 32 bytes UTF-8).
37    pub shell_id: String,
38    /// Human-readable shell name shown to end users.
39    pub display_name: String,
40}
41
42/// Runtime version bounds for this shell.
43#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct RuntimeSection {
46    /// Highest Runtime semver the shell promises compatibility with
47    /// (e.g. `"0.15"`). Must be ≥ `runtime_current`.
48    pub runtime_max: String,
49    /// Runtime semver the manifest author tested against (e.g. `"0.13"`).
50    pub runtime_current: String,
51}
52
53/// Audit / crypto stance — compliance tier classification.
54#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct AuditSection {
57    /// PII AEAD family identifier — e.g. `"xchacha20-poly1305"`,
58    /// `"aes-256-gcm"`, `"aes-256-gcm-siv"`.
59    pub pii_cipher: String,
60    /// DEK backend identifier — `"software-kek"` (Tier-0 dev only),
61    /// `"hsm"`, `"aws-kms"`, `"gcp-kms"`, etc.
62    pub dek_backend: String,
63    /// KMS auto-promote policy — `"manual"` (operator approval) or
64    /// `"after_60min"` (auto-promote after 60 minutes of health-check
65    /// consensus).
66    pub kms_auto_promote: String,
67    /// Audit receipt signature class (the E13 axiom). Wire format
68    /// accepts `"ed25519"` / `"ml-dsa-65"` / `"hybrid"`. Forge L2
69    /// attestation emits `"ed25519"`.
70    pub signature_class: String,
71    /// Compliance tier — `0` (software KEK, dev), `1` (single KMS
72    /// free-tier), `2` (production Multi-KMS + threshold HSM).
73    pub compliance_tier: u8,
74}
75
76/// Frontend / TLS / credential policy. Defaults apply when the TOML omits
77/// the `[frontend]` table entirely or any sub-field.
78#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct FrontendSection {
81    /// TLS is mandatory on the public ingress (default `true`).
82    pub tls_required: bool,
83    /// Alpha-tier credentials must rotate on promotion (default `true`).
84    pub alpha_credential_rotation_required: bool,
85}
86
87impl Default for FrontendSection {
88    fn default() -> Self {
89        Self {
90            tls_required: true,
91            alpha_credential_rotation_required: true,
92        }
93    }
94}
95
96/// Typed view of a loaded shell manifest.
97#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ManifestSnapshot {
100    /// Wire schema version.
101    pub schema_version: u16,
102    /// `[shell]` table.
103    pub shell: ShellSection,
104    /// `[runtime]` table.
105    pub runtime: RuntimeSection,
106    /// `[audit]` table.
107    pub audit: AuditSection,
108    /// `[frontend]` table (optional in TOML, filled via `Default`).
109    #[serde(default)]
110    pub frontend: FrontendSection,
111}
112
113// ===================== Errors =====================
114
115/// Manifest-loading failure taxonomy.
116#[derive(Debug, thiserror::Error)]
117#[non_exhaustive]
118pub enum ManifestError {
119    /// TOML parse or UTF-8 decode failure.
120    #[error("parse error: {0}")]
121    ParseError(String),
122
123    /// A required field was absent (reserved for future custom checks
124    /// beyond what `serde(deny_unknown_fields)` already enforces).
125    #[error("missing required field: {0}")]
126    MissingRequired(&'static str),
127
128    /// Compliance-tier / DEK-backend pair is inconsistent.
129    #[error("tier {tier} incompatible with backend '{backend}'")]
130    TierBackendMismatch {
131        /// Declared tier.
132        tier: u8,
133        /// Declared backend.
134        backend: String,
135    },
136
137    /// `software-kek` is allowed only on Tier-0 dev runs; production
138    /// runtimes (`runtime_current > 0.15`) reject it.
139    #[error("software-kek rejected: runtime_current {current} > 0.15")]
140    SoftwareKekProductionRefused {
141        /// Declared current runtime version.
142        current: String,
143    },
144
145    /// `runtime_max` lexicographically precedes `runtime_current` (illegal
146    /// because the shell would claim forward-compat to a version less than
147    /// the one it tested).
148    #[error("version mismatch: runtime_max < runtime_current")]
149    VersionMismatch,
150
151    /// Reserved — future custom deserializer returns this when extracting
152    /// the offending key from a `deny_unknown_fields` failure.
153    #[error("unknown field: {0}")]
154    UnknownField(String),
155
156    /// Field failed a validation predicate (e.g. malformed semver).
157    #[error("invalid value for {field}: {reason}")]
158    InvalidValue {
159        /// Field name whose value was rejected.
160        field: &'static str,
161        /// Human-readable reason.
162        reason: String,
163    },
164}
165
166// ===================== Loader =====================
167
168/// Zero-state manifest loader.
169pub struct ManifestLoader;
170
171impl ManifestLoader {
172    /// Parse + validate + digest a TOML-encoded manifest in one call.
173    pub fn load(toml_bytes: &[u8]) -> Result<(ManifestSnapshot, ManifestDigest), ManifestError> {
174        let text = core::str::from_utf8(toml_bytes)
175            .map_err(|e| ManifestError::ParseError(format!("utf-8: {}", e)))?;
176        let snapshot: ManifestSnapshot =
177            toml::from_str(text).map_err(|e| ManifestError::ParseError(e.to_string()))?;
178        Self::validate(&snapshot)?;
179        let digest = Self::canonical_digest(&snapshot)?;
180        Ok((snapshot, digest))
181    }
182
183    /// Run cross-field validators on a parsed snapshot. Called by
184    /// [`ManifestLoader::load`]; exposed for callers that construct
185    /// `ManifestSnapshot` directly.
186    pub fn validate(m: &ManifestSnapshot) -> Result<(), ManifestError> {
187        // shell_id length — stays within BoundedString<32> budget.
188        if m.shell.shell_id.len() > 32 {
189            return Err(ManifestError::InvalidValue {
190                field: "shell.shell_id",
191                reason: format!("length {} > 32", m.shell.shell_id.len()),
192            });
193        }
194
195        // Parseability of runtime versions.
196        let current =
197            parse_version(&m.runtime.runtime_current).ok_or(ManifestError::InvalidValue {
198                field: "runtime.runtime_current",
199                reason: format!(
200                    "not a 'M.N' or 'M.N.P' semver: {}",
201                    m.runtime.runtime_current
202                ),
203            })?;
204        let max = parse_version(&m.runtime.runtime_max).ok_or(ManifestError::InvalidValue {
205            field: "runtime.runtime_max",
206            reason: format!("not a 'M.N' or 'M.N.P' semver: {}", m.runtime.runtime_max),
207        })?;
208        if max < current {
209            return Err(ManifestError::VersionMismatch);
210        }
211
212        // Tier ↔ backend cross-check.
213        match (m.audit.compliance_tier, m.audit.dek_backend.as_str()) {
214            (0, "software-kek") => {}
215            (0, other) => {
216                return Err(ManifestError::TierBackendMismatch {
217                    tier: 0,
218                    backend: other.to_string(),
219                });
220            }
221            (1 | 2, "software-kek") => {
222                return Err(ManifestError::TierBackendMismatch {
223                    tier: m.audit.compliance_tier,
224                    backend: "software-kek".to_string(),
225                });
226            }
227            (1 | 2, _) => {}
228            _ => {
229                return Err(ManifestError::InvalidValue {
230                    field: "audit.compliance_tier",
231                    reason: format!("unsupported tier {}", m.audit.compliance_tier),
232                });
233            }
234        }
235
236        // software-kek refused past runtime 0.15.
237        if m.audit.dek_backend == "software-kek" && (current.0, current.1) > (0, 15) {
238            return Err(ManifestError::SoftwareKekProductionRefused {
239                current: m.runtime.runtime_current.clone(),
240            });
241        }
242
243        Ok(())
244    }
245
246    /// Compute the canonical BLAKE3 digest of a snapshot. The manifest is
247    /// re-serialized to TOML through serde (struct field order is
248    /// deterministic), then hashed under the `arkhe-forge-manifest-digest`
249    /// domain.
250    ///
251    /// # `toml` crate dependency note
252    ///
253    /// The digest depends on the underlying `toml` crate's serialise output —
254    /// specifically its `BTreeMap` traversal order, key spacing, and string
255    /// quoting style. `Cargo.lock` pins the `toml` minor version so the
256    /// emitted bytes stay stable across builds within the pinned-toml
257    /// version window.
258    ///
259    /// A future `toml` **major** version bump (currently `1.x`, hypothetical
260    /// `2.x`) is allowed to change those low-level emit details and must be
261    /// accompanied by:
262    ///
263    /// 1. A regression sentinel update in the `digest_invariant` test module
264    ///    (`TIER0_DEV_DIGEST_V0_11` constant; test will surface the byte
265    ///    drift on first run).
266    /// 2. A manifest schema micro-patch documenting the digest re-pin —
267    ///    the manifest `schema_version` is **not** bumped on its own (the
268    ///    schema itself did not change); the spec patch records the toml
269    ///    crate bump as the cause.
270    ///
271    /// Design: regression sentinel + spec drift correction. The digest
272    /// input keeps wire-bytes pure (no toml-version literal mixed into the
273    /// keyed_hash key); the regression test catches any drift and turns it
274    /// into an explicit operator decision rather than a silent re-pin.
275    pub fn canonical_digest(m: &ManifestSnapshot) -> Result<ManifestDigest, ManifestError> {
276        let canonical = toml::to_string(m)
277            .map_err(|e| ManifestError::ParseError(format!("toml serialize: {}", e)))?;
278        let key = blake3::derive_key(DIGEST_DOMAIN, &[]);
279        let hash = blake3::keyed_hash(&key, canonical.as_bytes());
280        let mut out = [0u8; 32];
281        out.copy_from_slice(hash.as_bytes());
282        Ok(out)
283    }
284}
285
286// ===================== RuntimeBootstrap helper =====================
287
288/// Emit a `RuntimeBootstrap` event onto `ctx` using `digest` as the
289/// manifest anchor (the E12 axiom). The caller supplies the L0 and
290/// Runtime semver plus the active TypeCode pin set — downstream boot code
291/// plugs in the live registry.
292pub fn emit_runtime_bootstrap(
293    digest: &ManifestDigest,
294    l0_semver: SemVer,
295    runtime_semver: SemVer,
296    typecode_pins: Vec<TypeCode>,
297    bootstrap_tick: Tick,
298    ctx: &mut ActionContext<'_>,
299) -> Result<(), ActionError> {
300    let event = RuntimeBootstrap {
301        schema_version: 1,
302        l0_semver,
303        runtime_semver,
304        manifest_digest: *digest,
305        typecode_pins,
306        bootstrap_tick,
307    };
308    ctx.emit_event(&event)
309}
310
311// ===================== Helpers =====================
312
313/// Parse `"M.N"` or `"M.N.P"` into `(major, minor, patch)`. Rejects
314/// pre-release / build-metadata suffixes (Runtime does
315/// not accept SemVer 2.0 suffixes for canonical-bytes stability).
316fn parse_version(s: &str) -> Option<(u16, u16, u16)> {
317    let mut parts = s.splitn(3, '.');
318    let major: u16 = parts.next()?.parse().ok()?;
319    let minor: u16 = parts.next()?.parse().ok()?;
320    let patch: u16 = match parts.next() {
321        Some(p) => p.parse().ok()?,
322        None => 0,
323    };
324    Some((major, minor, patch))
325}
326
327// ===================== Tests =====================
328
329#[cfg(test)]
330#[allow(clippy::unwrap_used, clippy::expect_used)]
331mod tests {
332    use super::*;
333    use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
334
335    pub(super) const TIER0_DEV_TOML: &str = r#"
336schema_version = 1
337
338[shell]
339shell_id = "shell.dev.example"
340display_name = "Dev Sandbox"
341
342[runtime]
343runtime_max = "0.15"
344runtime_current = "0.13"
345
346[audit]
347pii_cipher = "xchacha20-poly1305"
348dek_backend = "software-kek"
349kms_auto_promote = "manual"
350signature_class = "ed25519"
351compliance_tier = 0
352"#;
353
354    const TIER1_PROD_TOML: &str = r#"
355schema_version = 1
356
357[shell]
358shell_id = "shell.prod.example"
359display_name = "Prod Shell"
360
361[runtime]
362runtime_max = "0.20"
363runtime_current = "0.13"
364
365[audit]
366pii_cipher = "aes-256-gcm-siv"
367dek_backend = "aws-kms"
368kms_auto_promote = "after_60min"
369signature_class = "hybrid"
370compliance_tier = 1
371
372[frontend]
373tls_required = true
374alpha_credential_rotation_required = true
375"#;
376
377    #[test]
378    fn load_valid_tier0_dev_manifest() {
379        let (snap, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
380        assert_eq!(snap.schema_version, 1);
381        assert_eq!(snap.audit.compliance_tier, 0);
382        assert_eq!(snap.audit.dek_backend, "software-kek");
383        assert_eq!(digest.len(), 32);
384        assert!(snap.frontend.tls_required); // default
385    }
386
387    #[test]
388    fn load_valid_tier1_prod_manifest() {
389        let (snap, digest) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
390        assert_eq!(snap.audit.compliance_tier, 1);
391        assert_eq!(snap.audit.dek_backend, "aws-kms");
392        assert_eq!(digest.len(), 32);
393    }
394
395    #[test]
396    fn digest_is_deterministic_across_loads() {
397        let (_, d1) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
398        let (_, d2) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
399        assert_eq!(d1, d2);
400    }
401
402    #[test]
403    fn digest_is_whitespace_and_comment_invariant() {
404        let a = TIER0_DEV_TOML;
405        // Add comments + trailing whitespace; canonicalization should erase them.
406        let b = r#"
407# comment block
408schema_version = 1
409
410[shell]
411shell_id = "shell.dev.example"   # trailing comment
412display_name = "Dev Sandbox"
413
414[runtime]
415runtime_max = "0.15"
416runtime_current = "0.13"
417
418[audit]
419pii_cipher = "xchacha20-poly1305"
420dek_backend = "software-kek"
421kms_auto_promote = "manual"
422signature_class = "ed25519"
423compliance_tier = 0
424"#;
425        let (_, da) = ManifestLoader::load(a.as_bytes()).unwrap();
426        let (_, db) = ManifestLoader::load(b.as_bytes()).unwrap();
427        assert_eq!(da, db, "comments / whitespace must not affect digest");
428    }
429
430    #[test]
431    fn digest_differs_for_semantically_different_manifest() {
432        let (_, d0) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
433        let (_, d1) = ManifestLoader::load(TIER1_PROD_TOML.as_bytes()).unwrap();
434        assert_ne!(d0, d1);
435    }
436
437    #[test]
438    fn print_tier0_dev_digest_for_pin() {
439        // One-shot helper to discover the sentinel bytes; left in the
440        // suite so maintainers can re-run it after a deliberate schema
441        // bump and copy the new bytes into
442        // `digest_invariant::TIER0_DEV_DIGEST_V0_11`.
443        let (_, d) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
444        eprintln!(
445            "tier0_dev digest = {:?}",
446            d.iter().map(|b| format!("0x{b:02x}")).collect::<Vec<_>>()
447        );
448    }
449
450    #[test]
451    fn tier0_with_kms_backend_rejected() {
452        let bad = TIER0_DEV_TOML.replace("software-kek", "aws-kms");
453        let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
454        assert!(matches!(
455            err,
456            ManifestError::TierBackendMismatch { tier: 0, .. }
457        ));
458    }
459
460    #[test]
461    fn tier1_with_software_kek_rejected() {
462        let bad = TIER1_PROD_TOML.replace("aws-kms", "software-kek");
463        let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
464        assert!(matches!(
465            err,
466            ManifestError::TierBackendMismatch { tier: 1, .. }
467        ));
468    }
469
470    #[test]
471    fn software_kek_past_0_15_refused() {
472        let bad = TIER0_DEV_TOML
473            .replace("runtime_current = \"0.13\"", "runtime_current = \"0.16\"")
474            // runtime_max also must be >= current for version ordering to pass.
475            .replace("runtime_max = \"0.15\"", "runtime_max = \"0.20\"");
476        let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
477        assert!(matches!(
478            err,
479            ManifestError::SoftwareKekProductionRefused { .. }
480        ));
481    }
482
483    #[test]
484    fn runtime_max_less_than_current_rejected() {
485        let bad = TIER0_DEV_TOML.replace("runtime_max = \"0.15\"", "runtime_max = \"0.5\"");
486        let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
487        assert!(matches!(err, ManifestError::VersionMismatch));
488    }
489
490    #[test]
491    fn parse_error_on_malformed_toml() {
492        let err = ManifestLoader::load(b"this is not toml == invalid").unwrap_err();
493        assert!(matches!(err, ManifestError::ParseError(_)));
494    }
495
496    #[test]
497    fn unknown_top_level_field_rejected() {
498        let bad = format!("{}\nunknown_field = 42\n", TIER0_DEV_TOML);
499        let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
500        // `deny_unknown_fields` surfaces the serde error inside ParseError.
501        assert!(matches!(err, ManifestError::ParseError(_)));
502    }
503
504    #[test]
505    fn bad_semver_rejected() {
506        let bad = TIER0_DEV_TOML.replace(
507            "runtime_current = \"0.13\"",
508            "runtime_current = \"not-a-version\"",
509        );
510        let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
511        assert!(matches!(
512            err,
513            ManifestError::InvalidValue {
514                field: "runtime.runtime_current",
515                ..
516            }
517        ));
518    }
519
520    #[test]
521    fn shell_id_over_32_bytes_rejected() {
522        let long_id = "a".repeat(33);
523        let bad = TIER0_DEV_TOML.replace("shell.dev.example", &long_id);
524        let err = ManifestLoader::load(bad.as_bytes()).unwrap_err();
525        assert!(matches!(
526            err,
527            ManifestError::InvalidValue {
528                field: "shell.shell_id",
529                ..
530            }
531        ));
532    }
533
534    #[test]
535    fn emit_runtime_bootstrap_appends_event() {
536        let (_, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
537        let mut ctx = ActionContext::new(
538            [0x11u8; 32],
539            InstanceId::new(1).unwrap(),
540            Tick(1),
541            Principal::System,
542            CapabilityMask::SYSTEM,
543        );
544        emit_runtime_bootstrap(
545            &digest,
546            SemVer::new(0, 11, 0),
547            SemVer::new(0, 11, 0),
548            vec![TypeCode(0x0003_0001), TypeCode(0x0003_0002)],
549            Tick(1),
550            &mut ctx,
551        )
552        .unwrap();
553        let events = ctx.drain_events();
554        assert_eq!(events.len(), 1);
555        assert_eq!(events[0].type_code, 0x0003_0F01);
556        assert_eq!(events[0].tick, Tick(1));
557
558        // The serialized payload round-trips and carries the supplied digest.
559        let bootstrap: RuntimeBootstrap = postcard::from_bytes(&events[0].payload).unwrap();
560        assert_eq!(bootstrap.manifest_digest, digest);
561        assert_eq!(bootstrap.l0_semver, SemVer::new(0, 11, 0));
562    }
563
564    #[test]
565    fn frontend_defaults_when_omitted() {
566        let (snap, _) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes()).unwrap();
567        // TIER0_DEV_TOML omits [frontend]; defaults apply.
568        assert!(snap.frontend.tls_required);
569        assert!(snap.frontend.alpha_credential_rotation_required);
570    }
571
572    #[test]
573    fn parse_version_accepts_two_or_three_components() {
574        assert_eq!(parse_version("0.13"), Some((0, 13, 0)));
575        assert_eq!(parse_version("0.13.3"), Some((0, 13, 3)));
576        assert_eq!(parse_version(""), None);
577        assert_eq!(parse_version("0.13.3.4"), None); // 3-part splitn leaves "3.4"
578        assert_eq!(parse_version("abc.def"), None);
579    }
580}
581
582/// Manifest digest sentinel pin.
583///
584/// The canonical digest depends on the underlying `toml` crate's serialise
585/// output. `Cargo.lock` pins the toml minor version, so within the pinned
586/// toml-version window the bytes below are stable. A toml major bump
587/// (`1.x → 2.x`) is expected to change these bytes — the test then surfaces
588/// the drift, the operator updates the sentinel and writes a manifest schema
589/// manifest schema micro-patch documenting the re-pin.
590#[cfg(test)]
591#[allow(clippy::unwrap_used, clippy::expect_used)]
592mod digest_invariant {
593    use super::tests::TIER0_DEV_TOML;
594    use super::ManifestLoader;
595
596    /// SHA equivalent: `blake3::keyed_hash(derive_key("arkhe-forge-manifest-digest", &[]),
597    /// toml::to_string(TIER0_DEV_TOML_snapshot))` under the
598    /// `Cargo.lock`-pinned `toml` crate version.
599    const TIER0_DEV_DIGEST_V0_11: [u8; 32] = [
600        0xa1, 0xbf, 0xe8, 0x2a, 0x57, 0xe1, 0xde, 0x55, 0x05, 0x7e, 0x47, 0x51, 0xd0, 0xeb, 0xed,
601        0x3d, 0x48, 0x82, 0xdb, 0xd5, 0x95, 0x2d, 0x83, 0xcd, 0x52, 0x10, 0x6c, 0x0f, 0xae, 0x3b,
602        0xab, 0x3c,
603    ];
604
605    #[test]
606    fn tier0_dev_digest_matches_v0_11_sentinel() {
607        let (_, digest) = ManifestLoader::load(TIER0_DEV_TOML.as_bytes())
608            .expect("TIER0_DEV_TOML must parse cleanly");
609        assert_eq!(
610            digest, TIER0_DEV_DIGEST_V0_11,
611            "manifest canonical_digest drifted from the pinned sentinel — check toml crate \
612             version, ManifestSnapshot field order, and DIGEST_DOMAIN. Update the sentinel + \
613             write a manifest schema micro-patch if the change is intentional."
614        );
615    }
616}