Skip to main content

greentic_deployer/environment/
bundle_deployment.rs

1//! `BundleDeployment` lifecycle helpers (B10 of `plans/next-gen-deployment.md`).
2//!
3//! Owns the on-disk, **versioned revenue-policy artifact**. Every mutation of a
4//! deployment's `revenue_share` (`gtc op bundles add` writes `v1`; each
5//! `gtc op bundles update --revenue-share …` writes `v{N+1}`) materializes a
6//! new policy version under:
7//!
8//! ```text
9//! <env_dir>/billing-policies/<bundle_id>/<customer_id>/vN.json      # the document
10//! <env_dir>/billing-policies/<bundle_id>/<customer_id>/vN.json.sig  # detached sidecar
11//! ```
12//!
13//! `BundleDeployment.revenue_policy_ref` is set to the **env-relative** path of
14//! the latest sidecar.
15//!
16//! Since PR-4.2g the storage-free half — version derivation, document +
17//! predicate construction, DSSE signing, the trust-root refusal, and the
18//! in-memory self-verify — lives in
19//! [`greentic_operator_trust::revenue_policy`], shared with the
20//! operator-store-server (which persists the same bytes to its
21//! `revenue_policies` table). This module keeps the file-backed half: the
22//! env trust-root load, the atomic writes, and the on-disk re-read
23//! self-verify.
24//!
25//! ## Signing posture (C2)
26//!
27//! The `.sig` sidecar is a DSSE envelope (`application/vnd.in-toto+json`)
28//! whose in-toto v1 Statement pins the canonical-JSON SHA-256 of the
29//! corresponding `vN.json` and carries a `greentic.revenue-policy-predicate.v1`
30//! predicate. The envelope is signed Ed25519 with the operator's key (see
31//! [`crate::operator_key`]) and verifiable via
32//! [`greentic_distributor_client::signing::verify_artifact_dsse`] against the
33//! env's [`super::trust_root`].
34//!
35//! ## Trust-root contract (Codex #1 — revocation durability)
36//!
37//! The writer NEVER mutates the env trust root. It loads
38//! `<env_dir>/trust-root.json`, refuses to sign if the operator's `key_id`
39//! is not already a trusted entry, and self-verifies the freshly-written
40//! envelope against that same trust root before returning.
41//!
42//! This makes `gtc op trust-root remove` a real revocation boundary:
43//! after removal, every subsequent `bundle add/update` aborts with
44//! [`BundleDeploymentError::OperatorKeyNotTrusted`] until an authorized
45//! caller runs the explicit `gtc op trust-root bootstrap` verb (or
46//! rotates the operator's local key entirely). An earlier draft of this
47//! writer auto-seeded the operator key on every write — that defeated
48//! revocation because the next mutation always re-inserted the removed
49//! key.
50//!
51//! ## Concurrency & partial-failure safety
52//!
53//! [`write_revenue_policy_version`] derives the next version from the
54//! deployment's **committed** `revenue_policy_ref` (`env.json`), not from a
55//! filesystem scan. Callers persist `env.json` only after this writer returns,
56//! so a failed attempt (sidecar write or env save) leaves the committed ref
57//! unchanged; a retry rewrites the *same* version, overwriting any orphan
58//! files instead of advancing past them. Committed state therefore never
59//! references an uncommitted or dangling version. Callers MUST still run inside
60//! `EnvironmentStore::transact` so the file write and the `env.json` update
61//! share one env flock.
62
63use std::path::{Path, PathBuf};
64
65use chrono::{DateTime, Utc};
66use greentic_deploy_spec::{BundleDeployment, RevenueShareEntry};
67use greentic_distributor_client::signing::{SigningError, verify_artifact_dsse};
68use greentic_operator_trust::revenue_policy::{self, RevenuePolicyError};
69use thiserror::Error;
70
71use super::atomic_write::AtomicWriteError;
72use super::trust_root::{self, TrustRootError};
73use crate::operator_key::{OperatorKey, OperatorKeyError};
74
75// Predicate shape + discriminator moved to the shared builder crate in
76// PR-4.2g; re-exported so `crate::environment::…` paths keep working.
77pub use greentic_operator_trust::revenue_policy::{
78    REVENUE_POLICY_PREDICATE_TYPE_V1, RevenuePolicyPredicate,
79};
80
81#[derive(Debug, Error)]
82pub enum BundleDeploymentError {
83    #[error("revenue-policy spec invalid: {0}")]
84    Spec(#[from] greentic_deploy_spec::SpecError),
85    #[error("revenue-policy write {path}: {source}")]
86    Write {
87        path: PathBuf,
88        #[source]
89        source: AtomicWriteError,
90    },
91    #[error(
92        "unsafe path segment `{0}`: must be a single component, not `.`/`..`, and contain no path separators or NUL"
93    )]
94    UnsafeSegment(String),
95    #[error("revenue-policy version counter exhausted (committed ref already at the maximum)")]
96    VersionOverflow,
97    #[error("revenue-policy io on {path}: {source}")]
98    Io {
99        path: PathBuf,
100        #[source]
101        source: std::io::Error,
102    },
103    #[error("revenue-policy signing: {0}")]
104    Sign(#[from] SigningError),
105    #[error("revenue-policy operator key: {0}")]
106    OperatorKey(#[from] OperatorKeyError),
107    #[error("revenue-policy trust-root: {0}")]
108    TrustRoot(#[from] TrustRootError),
109    #[error("revenue-policy serialize: {0}")]
110    Serialize(serde_json::Error),
111    /// The operator's `(key_id, public_pem)` is not in the env trust root.
112    /// Auto-seeding would defeat revocation, so the writer refuses to sign
113    /// until the caller explicitly bootstraps the env trust root.
114    #[error(
115        "operator key `{key_id}` is not trusted in env `{env_dir}` (not present in `trust-root.json`); run `gtc op trust-root bootstrap <env-id>` first, or restore the key via `gtc op trust-root add`"
116    )]
117    OperatorKeyNotTrusted { key_id: String, env_dir: PathBuf },
118    /// Post-write self-verify caught corruption: the on-disk `vN.json`
119    /// hashes to something other than the value the DSSE Statement pinned.
120    /// Surfaces NFS / disk-space / concurrent-rename races at write time.
121    #[error(
122        "revenue-policy document `{path}` was corrupted after write: expected SHA-256 `{expected}`, on-disk SHA-256 `{actual}`"
123    )]
124    DocCorruptedAfterWrite {
125        path: PathBuf,
126        expected: String,
127        actual: String,
128    },
129}
130
131/// Map the shared builder's pure error onto this module's surface. Variant
132/// names and Display strings stay byte-identical to the pre-extraction
133/// enum; `OperatorKeyNotTrusted` regains the `env_dir` the backend-neutral
134/// builder cannot know.
135fn map_policy_error(err: RevenuePolicyError, env_dir: &Path) -> BundleDeploymentError {
136    match err {
137        RevenuePolicyError::Spec(e) => BundleDeploymentError::Spec(e),
138        RevenuePolicyError::UnsafeSegment(s) => BundleDeploymentError::UnsafeSegment(s),
139        RevenuePolicyError::VersionOverflow => BundleDeploymentError::VersionOverflow,
140        RevenuePolicyError::Sign(e) => BundleDeploymentError::Sign(e),
141        RevenuePolicyError::Serialize(e) => BundleDeploymentError::Serialize(e),
142        RevenuePolicyError::OperatorKeyNotTrusted { key_id } => {
143            BundleDeploymentError::OperatorKeyNotTrusted {
144                key_id,
145                env_dir: env_dir.to_path_buf(),
146            }
147        }
148    }
149}
150
151/// What a successful policy-version write produced.
152#[derive(Clone, Debug)]
153pub struct RevenuePolicyVersion {
154    /// Env-relative path to the new sidecar (→ `BundleDeployment.revenue_policy_ref`).
155    pub policy_ref: PathBuf,
156    pub version: u64,
157    /// Lowercase-hex SHA-256 of the canonical-JSON bytes pinned by the DSSE
158    /// statement. Same value the envelope's `subject.digest.sha256` carries
159    /// — callers can cross-reference without re-reading the document.
160    pub doc_sha256: String,
161    /// `keyid` recorded in the DSSE envelope (matches the operator's
162    /// canonical key id).
163    pub key_id: String,
164}
165
166/// Write the next revenue-policy version for `deployment` under `env_dir`,
167/// using `revenue_share` as the version's policy, `created_at` as its
168/// timestamp, and `operator_key` for DSSE signing.
169///
170/// On disk, two files land under
171/// `<env_dir>/billing-policies/<bundle_id>/<customer_id>/`:
172/// - `vN.json` — the canonical-JSON [`greentic_deploy_spec::RevenuePolicyDocument`].
173/// - `vN.json.sig` — a DSSE envelope whose in-toto v1 Statement pins the
174///   document's SHA-256 and carries a [`RevenuePolicyPredicate`].
175///
176/// **Trust-root precondition:** `operator_key.key_id` must already be
177/// present in `<env_dir>/trust-root.json`; the writer never mutates the
178/// trust root (see module docs on revocation durability). Bootstrap an
179/// env's trust root once with `gtc op trust-root bootstrap <env-id>`.
180///
181/// The freshly-written envelope is re-loaded and re-verified against that
182/// trust root before returning — a misconfiguration that would yield an
183/// unverifiable sidecar fails the write rather than landing on disk.
184///
185/// Returns the env-relative sidecar path the caller should store in
186/// `BundleDeployment.revenue_policy_ref`. Versions are 1-based and monotonic
187/// per `(bundle_id, customer_id)`; the new version chains backward to the prior
188/// one via `RevenuePolicyDocument::previous_version_ref`.
189///
190/// MUST run under the env flock (see module docs).
191pub fn write_revenue_policy_version(
192    env_dir: &Path,
193    deployment: &BundleDeployment,
194    revenue_share: &[RevenueShareEntry],
195    created_at: DateTime<Utc>,
196    operator_key: &OperatorKey,
197) -> Result<RevenuePolicyVersion, BundleDeploymentError> {
198    // Codex #1: load the env trust root (do NOT mutate it). The shared
199    // builder refuses to sign if the operator's key is not already trusted,
200    // BEFORE any bytes exist — so a failed precondition leaves NO partial
201    // artifacts (empty billing-policies subdirs, etc.) on disk.
202    let trust_root = trust_root::load(env_dir)?;
203    let built = revenue_policy::build_revenue_policy_version(
204        deployment,
205        revenue_share,
206        created_at,
207        operator_key,
208        &trust_root,
209    )
210    .map_err(|err| map_policy_error(err, env_dir))?;
211
212    let doc_abs = env_dir.join(&built.doc_ref);
213    let sig_abs = env_dir.join(&built.policy_ref);
214    let abs_dir = doc_abs
215        .parent()
216        .expect("built refs always carry the billing-policies parent dir")
217        .to_path_buf();
218    std::fs::create_dir_all(&abs_dir).map_err(|source| BundleDeploymentError::Io {
219        path: abs_dir.clone(),
220        source,
221    })?;
222
223    super::atomic_write::atomic_write_bytes(&doc_abs, &built.doc_bytes).map_err(|source| {
224        BundleDeploymentError::Write {
225            path: doc_abs.clone(),
226            source,
227        }
228    })?;
229    super::atomic_write::atomic_write_bytes(&sig_abs, &built.envelope_bytes).map_err(|source| {
230        BundleDeploymentError::Write {
231            path: sig_abs.clone(),
232            source,
233        }
234    })?;
235
236    // Self-verify: re-read BOTH files from disk, re-hash the doc, then
237    // verify the envelope against the digest of what actually landed. The
238    // builder already verified the in-memory bytes; re-hashing the disk
239    // bytes additionally catches write-time corruption (NFS / out-of-disk-
240    // space / concurrent rename) at the moment it is cheap to detect.
241    let on_disk_doc = std::fs::read(&doc_abs).map_err(|source| BundleDeploymentError::Io {
242        path: doc_abs.clone(),
243        source,
244    })?;
245    let on_disk_doc_sha256 = revenue_policy::sha256_hex(&on_disk_doc);
246    if on_disk_doc_sha256 != built.doc_sha256 {
247        return Err(BundleDeploymentError::DocCorruptedAfterWrite {
248            path: doc_abs.clone(),
249            expected: built.doc_sha256.clone(),
250            actual: on_disk_doc_sha256,
251        });
252    }
253    let written = std::fs::read(&sig_abs).map_err(|source| BundleDeploymentError::Io {
254        path: sig_abs.clone(),
255        source,
256    })?;
257    verify_artifact_dsse(&written, &on_disk_doc_sha256, &trust_root)?;
258
259    Ok(RevenuePolicyVersion {
260        policy_ref: built.policy_ref,
261        version: built.version,
262        doc_sha256: built.doc_sha256,
263        key_id: built.key_id,
264    })
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::operator_key::{OperatorKey, load_or_generate_at};
271    use greentic_deploy_spec::{
272        BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId, PartyId,
273        RevenuePolicyDocument, RouteBinding, SchemaVersion, TenantSelector,
274    };
275    use greentic_distributor_client::signing::{DsseEnvelope, TrustedKey};
276    use greentic_operator_trust::revenue_policy::sha256_hex;
277    use std::collections::BTreeMap;
278    use tempfile::{TempDir, tempdir};
279
280    /// Test fixture: load/generate an operator key AND seed it into the env
281    /// trust root. Mirrors the production flow where
282    /// `gtc op trust-root bootstrap` runs once before any revenue-policy
283    /// write. Tests that exercise the "operator not trusted" gate should NOT
284    /// call this — use `test_operator_key_without_bootstrap` instead.
285    fn test_operator_key(workdir: &TempDir) -> OperatorKey {
286        let key = test_operator_key_without_bootstrap(workdir);
287        trust_root::add_trusted_key(
288            workdir.path(),
289            TrustedKey {
290                key_id: key.key_id.clone(),
291                public_key_pem: key.public_pem.clone(),
292            },
293        )
294        .expect("seed operator key into env trust root");
295        key
296    }
297
298    fn test_operator_key_without_bootstrap(workdir: &TempDir) -> OperatorKey {
299        // Each test gets its own key under its own tempdir so writes are
300        // isolated and `~/.greentic/operator/key.pem` is never touched.
301        load_or_generate_at(&workdir.path().join("operator-key.pem")).expect("generate key")
302    }
303
304    fn deployment(bundle: &str, customer: &str) -> BundleDeployment {
305        BundleDeployment {
306            schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
307            deployment_id: DeploymentId::new(),
308            env_id: EnvId::try_from("local").unwrap(),
309            bundle_id: BundleId::new(bundle),
310            customer_id: CustomerId::new(customer),
311            status: BundleDeploymentStatus::Active,
312            current_revisions: Vec::new(),
313            route_binding: RouteBinding {
314                hosts: Vec::new(),
315                path_prefixes: Vec::new(),
316                tenant_selector: TenantSelector {
317                    tenant: "default".to_string(),
318                    team: "default".to_string(),
319                },
320            },
321            revenue_share: shares(&[("greentic", 10_000)]),
322            revenue_policy_ref: PathBuf::from("placeholder"),
323            usage: None,
324            created_at: Utc::now(),
325            authorization_ref: PathBuf::from("auth.json"),
326            config_overrides: BTreeMap::new(),
327        }
328    }
329
330    fn shares(parts: &[(&str, u32)]) -> Vec<RevenueShareEntry> {
331        parts
332            .iter()
333            .map(|(p, bps)| RevenueShareEntry {
334                party_id: PartyId::new(*p),
335                basis_points: *bps,
336            })
337            .collect()
338    }
339
340    #[test]
341    fn first_write_is_v1_with_files_and_no_previous() {
342        let dir = tempdir().unwrap();
343        let op = test_operator_key(&dir);
344        let dep = deployment("fast2flow", "local-dev");
345        let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
346            .unwrap();
347        assert_eq!(v.version, 1);
348        assert_eq!(
349            v.policy_ref,
350            PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig")
351        );
352        assert!(dir.path().join(&v.policy_ref).is_file());
353        assert!(
354            dir.path()
355                .join("billing-policies/fast2flow/local-dev/v1.json")
356                .is_file()
357        );
358        let doc: RevenuePolicyDocument = serde_json::from_slice(
359            &std::fs::read(
360                dir.path()
361                    .join("billing-policies/fast2flow/local-dev/v1.json"),
362            )
363            .unwrap(),
364        )
365        .unwrap();
366        assert!(doc.previous_version_ref.is_none());
367        assert!(doc.validate().is_ok());
368    }
369
370    #[test]
371    fn second_write_increments_and_chains() {
372        let dir = tempdir().unwrap();
373        let op = test_operator_key(&dir);
374        // The version advances off the deployment's *committed* ref, so the
375        // caller threads the prior ref onto the deployment between writes —
376        // exactly what `cli::bundles::update` does after a successful save.
377        let mut dep = deployment("fast2flow", "cust-acme");
378        let v1 =
379            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
380                .unwrap();
381        dep.revenue_policy_ref = v1.policy_ref;
382        let v2 = write_revenue_policy_version(
383            dir.path(),
384            &dep,
385            &shares(&[("agency-a", 3_000), ("greentic", 7_000)]),
386            Utc::now(),
387            &op,
388        )
389        .unwrap();
390        assert_eq!(v2.version, 2);
391        let doc: RevenuePolicyDocument = serde_json::from_slice(
392            &std::fs::read(
393                dir.path()
394                    .join("billing-policies/fast2flow/cust-acme/v2.json"),
395            )
396            .unwrap(),
397        )
398        .unwrap();
399        assert_eq!(
400            doc.previous_version_ref,
401            Some(PathBuf::from(
402                "billing-policies/fast2flow/cust-acme/v1.json.sig"
403            ))
404        );
405    }
406
407    #[test]
408    fn retry_after_uncommitted_write_reuses_same_version() {
409        // Codex regression: a failed attempt (sidecar write or env.json save)
410        // never advances the committed ref, so a retry must rewrite the SAME
411        // version and overwrite the orphan files rather than advance past them.
412        let dir = tempdir().unwrap();
413        let op = test_operator_key(&dir);
414        let dep = deployment("fast2flow", "local-dev"); // committed ref is the placeholder
415        // First attempt "fails to commit": files land on disk but the caller
416        // never persists the new ref onto the deployment.
417        let a = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
418            .unwrap();
419        assert_eq!(a.version, 1);
420        // Retry with the SAME (still-uncommitted) deployment.
421        let b = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
422            .unwrap();
423        assert_eq!(b.version, 1, "retry must not advance past the orphan");
424        // No v2 was ever produced.
425        assert!(
426            !dir.path()
427                .join("billing-policies/fast2flow/local-dev/v2.json")
428                .exists()
429        );
430    }
431
432    #[test]
433    fn retry_on_update_path_does_not_dangle_chain() {
434        // Update committed v1 (ref threaded), then an update attempt to v2
435        // "fails to commit" (ref left at v1); the retry rewrites v2 and chains
436        // to the committed v1 sidecar — never to a missing/uncommitted one.
437        let dir = tempdir().unwrap();
438        let op = test_operator_key(&dir);
439        let mut dep = deployment("fast2flow", "cust-acme");
440        let v1 =
441            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
442                .unwrap();
443        dep.revenue_policy_ref = v1.policy_ref; // v1 committed
444        // First v2 attempt (uncommitted): ref stays at v1.
445        write_revenue_policy_version(
446            dir.path(),
447            &dep,
448            &shares(&[("greentic", 10_000)]),
449            Utc::now(),
450            &op,
451        )
452        .unwrap();
453        // Retry: still derives v2 from committed v1.
454        let v2 = write_revenue_policy_version(
455            dir.path(),
456            &dep,
457            &shares(&[("greentic", 10_000)]),
458            Utc::now(),
459            &op,
460        )
461        .unwrap();
462        assert_eq!(v2.version, 2);
463        let doc: RevenuePolicyDocument = serde_json::from_slice(
464            &std::fs::read(
465                dir.path()
466                    .join("billing-policies/fast2flow/cust-acme/v2.json"),
467            )
468            .unwrap(),
469        )
470        .unwrap();
471        let prev = doc.previous_version_ref.expect("v2 chains to v1");
472        assert!(
473            dir.path().join(&prev).is_file(),
474            "previous_version_ref must point at a real (committed) sidecar"
475        );
476        assert!(
477            !dir.path()
478                .join("billing-policies/fast2flow/cust-acme/v3.json")
479                .exists()
480        );
481    }
482
483    #[test]
484    fn sidecar_is_dsse_envelope_that_verifies_against_doc_sha256() {
485        let dir = tempdir().unwrap();
486        let op = test_operator_key(&dir);
487        let dep = deployment("fast2flow", "local-dev");
488        let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
489            .unwrap();
490        assert_eq!(v.key_id, op.key_id);
491
492        let doc_path = dir
493            .path()
494            .join("billing-policies/fast2flow/local-dev/v1.json");
495        let doc_bytes = std::fs::read(&doc_path).unwrap();
496        let doc_sha256 = sha256_hex(&doc_bytes);
497        // Round-trips through serde so the SHA-256 lines up with what the
498        // writer pinned (canonical-JSON via serde_json::to_vec_pretty).
499        let _doc: RevenuePolicyDocument = serde_json::from_slice(&doc_bytes).unwrap();
500
501        let envelope_bytes = std::fs::read(dir.path().join(&v.policy_ref)).unwrap();
502        let parsed: DsseEnvelope = serde_json::from_slice(&envelope_bytes).unwrap();
503        assert_eq!(parsed.payload_type, "application/vnd.in-toto+json");
504        assert_eq!(parsed.signatures.len(), 1);
505        assert_eq!(parsed.signatures[0].keyid, op.key_id);
506
507        // Trust root carries the bootstrapped operator key (fixture-seeded).
508        let trust = super::super::trust_root::load(dir.path()).unwrap();
509        assert!(!trust.is_empty(), "fixture must bootstrap trust-root.json");
510        verify_artifact_dsse(&envelope_bytes, &doc_sha256, &trust)
511            .expect("envelope must verify against the env trust root");
512    }
513
514    #[test]
515    fn previous_version_ref_in_predicate_uses_forward_slashes() {
516        // xhigh #13: PathBuf serialization via Display embeds the platform
517        // separator. On Windows this would be `\` — a verifier on another
518        // OS could not resolve the predicate's `previous_version_ref`.
519        let dir = tempdir().unwrap();
520        let op = test_operator_key(&dir);
521        let mut dep = deployment("fast2flow", "cust-acme");
522        let v1 =
523            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
524                .unwrap();
525        dep.revenue_policy_ref = v1.policy_ref;
526        write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
527            .unwrap();
528
529        let envelope_bytes = std::fs::read(
530            dir.path()
531                .join("billing-policies/fast2flow/cust-acme/v2.json.sig"),
532        )
533        .unwrap();
534        let env: DsseEnvelope = serde_json::from_slice(&envelope_bytes).unwrap();
535        use base64::Engine;
536        let payload = base64::engine::general_purpose::STANDARD
537            .decode(&env.payload)
538            .unwrap();
539        let stmt: serde_json::Value = serde_json::from_slice(&payload).unwrap();
540        let prev = stmt["predicate"]["previous_version_ref"].as_str().unwrap();
541        assert!(
542            !prev.contains('\\'),
543            "predicate previous_version_ref must not contain backslashes; got {prev:?}"
544        );
545        assert!(
546            prev.starts_with("billing-policies/fast2flow/cust-acme/"),
547            "expected forward-slash-normalized path; got {prev:?}"
548        );
549    }
550
551    #[test]
552    fn writer_does_not_mutate_trust_root() {
553        // Codex #1 revocation durability: two consecutive writes must not
554        // change the trust root — the writer is read-only against it.
555        let dir = tempdir().unwrap();
556        let op = test_operator_key(&dir);
557        let pre = super::super::trust_root::load(dir.path()).unwrap();
558        let mut dep = deployment("fast2flow", "local-dev");
559        let v1 =
560            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
561                .unwrap();
562        dep.revenue_policy_ref = v1.policy_ref;
563        write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
564            .unwrap();
565        let post = super::super::trust_root::load(dir.path()).unwrap();
566        assert_eq!(pre.keys, post.keys, "writer must not mutate trust root");
567    }
568
569    #[test]
570    fn writer_refuses_when_operator_key_not_in_trust_root() {
571        // Codex #1: unbootstrapped trust root is a hard fail, never silent
572        // auto-seed.
573        let dir = tempdir().unwrap();
574        let op = test_operator_key_without_bootstrap(&dir);
575        let dep = deployment("fast2flow", "local-dev");
576        let err =
577            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
578                .expect_err("unbootstrapped op must be rejected");
579        match err {
580            BundleDeploymentError::OperatorKeyNotTrusted { key_id, .. } => {
581                assert_eq!(key_id, op.key_id);
582            }
583            other => panic!("expected OperatorKeyNotTrusted, got {other:?}"),
584        }
585        assert!(
586            !dir.path().join("billing-policies").exists(),
587            "no policy artifact must land on a failed precondition"
588        );
589    }
590
591    #[test]
592    fn writer_refuses_after_explicit_trust_root_remove() {
593        // Codex #1 durability: once an operator key is removed via
594        // `trust-root remove`, the writer must keep refusing — never silently
595        // re-seed and resume signing.
596        let dir = tempdir().unwrap();
597        let op = test_operator_key(&dir);
598        let dep = deployment("fast2flow", "local-dev");
599        write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
600            .unwrap();
601        super::super::trust_root::remove_trusted_key(dir.path(), &op.key_id).unwrap();
602        let mut dep2 = dep.clone();
603        dep2.bundle_id = BundleId::new("post-revocation");
604        let err =
605            write_revenue_policy_version(dir.path(), &dep2, &dep2.revenue_share, Utc::now(), &op)
606                .expect_err("revoked op must stay revoked");
607        assert!(matches!(
608            err,
609            BundleDeploymentError::OperatorKeyNotTrusted { .. }
610        ));
611        let trust = super::super::trust_root::load(dir.path()).unwrap();
612        assert!(
613            trust.keys.is_empty(),
614            "revocation must be durable; got {:?}",
615            trust.keys
616        );
617    }
618
619    #[test]
620    fn unsafe_bundle_segment_rejected() {
621        let dir = tempdir().unwrap();
622        let op = test_operator_key(&dir);
623        let dep = deployment("../escape", "local-dev");
624        let err =
625            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
626                .unwrap_err();
627        assert!(matches!(err, BundleDeploymentError::UnsafeSegment(_)));
628    }
629
630    #[test]
631    fn unsafe_customer_segment_rejected() {
632        let dir = tempdir().unwrap();
633        let op = test_operator_key(&dir);
634        let dep = deployment("fast2flow", "a/b");
635        let err =
636            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
637                .unwrap_err();
638        assert!(matches!(err, BundleDeploymentError::UnsafeSegment(_)));
639    }
640
641    #[test]
642    fn invalid_revenue_share_rejected_before_write() {
643        let dir = tempdir().unwrap();
644        let op = test_operator_key(&dir);
645        let dep = deployment("fast2flow", "local-dev");
646        let err = write_revenue_policy_version(
647            dir.path(),
648            &dep,
649            &shares(&[("greentic", 5_000)]),
650            Utc::now(),
651            &op,
652        )
653        .unwrap_err();
654        assert!(matches!(err, BundleDeploymentError::Spec(_)));
655        // Nothing should have been written.
656        assert!(!dir.path().join("billing-policies").exists());
657    }
658
659    #[test]
660    fn corrupted_v0_ref_starts_fresh_at_v1_without_chain() {
661        let dir = tempdir().unwrap();
662        let op = test_operator_key(&dir);
663        let mut dep = deployment("fast2flow", "local-dev");
664        dep.revenue_policy_ref = PathBuf::from("billing-policies/fast2flow/local-dev/v0.json.sig");
665        let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
666            .unwrap();
667        assert_eq!(v.version, 1, "v0 ref must not chain; restart at v1");
668        let doc: RevenuePolicyDocument = serde_json::from_slice(
669            &std::fs::read(
670                dir.path()
671                    .join("billing-policies/fast2flow/local-dev/v1.json"),
672            )
673            .unwrap(),
674        )
675        .unwrap();
676        assert!(doc.previous_version_ref.is_none());
677    }
678
679    #[test]
680    fn version_counter_overflow_is_an_error_not_a_panic() {
681        let dir = tempdir().unwrap();
682        let op = test_operator_key(&dir);
683        let mut dep = deployment("fast2flow", "local-dev");
684        dep.revenue_policy_ref = PathBuf::from(format!(
685            "billing-policies/fast2flow/local-dev/v{}.json.sig",
686            u64::MAX
687        ));
688        let err =
689            write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
690                .unwrap_err();
691        assert!(matches!(err, BundleDeploymentError::VersionOverflow));
692    }
693
694    #[test]
695    fn previous_version_ref_is_reconstructed_not_copied_verbatim() {
696        // A crafted cross-env committed ref must NOT propagate into the new
697        // doc's previous_version_ref; the link is rebuilt under this
698        // deployment's own billing dir.
699        let dir = tempdir().unwrap();
700        let op = test_operator_key(&dir);
701        let mut dep = deployment("fast2flow", "cust-acme");
702        dep.revenue_policy_ref =
703            PathBuf::from("../../other-env/billing-policies/victim/cust/v3.json.sig");
704        let v = write_revenue_policy_version(dir.path(), &dep, &dep.revenue_share, Utc::now(), &op)
705            .unwrap();
706        assert_eq!(v.version, 4); // derived from the parsed v3
707        let doc: RevenuePolicyDocument = serde_json::from_slice(
708            &std::fs::read(
709                dir.path()
710                    .join("billing-policies/fast2flow/cust-acme/v4.json"),
711            )
712            .unwrap(),
713        )
714        .unwrap();
715        assert_eq!(
716            doc.previous_version_ref,
717            Some(PathBuf::from(
718                "billing-policies/fast2flow/cust-acme/v3.json.sig"
719            )),
720            "previous_version_ref must be rebuilt under this deployment's dir, not the crafted ref"
721        );
722    }
723}