Skip to main content

greentic_deployer/environment/
mutations_local.rs

1//! [`EnvironmentMutations`]-trait-shaped inherent methods on [`LocalFsStore`].
2//!
3//! Phase D PR-3a.2..3a.16 lands one verb group per PR here, each replacing
4//! the matching `store.transact(env_id, |locked| …)` closure in `src/cli/*`
5//! with a typed verb that can also be implemented by `HttpEnvironmentStore`
6//! (PR-3b) over the A8 wire contract.
7//!
8//! The methods land as **inherent** (not the trait impl) so each PR can land
9//! independently — Rust requires all trait methods to exist before a single
10//! `impl EnvironmentMutations for LocalFsStore` block compiles. Once every
11//! verb group has migrated (PR-3a.16), a trailing PR wires the trait impl as
12//! thin forwarders.
13
14use std::path::Path;
15
16use chrono::Utc;
17use greentic_distributor_client::signing::TrustedKey;
18
19use greentic_deploy_spec::engine::{self, EngineError};
20use greentic_deploy_spec::{
21    BundleDeployment, BundleId, CapabilitySlot, DeploymentId, EnvId, EnvPackBinding, Environment,
22    EnvironmentHostConfig, ExtensionBinding, HealthStatus, IdempotencyKey, MessagingEndpoint,
23    MessagingEndpointId, RetentionPolicy, Revision, RevisionId, RevocationConfig, SchemaVersion,
24    SecretRef,
25};
26
27use super::bootstrap::{
28    EnsureLocalEnvironmentPayload, LocalEnvOutcome, fill_missing_default_bindings,
29};
30use super::lifecycle::LifecycleError;
31use super::mutations::{
32    AddBundlePayload, AddMessagingEndpointPayload, ApplyTrafficSplitOutcome, EnvironmentMutations,
33    ExtensionKey, MigrateMergePayload, RemoveBundleOutcome, RevisionTransitionOutcome,
34    RollbackTrafficSplitOutcome, SetMessagingWelcomeFlowPayload, SetTrafficSplitPayload,
35    StageRevisionPayload, TrustRootAddOutcome, TrustRootRemoveOutcome, TrustRootSeed,
36    UpdateBundlePayload, UpdateEnvironmentPayload, WarmRevisionPayload,
37};
38use super::store::{EnvironmentStore, LocalFsStore, StoreError};
39use super::trust_root::{self as store_trust_root, trust_root_path};
40
41/// Map a [`LifecycleError`] into `StoreError`, peeling `LifecycleError::Store`
42/// so the original [`StoreError`] reaches callers unboxed.
43fn fold_lifecycle_err(err: LifecycleError) -> StoreError {
44    match err {
45        LifecycleError::Store(inner) => inner,
46        other => StoreError::Lifecycle(Box::new(other)),
47    }
48}
49
50/// Map a pure-engine failure onto the local store's error surface. The
51/// operator-store-server maps the same [`EngineError`]s onto
52/// `RemoteStoreError` — both sides share the transform, each owns its
53/// error vocabulary.
54fn map_engine_err(err: EngineError) -> StoreError {
55    match err {
56        EngineError::NotFound(id) => StoreError::NotFound(id),
57    }
58}
59
60/// Map a pure traffic-split failure onto the local store's error surface
61/// (the operator-store-server maps the same errors onto
62/// `RemoteStoreError`). Variant → noun choices preserve the pre-engine
63/// behavior verbatim: referential misses are `DependentNotFound`, state /
64/// protocol conflicts are `Conflict`, and spec-validation failures keep
65/// their typed [`StoreError::Spec`] so the CLI's traffic mapper can peel
66/// them back into `OpError::Spec`.
67fn map_traffic_err(err: engine::TrafficSplitError) -> StoreError {
68    use engine::TrafficSplitError as E;
69    match err {
70        E::DeploymentNotFound { .. }
71        | E::RevisionNotFound { .. }
72        | E::NoSplit { .. }
73        | E::SnapshotMissing { .. } => StoreError::DependentNotFound(err.to_string()),
74        E::WrongDeployment { .. }
75        | E::IdempotencyKeyReused { .. }
76        | E::AdmissionRevisionMissing { .. }
77        | E::NotReady { .. }
78        | E::SnapshotEncode { .. }
79        | E::NoPreviousSnapshot { .. }
80        | E::SnapshotDecode { .. } => StoreError::Conflict(err.to_string()),
81        E::Invalid(spec) => StoreError::Spec(spec),
82    }
83}
84
85/// Map a pure binding failure onto the local store's error surface,
86/// preserving the kinds the pre-engine closures raised (PR-4.2d): a
87/// missing slot / extension / stash payload is a dependent lookup miss;
88/// everything else is an operator-resolvable conflict. `NotPackSlot` is
89/// unreachable through the CLI (rejected upstream with its own message) —
90/// it exists for the store-server's wire surface.
91fn map_binding_err(err: engine::BindingError) -> StoreError {
92    use engine::BindingError as E;
93    match err {
94        E::SlotNotBound { .. }
95        | E::ExtensionNotBound { .. }
96        | E::SlotSnapshotMissing { .. }
97        | E::ExtensionSnapshotMissing { .. } => StoreError::DependentNotFound(err.to_string()),
98        E::SlotAlreadyBound { .. }
99        | E::SlotMismatch { .. }
100        | E::NotPackSlot { .. }
101        | E::SlotNoPrevious { .. }
102        | E::SlotGenerationOverflow { .. }
103        | E::ExtensionAlreadyBound { .. }
104        | E::ExtensionKeyMismatch { .. }
105        | E::ExtensionNoPrevious { .. }
106        | E::ExtensionGenerationOverflow { .. }
107        | E::SnapshotEncode { .. }
108        | E::SnapshotDecode { .. } => StoreError::Conflict(err.to_string()),
109    }
110}
111
112/// Map the engine's typed bundle errors onto the store surface. Messages
113/// are verbatim (the engine moved them in PR-4.2g), so operator-facing CLI
114/// errors are unchanged.
115fn map_bundle_err(err: engine::BundleError) -> StoreError {
116    use engine::BundleError as E;
117    match err {
118        E::DeploymentNotFound { .. } => StoreError::DependentNotFound(err.to_string()),
119        E::AlreadyDeployed { .. } | E::StillLive { .. } => StoreError::Conflict(err.to_string()),
120    }
121}
122
123/// Map the engine's typed messaging errors onto the store surface. Messages
124/// are verbatim (the engine moved them in PR-4.2h), so operator-facing CLI
125/// errors are unchanged. `SecretProvision` carries the dev-store sink's
126/// message and keeps the `Conflict` noun this store raised before the move.
127fn map_messaging_err(err: engine::MessagingError) -> StoreError {
128    use engine::MessagingError as E;
129    match err {
130        E::EndpointNotFound { .. } | E::BundleNotDeployed { .. } => {
131            StoreError::DependentNotFound(err.to_string())
132        }
133        E::IdempotencyKeyReuse { .. }
134        | E::EndpointAlreadyExists { .. }
135        | E::WelcomeFlowOwned { .. } => StoreError::Conflict(err.to_string()),
136        E::BundleNotLinked { .. } | E::WelcomePackUnknown { .. } | E::InvalidSecretRef { .. } => {
137            StoreError::InvalidArgument(err.to_string())
138        }
139        E::SecretProvision(message) => StoreError::Conflict(message),
140    }
141}
142
143/// Derive the webhook-secret provisioning context from the env: the env's owning
144/// tenant (`tenant_org_id`, trimmed; `None` when absent or blank) and whether the
145/// secrets backend custodies values in the local dev-store (so the sink mints +
146/// writes the value) versus an external backend like Vault (where the operator
147/// seeds the value out-of-band and the sink only stamps the ref). Computed from
148/// `env` before the `&mut env` engine call so the `provision` closure captures
149/// owned values, not a borrow of `env`. The owner→tenant-segment mapping (and the
150/// fail-closed rule for non-custodial backends with no owner) lives in
151/// [`crate::cli::messaging::provision_webhook_secret`].
152fn webhook_provision_ctx(env: &Environment) -> (Option<String>, bool) {
153    let owner = env
154        .host_config
155        .tenant_org_id
156        .as_deref()
157        .map(str::trim)
158        .filter(|s| !s.is_empty())
159        .map(str::to_string);
160    (owner, crate::cli::env::secrets_backend_is_dev_store(env))
161}
162
163impl LocalFsStore {
164    // -------------------------------------------------------------
165    // Environment lifecycle  (PR-3a.3)
166    //   `op env create | update | set-public-url`
167    //   `op config set`
168    // -------------------------------------------------------------
169
170    /// Create a fresh environment with empty bundles/revisions/packs.
171    /// Rejects (via [`StoreError::Conflict`]) if the env already exists —
172    /// callers wanting upsert semantics should call
173    /// [`Self::update_environment`].
174    ///
175    /// The caller's [`EnvironmentHostConfig::env_id`] is overwritten with
176    /// `env_id` so the on-disk row's host-config envelope cannot disagree
177    /// with the directory it lands in.
178    pub fn create_environment(
179        &self,
180        env_id: &EnvId,
181        name: String,
182        host_config: EnvironmentHostConfig,
183    ) -> Result<Environment, StoreError> {
184        self.transact(env_id, |locked| {
185            // Existence check must reject non-NotFound errors instead of
186            // treating "load failed" as "env doesn't exist". A corrupt
187            // `environment.json`, an env-id mismatch, or any I/O error
188            // would otherwise fall through to fresh `Environment`
189            // construction and overwrite the existing (recoverable) file
190            // — silent data loss while reporting create success.
191            match locked.load() {
192                Ok(_) => {
193                    return Err(StoreError::Conflict(format!(
194                        "environment `{}` already exists",
195                        locked.env_id()
196                    )));
197                }
198                Err(StoreError::NotFound(_)) => {}
199                Err(e) => return Err(e),
200            }
201            let env = engine::fresh_environment(
202                locked.env_id(),
203                name,
204                host_config,
205                RevocationConfig::default(),
206                RetentionPolicy::default(),
207                HealthStatus::default(),
208            );
209            locked.save(&env)?;
210            Ok(env)
211        })
212    }
213
214    /// Patch the named scalar fields on an existing env. [`FieldUpdate::Keep`]
215    /// fields are skipped, [`FieldUpdate::Set`] writes the new value, and
216    /// [`FieldUpdate::Clear`] resets an optional field to `None`. Returns the
217    /// fully-updated [`Environment`]. Collapses what was previously split
218    /// across the `op env update`, `op env set-public-url`, and `op config
219    /// set` verbs — see [`UpdateEnvironmentPayload`] for the rationale.
220    ///
221    /// `StoreError::NotFound` passes through unchanged; the CLI mapper
222    /// downcasts it to `OpError::NotFound` via
223    /// [`crate::cli::map_store_err_preserving_noun`].
224    pub fn update_environment(
225        &self,
226        env_id: &EnvId,
227        patch: UpdateEnvironmentPayload,
228    ) -> Result<Environment, StoreError> {
229        self.transact(env_id, |locked| {
230            let mut env = locked.load()?;
231            engine::apply_environment_update(&mut env, patch);
232            locked.save(&env)?;
233            Ok(env)
234        })
235    }
236
237    // -------------------------------------------------------------
238    // Migration  (PR-3a.4)
239    //   `op env migrate-dev --apply`
240    // -------------------------------------------------------------
241
242    /// Merge pack bindings and extension bindings into `target_env_id`,
243    /// optionally seeding a fresh target env from a source when the target
244    /// doesn't exist yet. All work runs under the target's flock so the
245    /// existence check + optional seed + merge + save are atomic.
246    ///
247    /// Skips slots already in the target's `packs` and extension keys
248    /// already in the target's `extensions` (uniqueness on
249    /// `(kind.path(), instance_id)`). Returns `(merged_slot_names,
250    /// merged_extension_key_strings)`.
251    ///
252    /// Returns `StoreError::NotFound` if target is missing AND
253    /// `payload.seed_if_missing` is `None` (the caller asserted target
254    /// presence).
255    pub fn migrate_merge_bindings(
256        &self,
257        target_env_id: &EnvId,
258        payload: MigrateMergePayload,
259    ) -> Result<(Vec<String>, Vec<String>), StoreError> {
260        let MigrateMergePayload {
261            packs,
262            extensions,
263            seed_if_missing,
264        } = payload;
265        self.transact(target_env_id, |locked| {
266            let existing = match locked.load() {
267                Ok(env) => Some(env),
268                Err(StoreError::NotFound(_)) => None,
269                Err(e) => return Err(e),
270            };
271            let mut target_env =
272                engine::seed_or_existing(existing, locked.env_id(), seed_if_missing)
273                    .map_err(map_engine_err)?;
274            // Extension bindings (`Path 3`) are light, referentially
275            // independent state — like `packs`, they migrate.
276            // (`messaging_endpoints` are NOT migrated: they reference
277            // `linked_bundles` that don't migrate, so a blind copy would
278            // break referential integrity.)
279            let report = engine::merge_bindings(&mut target_env, packs, extensions);
280            locked.save(&target_env)?;
281            Ok((report.merged_slots, report.merged_extensions))
282        })
283    }
284
285    // -------------------------------------------------------------
286    // Revision lifecycle — stage  (PR-3a.5)
287    //   `op revisions stage`
288    //   warm/drain/archive land in PR-3a.6
289    // -------------------------------------------------------------
290
291    /// Stage a fresh revision under `payload.deployment_id`. The caller
292    /// supplies the pre-resolved artifact pointers (`bundle_digest`,
293    /// `pack_list`, `pack_list_lock_ref`, `pack_config_refs`) and a
294    /// pre-minted [`RevisionId`] — bundle staging (extract + lock-pin +
295    /// pack-config materialization) runs OUTSIDE the env flock because
296    /// the `rev_dir` is named after the ULID and the extraction cost
297    /// shouldn't hold the lock.
298    ///
299    /// Inside the flock: load → re-validate deployment exists →
300    /// compute `next_sequence = max(existing[deployment]) + 1` → build
301    /// `Revision` (Staged) → push → save.
302    ///
303    /// Returns [`StoreError::DependentNotFound`] when the deployment is
304    /// missing under the env at lock-acquisition time (closes the
305    /// TOCTOU window over any pre-call lookup the caller may have done
306    /// for input validation).
307    ///
308    /// `payload.idempotency_key` is accepted for trait conformance and
309    /// ignored locally; the HTTP backend caches it for A8 §2 replay.
310    pub fn stage_revision(
311        &self,
312        env_id: &EnvId,
313        payload: StageRevisionPayload,
314        _idempotency_key: IdempotencyKey,
315    ) -> Result<Revision, StoreError> {
316        self.transact(env_id, |locked| {
317            let mut env = locked.load()?;
318            let revision = engine::stage_revision(&mut env, payload, Utc::now())
319                .map_err(|err| fold_lifecycle_err(err.into()))?;
320            locked.save(&env)?;
321            Ok(revision)
322        })
323    }
324
325    /// Drive a revision through the `Staged → Warming → Ready` chain and
326    /// apply the client-evaluated warm/ready health-gate outcome. The
327    /// chain advance, the `warmed_at` stamp, the gate-result application
328    /// (Ready on `Ok(())`; Failed on `Err(failure)`, persisted), and the
329    /// `runtime-config.json` refresh all happen inside one
330    /// [`super::store::LocalFsStore::transact`] flock so the on-disk env
331    /// is durable when the call returns.
332    ///
333    /// The lifecycle precondition (`payload.expected_lifecycle`, PR-3a.6b),
334    /// the chain constants, and the gate semantics live in
335    /// [`engine::warm_revision`] — shared verbatim with the
336    /// operator-store-server.
337    ///
338    /// `_idempotency_key` is accepted for trait conformance and ignored
339    /// locally; the HTTP backend caches it for A8 §2 replay.
340    pub fn warm_revision(
341        &self,
342        env_id: &EnvId,
343        payload: WarmRevisionPayload,
344        _idempotency_key: IdempotencyKey,
345    ) -> Result<RevisionTransitionOutcome, StoreError> {
346        self.run_revision_transition(env_id, |env| {
347            engine::warm_revision(env, payload, Utc::now())
348        })
349    }
350
351    /// Transition a `Ready` revision to `Draining`. Pure lifecycle stamp —
352    /// the in-flight drain dance (sessions, WebSocket cleanup) is owned by
353    /// `greentic-start`.
354    ///
355    /// `_idempotency_key` is accepted for trait conformance and ignored
356    /// locally; the HTTP backend caches it for A8 §2 replay.
357    pub fn drain_revision(
358        &self,
359        env_id: &EnvId,
360        revision_id: RevisionId,
361        _idempotency_key: IdempotencyKey,
362    ) -> Result<RevisionTransitionOutcome, StoreError> {
363        self.run_revision_transition(env_id, |env| engine::drain_revision(env, revision_id))
364    }
365
366    /// Archive a revision, walking any of `Staged | Warming | Ready | Failed`
367    /// to `Archived` in one hop and the post-drain `Draining → Inactive →
368    /// Archived` walk end-to-end. Refuses if the revision still routes live
369    /// traffic — callers rebalance via `gtc op traffic set` first.
370    ///
371    /// `_idempotency_key` is accepted for trait conformance and ignored
372    /// locally; the HTTP backend caches it for A8 §2 replay.
373    pub fn archive_revision(
374        &self,
375        env_id: &EnvId,
376        revision_id: RevisionId,
377        _idempotency_key: IdempotencyKey,
378    ) -> Result<RevisionTransitionOutcome, StoreError> {
379        self.run_revision_transition(env_id, |env| engine::archive_revision(env, revision_id))
380    }
381
382    /// Shared transact body for warm/drain/archive: load the env, drive the
383    /// pure engine transform, persist per the engine's rule (`Ok` and
384    /// `env_mutated` errors — the gate-failed flip to `Failed` — save;
385    /// every other error discards), refresh the materialized runtime
386    /// config, return the typed outcome.
387    ///
388    /// The engine reports `starting_lifecycle` (the archive
389    /// eviction-vs-retirement discriminator in
390    /// `cli::revisions::emit_for_op`) — `archive`'s chain can traverse
391    /// `Draining → Inactive → Archived` end-to-end in one call, so the
392    /// final lifecycle alone can't tell whether the eviction hop fired.
393    fn run_revision_transition<F>(
394        &self,
395        env_id: &EnvId,
396        apply: F,
397    ) -> Result<RevisionTransitionOutcome, StoreError>
398    where
399        F: FnOnce(
400            &mut Environment,
401        ) -> Result<engine::RevisionTransition, engine::RevisionLifecycleError>,
402    {
403        self.transact(env_id, |locked| {
404            let mut env = locked.load()?;
405            let transition = match apply(&mut env) {
406                Ok(transition) => {
407                    locked.save(&env)?;
408                    transition
409                }
410                Err(err) if err.env_mutated() => {
411                    // Gate failure: the revision was flipped to `Failed` in
412                    // memory; persist before surfacing (committed-on-error,
413                    // the CLI's mark_committed contract relies on it).
414                    locked.save(&env)?;
415                    return Err(fold_lifecycle_err(err.into()));
416                }
417                Err(err) => return Err(fold_lifecycle_err(err.into())),
418            };
419            // From here on the env mutation is durable on disk. Any
420            // subsequent failure (materialized runtime-config refresh) is
421            // committed-on-error and MUST be surfaced as
422            // `StoreError::CommittedAfterSave` so the CLI audit boundary
423            // fails-closed on an audit-append failure.
424            //
425            // Lifecycle transitions don't change traffic splits today, so
426            // the refresh is a no-op-guarded by change-detection; it
427            // keeps the runtime-config contract uniform across every
428            // mutating verb.
429            locked
430                .refresh_runtime_config(&env)
431                .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
432            Ok(RevisionTransitionOutcome {
433                revision: transition.revision,
434                environment: env,
435                starting_lifecycle: transition.starting_lifecycle,
436            })
437        })
438    }
439
440    // -------------------------------------------------------------
441    // Bundle deployment CRUD  (PR-3a.7 + PR-3a.7b)
442    //   `op bundles add | update | remove`
443    // -------------------------------------------------------------
444
445    /// Add a [`BundleDeployment`] to the env. Rejects with
446    /// [`StoreError::Conflict`] when `(bundle_id, customer_id)` is already
447    /// deployed (verb semantics live in [`engine::add_bundle`]). Writes the
448    /// v1 revenue-policy sidecar via
449    /// [`super::write_revenue_policy_version`] and pins the resulting ref
450    /// on the deployment.
451    ///
452    /// `_idempotency_key` is accepted for trait conformance and ignored
453    /// locally; the HTTP backend caches it for A8 §2 replay.
454    pub fn add_bundle(
455        &self,
456        env_id: &EnvId,
457        payload: AddBundlePayload,
458        _idempotency_key: IdempotencyKey,
459    ) -> Result<BundleDeployment, StoreError> {
460        let env_dir = self.env_dir(env_id)?;
461        self.transact(env_id, |locked| {
462            let mut env = locked.load()?;
463            let idx = engine::add_bundle(
464                &mut env,
465                payload,
466                crate::environment::mint_deployment_id(),
467                Utc::now(),
468            )
469            .map_err(map_bundle_err)?;
470            let operator_key = crate::operator_key::load_existing_only()?;
471            let version = crate::environment::write_revenue_policy_version(
472                &env_dir,
473                &env.bundles[idx],
474                &env.bundles[idx].revenue_share,
475                env.bundles[idx].created_at,
476                &operator_key,
477            )?;
478            env.bundles[idx].revenue_policy_ref = version.policy_ref;
479            locked.save(&env)?;
480            Ok(env.bundles[idx].clone())
481        })
482    }
483
484    /// Patch a [`BundleDeployment`]'s scalar fields. `None` fields are
485    /// skipped (verb semantics live in [`engine::update_bundle`]). When
486    /// `revenue_share` is `Some`, writes a new signed/versioned
487    /// revenue-policy sidecar (chain-linked to the prior version) and pins
488    /// the new ref on the deployment.
489    ///
490    /// Returns [`StoreError::DependentNotFound`] when `deployment_id` is
491    /// absent under the env at lock-acquisition time.
492    ///
493    /// `_idempotency_key` is accepted for trait conformance and ignored
494    /// locally; the HTTP backend caches it for A8 §2 replay.
495    pub fn update_bundle(
496        &self,
497        env_id: &EnvId,
498        payload: UpdateBundlePayload,
499        _idempotency_key: IdempotencyKey,
500    ) -> Result<BundleDeployment, StoreError> {
501        let env_dir = self.env_dir(env_id)?;
502        self.transact(env_id, |locked| {
503            let mut env = locked.load()?;
504            let applied = engine::update_bundle(&mut env, payload).map_err(map_bundle_err)?;
505            if applied.revenue_share_changed {
506                let idx = applied.index;
507                let created_at = Utc::now();
508                let operator_key = crate::operator_key::load_existing_only()?;
509                let version = crate::environment::write_revenue_policy_version(
510                    &env_dir,
511                    &env.bundles[idx],
512                    &env.bundles[idx].revenue_share,
513                    created_at,
514                    &operator_key,
515                )?;
516                env.bundles[idx].revenue_policy_ref = version.policy_ref;
517            }
518            locked.save(&env)?;
519            Ok(env.bundles[applied.index].clone())
520        })
521    }
522
523    /// Remove a [`BundleDeployment`] from the env. Refuses with
524    /// [`StoreError::Conflict`] if the deployment still carries live state
525    /// (any [`greentic_deploy_spec::TrafficSplit`] pointing at it, or any
526    /// non-`Archived` revision under it) — callers run `op traffic clear`
527    /// and archive revisions first. Drops archived revisions for the same
528    /// `deployment_id` so the env stays compact. Verb semantics live in
529    /// [`engine::remove_bundle`]; this wrapper owns the flock + persistence.
530    ///
531    /// Returns [`StoreError::DependentNotFound`] when the deployment is
532    /// absent under the env at lock-acquisition time (matches the
533    /// `DependentNotFound` precedent set by `stage_revision`).
534    ///
535    /// `_idempotency_key` is accepted for trait conformance and ignored
536    /// locally; the HTTP backend caches it for A8 §2 replay.
537    pub fn remove_bundle(
538        &self,
539        env_id: &EnvId,
540        deployment_id: DeploymentId,
541        _idempotency_key: IdempotencyKey,
542    ) -> Result<RemoveBundleOutcome, StoreError> {
543        self.transact(env_id, |locked| {
544            let mut env = locked.load()?;
545            let outcome = engine::remove_bundle(&mut env, deployment_id).map_err(map_bundle_err)?;
546            locked.save(&env)?;
547            Ok(outcome)
548        })
549    }
550
551    // -------------------------------------------------------------
552    // Env-pack binding CRUD  (PR-3a.8)
553    //   `op env-packs add | update | remove | rollback`
554    // -------------------------------------------------------------
555
556    /// Bind a new env-pack slot. Rejects with [`StoreError::Conflict`]
557    /// when the slot is already bound (callers should `update` instead).
558    /// Verb semantics live in [`engine::add_pack_binding`]; this wrapper
559    /// owns the flock + persistence.
560    ///
561    /// `_idempotency_key` is accepted for trait conformance and ignored
562    /// locally; the HTTP backend caches it for A8 §2 replay.
563    pub fn add_pack_binding(
564        &self,
565        env_id: &EnvId,
566        binding: EnvPackBinding,
567        _idempotency_key: IdempotencyKey,
568    ) -> Result<EnvPackBinding, StoreError> {
569        self.transact(env_id, |locked| {
570            let mut env = locked.load()?;
571            let added = engine::add_pack_binding(&mut env, binding).map_err(map_binding_err)?;
572            locked.save(&env)?;
573            Ok(added)
574        })
575    }
576
577    /// Replace the binding on an existing slot. The engine snapshots the
578    /// prior binding inline (one-step-rollback stash) — see
579    /// [`engine::update_pack_binding`].
580    ///
581    /// Returns `(new_binding, new_generation)`.
582    ///
583    /// `_idempotency_key` is accepted for trait conformance and ignored
584    /// locally; the HTTP backend caches it for A8 §2 replay.
585    pub fn update_pack_binding(
586        &self,
587        env_id: &EnvId,
588        slot: CapabilitySlot,
589        binding: EnvPackBinding,
590        _idempotency_key: IdempotencyKey,
591    ) -> Result<(EnvPackBinding, u64), StoreError> {
592        self.transact(env_id, |locked| {
593            let mut env = locked.load()?;
594            let (updated, new_generation) =
595                engine::update_pack_binding(&mut env, slot, binding).map_err(map_binding_err)?;
596            locked.save(&env)?;
597            Ok((updated, new_generation))
598        })
599    }
600
601    /// Remove a pack-binding slot. Returns `(removed_binding,
602    /// removed_generation)`.
603    ///
604    /// `_idempotency_key` is accepted for trait conformance and ignored
605    /// locally; the HTTP backend caches it for A8 §2 replay.
606    pub fn remove_pack_binding(
607        &self,
608        env_id: &EnvId,
609        slot: CapabilitySlot,
610        _idempotency_key: IdempotencyKey,
611    ) -> Result<(EnvPackBinding, u64), StoreError> {
612        self.transact(env_id, |locked| {
613            let mut env = locked.load()?;
614            let (removed, generation) =
615                engine::remove_pack_binding(&mut env, slot).map_err(map_binding_err)?;
616            locked.save(&env)?;
617            Ok((removed, generation))
618        })
619    }
620
621    /// Rollback a pack-binding slot to its one-step-previous snapshot.
622    /// Returns `(restored_binding, new_generation)`. Fails with
623    /// [`StoreError::DependentNotFound`] when the slot doesn't exist
624    /// and [`StoreError::Conflict`] when there is no previous snapshot
625    /// to restore.
626    ///
627    /// `_idempotency_key` is accepted for trait conformance and ignored
628    /// locally; the HTTP backend caches it for A8 §2 replay.
629    pub fn rollback_pack_binding(
630        &self,
631        env_id: &EnvId,
632        slot: CapabilitySlot,
633        _idempotency_key: IdempotencyKey,
634    ) -> Result<(EnvPackBinding, u64), StoreError> {
635        self.transact(env_id, |locked| {
636            let mut env = locked.load()?;
637            let (restored, new_generation) =
638                engine::rollback_pack_binding(&mut env, slot).map_err(map_binding_err)?;
639            locked.save(&env)?;
640            Ok((restored, new_generation))
641        })
642    }
643
644    // -------------------------------------------------------------
645    // Trust root  (PR-3a.2)
646    //   `op env trust-root bootstrap | add | remove`
647    //   `op env init` calls `seed_trust_root_if_absent` for first-init only.
648    // -------------------------------------------------------------
649
650    /// Unconditional re-grant: load (or generate) the operator key and add
651    /// it to the env trust root. Idempotent on case-insensitive key_id
652    /// collision — the existing entry's PEM is overwritten with whatever
653    /// the operator-key file holds today.
654    ///
655    /// **Lock placement.** `operator_key::load_or_generate` runs OUTSIDE the
656    /// env flock so a slow OS RNG seed does not hold the lock; the trust-root
657    /// mutation runs INSIDE the flock so concurrent `add`/`remove` cannot
658    /// race the read-modify-write. Caller is responsible for any authz gate
659    /// before invoking this method — `~/.greentic/operator/key.pem` is
660    /// generated on first call to `load_or_generate`, so an authz failure
661    /// after this method runs would not roll back that side effect.
662    pub fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError> {
663        let op_key = crate::operator_key::load_or_generate()?;
664        let env_dir = self.env_dir(env_id)?;
665        self.transact(env_id, |_locked| seed_op_key(&env_dir, op_key))
666    }
667
668    /// First-init-only variant: returns `None` when `<env_dir>/trust-root.json`
669    /// already exists (operator has touched the trust root via
670    /// bootstrap/add/remove). The existence check and `load_or_generate` both
671    /// sit under the env flock so a concurrent `trust-root remove` cannot race
672    /// the gate, and `~/.greentic/operator/key.pem` is not auto-generated when
673    /// the gate would skip.
674    pub fn seed_trust_root_if_absent(
675        &self,
676        env_id: &EnvId,
677    ) -> Result<Option<TrustRootSeed>, StoreError> {
678        let env_dir = self.env_dir(env_id)?;
679        let tr_path = trust_root_path(&env_dir);
680        self.transact(env_id, |_locked| {
681            if tr_path.exists() {
682                return Ok(None);
683            }
684            let op_key = crate::operator_key::load_or_generate()?;
685            seed_op_key(&env_dir, op_key).map(Some)
686        })
687    }
688
689    /// Add a trusted (key_id, public_key_pem) entry to the env trust root.
690    /// Validates `key_id` matches the canonical derivation from `pem` and
691    /// rejects empty/whitespace key ids. Idempotent on case-insensitive
692    /// `key_id` collision.
693    ///
694    /// `_idempotency_key` is accepted for trait-conformance with
695    /// [`super::mutations::EnvironmentMutations::add_trusted_key`] and
696    /// ignored locally — the HTTP backend caches it for A8 §2 replay.
697    pub fn add_trusted_key(
698        &self,
699        env_id: &EnvId,
700        key_id: String,
701        public_key_pem: String,
702        _idempotency_key: IdempotencyKey,
703    ) -> Result<TrustRootAddOutcome, StoreError> {
704        let env_dir = self.env_dir(env_id)?;
705        self.transact(env_id, |_locked| {
706            let trust = store_trust_root::add_trusted_key(
707                &env_dir,
708                TrustedKey {
709                    key_id: key_id.clone(),
710                    public_key_pem,
711                },
712            )?;
713            Ok(TrustRootAddOutcome {
714                added_key_id: key_id,
715                trusted_key_count: trust.keys.len(),
716            })
717        })
718    }
719
720    /// Remove a trusted key by case-insensitive `key_id`. Silent no-op when
721    /// the id is absent. Captures the pre-state PEM under the flock for
722    /// race-safe recovery reporting.
723    ///
724    /// `_idempotency_key` is accepted for trait-conformance with
725    /// [`super::mutations::EnvironmentMutations::remove_trusted_key`] and
726    /// ignored locally. The HTTP backend MUST cache and replay the original
727    /// outcome so retries don't surface `removed_public_key_pem: None` (the
728    /// failure mode that motivated requiring the key).
729    pub fn remove_trusted_key(
730        &self,
731        env_id: &EnvId,
732        key_id: String,
733        _idempotency_key: IdempotencyKey,
734    ) -> Result<TrustRootRemoveOutcome, StoreError> {
735        let env_dir = self.env_dir(env_id)?;
736        self.transact(env_id, |_locked| {
737            let pre = store_trust_root::load(&env_dir)?;
738            let removed_public_key_pem = pre
739                .keys
740                .iter()
741                .find(|k| k.key_id.eq_ignore_ascii_case(&key_id))
742                .map(|k| k.public_key_pem.clone());
743            let trust = store_trust_root::remove_trusted_key(&env_dir, &key_id)?;
744            Ok(TrustRootRemoveOutcome {
745                removed_key_id: key_id,
746                removed_public_key_pem,
747                trusted_key_count: trust.keys.len(),
748            })
749        })
750    }
751
752    // -------------------------------------------------------------
753    // Extension binding CRUD  (PR-3a.9)
754    //   `op extensions add | update | remove | rollback`
755    // -------------------------------------------------------------
756
757    /// Add a new extension binding to the env. Rejects with
758    /// [`StoreError::Conflict`] if a binding with the same
759    /// `(kind.path(), instance_id)` key already exists — callers wanting
760    /// to replace use [`Self::update_extension_binding`]. Verb semantics
761    /// live in [`engine::add_extension_binding`].
762    ///
763    /// `_idempotency_key` is accepted for trait conformance and ignored
764    /// locally; the HTTP backend caches it for A8 §2 replay.
765    pub fn add_extension_binding(
766        &self,
767        env_id: &EnvId,
768        binding: ExtensionBinding,
769        _idempotency_key: IdempotencyKey,
770    ) -> Result<ExtensionBinding, StoreError> {
771        self.transact(env_id, |locked| {
772            let mut env = locked.load()?;
773            let added =
774                engine::add_extension_binding(&mut env, binding).map_err(map_binding_err)?;
775            locked.save(&env)?;
776            Ok(added)
777        })
778    }
779
780    /// Replace an existing extension binding identified by `key`. The
781    /// engine bumps `generation` and stashes the prior binding inline so
782    /// [`Self::rollback_extension_binding`] can restore it — see
783    /// [`engine::update_extension_binding`].
784    ///
785    /// Returns `(new_binding, new_generation)`.
786    ///
787    /// `_idempotency_key` is accepted for trait conformance and ignored
788    /// locally; the HTTP backend caches it for A8 §2 replay.
789    pub fn update_extension_binding(
790        &self,
791        env_id: &EnvId,
792        key: ExtensionKey,
793        binding: ExtensionBinding,
794        _idempotency_key: IdempotencyKey,
795    ) -> Result<(ExtensionBinding, u64), StoreError> {
796        self.transact(env_id, |locked| {
797            let mut env = locked.load()?;
798            let (updated, new_generation) =
799                engine::update_extension_binding(&mut env, &key, binding)
800                    .map_err(map_binding_err)?;
801            locked.save(&env)?;
802            Ok((updated, new_generation))
803        })
804    }
805
806    /// Remove an extension binding identified by `key`. Returns the removed
807    /// binding and its generation at the time of removal.
808    ///
809    /// `_idempotency_key` is accepted for trait conformance and ignored
810    /// locally; the HTTP backend caches it for A8 §2 replay.
811    pub fn remove_extension_binding(
812        &self,
813        env_id: &EnvId,
814        key: ExtensionKey,
815        _idempotency_key: IdempotencyKey,
816    ) -> Result<(ExtensionBinding, u64), StoreError> {
817        self.transact(env_id, |locked| {
818            let mut env = locked.load()?;
819            let (removed, generation) =
820                engine::remove_extension_binding(&mut env, &key).map_err(map_binding_err)?;
821            locked.save(&env)?;
822            Ok((removed, generation))
823        })
824    }
825
826    /// Rollback an extension binding to its previous version. Requires the
827    /// binding to have a stashed `previous_binding_ref`. Bumps generation
828    /// and clears the stash so a second rollback fails (single-step only).
829    ///
830    /// `_idempotency_key` is accepted for trait conformance and ignored
831    /// locally; the HTTP backend caches it for A8 §2 replay.
832    pub fn rollback_extension_binding(
833        &self,
834        env_id: &EnvId,
835        key: ExtensionKey,
836        _idempotency_key: IdempotencyKey,
837    ) -> Result<(ExtensionBinding, u64), StoreError> {
838        self.transact(env_id, |locked| {
839            let mut env = locked.load()?;
840            let (restored, new_generation) =
841                engine::rollback_extension_binding(&mut env, &key).map_err(map_binding_err)?;
842            locked.save(&env)?;
843            Ok((restored, new_generation))
844        })
845    }
846
847    // -------------------------------------------------------------
848    // Traffic split  (PR-3a.11)
849    //   `op traffic set | rollback`
850    // -------------------------------------------------------------
851
852    /// Replace the entire traffic-split entry list for one deployment.
853    /// Pure semantics (10,000 bps sum invariant, §5.3 admission, the
854    /// idempotency contract, the one-step rollback stash) live in
855    /// [`engine::set_traffic_split`]; this wrapper owns persistence and the
856    /// derived `runtime-config.json`.
857    ///
858    /// Post-save, the materialized `runtime-config.json` is refreshed. A
859    /// failure there wraps as [`StoreError::CommittedAfterSave`] so the CLI
860    /// audit fires for the already-persisted mutation. The idempotent no-op
861    /// replay skips the save but still reconciles runtime-config (repairs a
862    /// prior publish failure) — a refresh failure there is NOT
863    /// committed-after-save, because nothing new was committed.
864    ///
865    /// `TrafficSplitApplied` telemetry is emitted by the CLI layer from the
866    /// outcome's env snapshot (identical local and remote), not here.
867    pub fn set_traffic_split(
868        &self,
869        env_id: &EnvId,
870        payload: SetTrafficSplitPayload,
871        idempotency_key: IdempotencyKey,
872    ) -> Result<ApplyTrafficSplitOutcome, StoreError> {
873        self.transact(env_id, |locked| {
874            let mut env = locked.load()?;
875            let transition =
876                engine::set_traffic_split(&mut env, payload, &idempotency_key, Utc::now())
877                    .map_err(map_traffic_err)?;
878            if transition.mutated() {
879                locked.save(&env)?;
880                // From here on the env mutation is durable on disk. Any
881                // subsequent failure (runtime-config refresh) wraps as
882                // `CommittedAfterSave` so the CLI audit fires for the
883                // already-persisted mutation.
884                locked
885                    .refresh_runtime_config(&env)
886                    .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
887            } else {
888                // No-op replay. Reconcile the derived runtime-config before
889                // returning so a retry repairs a publish that failed after
890                // environment.json was already durable.
891                locked.refresh_runtime_config(&env)?;
892            }
893            Ok(ApplyTrafficSplitOutcome {
894                split: transition.split,
895                previous_generation: transition.previous_generation,
896                new_generation: transition.new_generation,
897                environment: env,
898            })
899        })
900    }
901
902    /// Rollback the traffic split for a deployment to its one-step-previous
903    /// snapshot. Pure semantics live in [`engine::rollback_traffic_split`];
904    /// this wrapper owns persistence and the `runtime-config.json` refresh
905    /// (wrapped as [`StoreError::CommittedAfterSave`] post-save).
906    ///
907    /// Returns [`StoreError::DependentNotFound`] when no split exists for
908    /// the deployment, and [`StoreError::Conflict`] when there is no
909    /// previous snapshot to restore.
910    ///
911    /// `_idempotency_key` is accepted for trait conformance and ignored
912    /// locally; the HTTP backend caches it for A8 §2 replay.
913    pub fn rollback_traffic_split(
914        &self,
915        env_id: &EnvId,
916        deployment_id: DeploymentId,
917        _idempotency_key: IdempotencyKey,
918    ) -> Result<RollbackTrafficSplitOutcome, StoreError> {
919        self.transact(env_id, |locked| {
920            let mut env = locked.load()?;
921            let transition = engine::rollback_traffic_split(&mut env, deployment_id, Utc::now())
922                .map_err(map_traffic_err)?;
923            locked.save(&env)?;
924            // From here on the env mutation is durable on disk.
925            locked
926                .refresh_runtime_config(&env)
927                .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
928            Ok(RollbackTrafficSplitOutcome {
929                restored: transition.restored,
930                previous_generation: transition.previous_generation,
931                new_generation: transition.new_generation,
932                environment: env,
933            })
934        })
935    }
936
937    // -------------------------------------------------------------
938    // Messaging endpoint CRUD  (PR-3a.10, engine rewire PR-4.2h)
939    //   `op messaging endpoint add | link-bundle | unlink-bundle
940    //                  | set-welcome-flow | remove | rotate-webhook-secret`
941    //
942    // The verb semantics live in `greentic_deploy_spec::engine::messaging`
943    // (shared with the operator-store-server). This impl supplies the env
944    // flock, the dev-store webhook-secret sink, and the derived
945    // `<env_dir>/messaging/` projection refresh — the projection runs on
946    // EVERY outcome (including engine no-op replays) because it also
947    // repairs a stale projection from a prior failed call.
948    // -------------------------------------------------------------
949
950    /// Add a messaging endpoint. Rejects with [`StoreError::Conflict`] when
951    /// the `(provider_type, provider_id)` pair is already present or when the
952    /// idempotency key was already used for a different endpoint identity.
953    /// Idempotent on same-key same-identity replay (repairs a stale
954    /// projection from a prior failed call).
955    ///
956    /// Telegram-class providers auto-generate a webhook secret at creation
957    /// time via [`crate::cli::messaging::provision_webhook_secret`].
958    pub fn add_messaging_endpoint(
959        &self,
960        env_id: &EnvId,
961        payload: AddMessagingEndpointPayload,
962        idempotency_key: IdempotencyKey,
963    ) -> Result<MessagingEndpoint, StoreError> {
964        self.transact(env_id, |locked| {
965            let mut env = locked.load()?;
966            let eid = MessagingEndpointId::new();
967            let (owner, custodial) = webhook_provision_ctx(&env);
968            let applied = engine::add_messaging_endpoint(
969                &mut env,
970                payload,
971                eid,
972                &idempotency_key,
973                Utc::now(),
974                |existing| {
975                    self.provision_webhook_secret_sink(
976                        env_id,
977                        &eid,
978                        owner.as_deref(),
979                        custodial,
980                        existing,
981                    )
982                },
983            )
984            .map_err(map_messaging_err)?;
985            self.finish_messaging_mutation(locked, &env, applied)
986        })
987    }
988
989    /// Link a bundle to an existing messaging endpoint. Idempotent when the
990    /// bundle is already linked (repairs a stale projection). Rejects with
991    /// [`StoreError::DependentNotFound`] when the endpoint or bundle is
992    /// missing.
993    pub fn link_messaging_bundle(
994        &self,
995        env_id: &EnvId,
996        endpoint_id: MessagingEndpointId,
997        bundle_id: BundleId,
998        updated_by: String,
999        idempotency_key: IdempotencyKey,
1000    ) -> Result<MessagingEndpoint, StoreError> {
1001        self.transact(env_id, |locked| {
1002            let mut env = locked.load()?;
1003            let applied = engine::link_messaging_bundle(
1004                &mut env,
1005                endpoint_id,
1006                bundle_id,
1007                &updated_by,
1008                &idempotency_key,
1009                Utc::now(),
1010            )
1011            .map_err(map_messaging_err)?;
1012            self.finish_messaging_mutation(locked, &env, applied)
1013        })
1014    }
1015
1016    /// Unlink a bundle from an existing messaging endpoint. Idempotent when
1017    /// the bundle is not linked (repairs a stale projection). Rejects with
1018    /// [`StoreError::Conflict`] if the bundle owns the endpoint's
1019    /// `welcome_flow`.
1020    pub fn unlink_messaging_bundle(
1021        &self,
1022        env_id: &EnvId,
1023        endpoint_id: MessagingEndpointId,
1024        bundle_id: BundleId,
1025        updated_by: String,
1026        idempotency_key: IdempotencyKey,
1027    ) -> Result<MessagingEndpoint, StoreError> {
1028        self.transact(env_id, |locked| {
1029            let mut env = locked.load()?;
1030            let applied = engine::unlink_messaging_bundle(
1031                &mut env,
1032                endpoint_id,
1033                bundle_id,
1034                &updated_by,
1035                &idempotency_key,
1036                Utc::now(),
1037            )
1038            .map_err(map_messaging_err)?;
1039            self.finish_messaging_mutation(locked, &env, applied)
1040        })
1041    }
1042
1043    /// Set the welcome flow on a messaging endpoint. Rejects with
1044    /// [`StoreError::InvalidArgument`] when the bundle is not linked, or when
1045    /// `pack_id` does not appear in any current revision's pack_list.
1046    /// Idempotent when the same welcome flow ref is already set (repairs a
1047    /// stale projection).
1048    pub fn set_messaging_welcome_flow(
1049        &self,
1050        env_id: &EnvId,
1051        payload: SetMessagingWelcomeFlowPayload,
1052        idempotency_key: IdempotencyKey,
1053    ) -> Result<MessagingEndpoint, StoreError> {
1054        self.transact(env_id, |locked| {
1055            let mut env = locked.load()?;
1056            let applied =
1057                engine::set_messaging_welcome_flow(&mut env, payload, &idempotency_key, Utc::now())
1058                    .map_err(map_messaging_err)?;
1059            self.finish_messaging_mutation(locked, &env, applied)
1060        })
1061    }
1062
1063    /// Remove a messaging endpoint by id. Idempotent when the endpoint is
1064    /// already absent (repairs a stale projection). Returns the id of the
1065    /// removed endpoint.
1066    pub fn remove_messaging_endpoint(
1067        &self,
1068        env_id: &EnvId,
1069        endpoint_id: MessagingEndpointId,
1070    ) -> Result<MessagingEndpointId, StoreError> {
1071        self.transact(env_id, |locked| {
1072            let mut env = locked.load()?;
1073            if engine::remove_messaging_endpoint(&mut env, endpoint_id) {
1074                locked.save(&env)?;
1075            }
1076            // Refresh runs even on the absent-endpoint no-op: it repairs a
1077            // stale projection from a prior failed call.
1078            locked
1079                .refresh_messaging_projection(&env)
1080                .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
1081            Ok(endpoint_id)
1082        })
1083    }
1084
1085    /// Rotate the webhook secret for a messaging endpoint. Generates a new
1086    /// CSPRNG secret value, writes it to the dev-store under the existing
1087    /// (or freshly-built) secret ref URI, and bumps generation.
1088    /// Idempotent on same-idem-key replay (returns the existing endpoint
1089    /// without re-generating).
1090    pub fn rotate_messaging_webhook_secret(
1091        &self,
1092        env_id: &EnvId,
1093        endpoint_id: MessagingEndpointId,
1094        updated_by: String,
1095        idempotency_key: IdempotencyKey,
1096    ) -> Result<MessagingEndpoint, StoreError> {
1097        self.transact(env_id, |locked| {
1098            let mut env = locked.load()?;
1099            let (owner, custodial) = webhook_provision_ctx(&env);
1100            // The control plane can only rotate a value it custodies (the local
1101            // dev-store). For an external backend (e.g. Vault) the value lives in
1102            // the backend and is rotated out-of-band, so fail closed rather than
1103            // bump generation and report success without changing anything.
1104            if !custodial {
1105                return Err(StoreError::Conflict(format!(
1106                    "cannot rotate a webhook secret on a non-dev-store secrets backend from the \
1107                     control plane (env `{}`); rotate the value in the secrets backend and \
1108                     re-seed it (e.g. via `op secrets put`)",
1109                    env_id.as_str()
1110                )));
1111            }
1112            let applied = engine::rotate_messaging_webhook_secret(
1113                &mut env,
1114                endpoint_id,
1115                &updated_by,
1116                &idempotency_key,
1117                Utc::now(),
1118                // The local store mints its own value; it never threads a
1119                // caller-supplied ref (the CLI rejects one).
1120                None,
1121                |existing| {
1122                    self.provision_webhook_secret_sink(
1123                        env_id,
1124                        &endpoint_id,
1125                        owner.as_deref(),
1126                        custodial,
1127                        existing,
1128                    )
1129                },
1130            )
1131            .map_err(map_messaging_err)?;
1132            self.finish_messaging_mutation(locked, &env, applied)
1133        })
1134    }
1135
1136    /// The LocalFS webhook-secret sink for the engine's `provision` seam: build
1137    /// the ref under the env `owner` (or `default` for an ownerless custodial
1138    /// env) and, when `custodial`, mint a CSPRNG value and write it into the
1139    /// env-pack dev-store via [`crate::cli::messaging::provision_webhook_secret`].
1140    /// A non-custodial backend with no `owner` fails closed there. Failures map to
1141    /// [`engine::MessagingError::SecretProvision`], which
1142    /// [`map_messaging_err`] folds back onto the `Conflict` noun this store
1143    /// raised before the engine rewire.
1144    fn provision_webhook_secret_sink(
1145        &self,
1146        env_id: &EnvId,
1147        endpoint_id: &MessagingEndpointId,
1148        owner: Option<&str>,
1149        custodial: bool,
1150        existing_ref: Option<&SecretRef>,
1151    ) -> Result<SecretRef, engine::MessagingError> {
1152        crate::cli::messaging::provision_webhook_secret(
1153            self,
1154            env_id,
1155            endpoint_id,
1156            owner,
1157            custodial,
1158            existing_ref,
1159        )
1160        .map_err(|e| engine::MessagingError::SecretProvision(e.to_string()))
1161    }
1162
1163    /// Shared tail of every endpoint-returning messaging verb: persist when
1164    /// the engine actually mutated, refresh the derived projection on EVERY
1165    /// outcome (no-op replays repair a stale projection from a prior failed
1166    /// call), and clone the affected endpoint out.
1167    fn finish_messaging_mutation(
1168        &self,
1169        locked: &super::store::Locked<'_>,
1170        env: &Environment,
1171        applied: engine::MessagingApplied,
1172    ) -> Result<MessagingEndpoint, StoreError> {
1173        if applied.mutated {
1174            locked.save(env)?;
1175        }
1176        let ep = env.messaging_endpoints[applied.index].clone();
1177        locked
1178            .refresh_messaging_projection(env)
1179            .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
1180        Ok(ep)
1181    }
1182
1183    // -------------------------------------------------------------
1184    // Bootstrap  (PR-3a.12)
1185    //   `op env init` — idempotent first-run bootstrap
1186    // -------------------------------------------------------------
1187
1188    /// Get-or-create-with-heal: idempotent first-run bootstrap of the `local`
1189    /// [`Environment`] with default env-pack bindings. Returns the env + an
1190    /// outcome variant indicating whether it was Created, Healed (default
1191    /// bindings added), or AlreadyExists (no change needed).
1192    ///
1193    /// The entire read-modify-write runs inside [`LocalFsStore::transact`], so
1194    /// concurrent first-run invocations on the same host serialize on the
1195    /// per-env flock and produce a single env.
1196    ///
1197    /// `refresh_local_runtime_stub` is NOT called here — the CLI layer runs
1198    /// it after the verb returns, outside the flock. The tiny race window
1199    /// (another writer could modify the env between verb-return and
1200    /// stub-refresh) is acceptable because the runtime stub is a derived
1201    /// projection that self-heals on every bootstrap call.
1202    ///
1203    /// This verb is **not** part of the [`super::mutations::EnvironmentMutations`]
1204    /// trait — bootstrap is `LocalFsStore`-specific. Remote stores don't run
1205    /// first-run local bootstrap.
1206    pub fn ensure_local_environment(
1207        &self,
1208        env_id: &EnvId,
1209        payload: EnsureLocalEnvironmentPayload,
1210    ) -> Result<(Environment, LocalEnvOutcome), StoreError> {
1211        self.transact(env_id, |locked| {
1212            match locked.load() {
1213                Ok(mut existing) => {
1214                    // The URL is only applied on creation; overwriting an
1215                    // existing env's URL goes through `op env set-public-url`.
1216                    if payload.public_base_url.is_some() {
1217                        return Err(StoreError::InvalidArgument(format!(
1218                            "env `{}` already exists; use `op env set-public-url <env_id> <URL>` \
1219                             to overwrite the persisted public URL",
1220                            locked.env_id()
1221                        )));
1222                    }
1223                    let added = fill_missing_default_bindings(&mut existing)?;
1224                    if added.is_empty() {
1225                        return Ok((existing, LocalEnvOutcome::AlreadyExists));
1226                    }
1227                    locked.save(&existing)?;
1228                    Ok((existing, LocalEnvOutcome::Healed { added_slots: added }))
1229                }
1230                Err(StoreError::NotFound(_)) => {
1231                    let packs = crate::defaults::local_pack_bindings().map_err(|e| {
1232                        StoreError::InvalidArgument(format!("default pack binding parse: {e}"))
1233                    })?;
1234                    let env = Environment {
1235                        schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
1236                        environment_id: locked.env_id().clone(),
1237                        name: env_id.as_str().to_string(),
1238                        host_config: EnvironmentHostConfig {
1239                            env_id: locked.env_id().clone(),
1240                            region: None,
1241                            tenant_org_id: None,
1242                            listen_addr: Some(greentic_deploy_spec::DEFAULT_LISTEN_ADDR),
1243                            public_base_url: payload.public_base_url.clone(),
1244                            gui_enabled: None,
1245                        },
1246                        packs,
1247                        credentials_ref: None,
1248                        bundles: Vec::new(),
1249                        revisions: Vec::new(),
1250                        traffic_splits: Vec::new(),
1251                        messaging_endpoints: Vec::new(),
1252                        extensions: Vec::new(),
1253                        revocation: Default::default(),
1254                        retention: Default::default(),
1255                        health: Default::default(),
1256                    };
1257                    locked.save(&env)?;
1258                    Ok((env, LocalEnvOutcome::Created))
1259                }
1260                Err(e) => Err(e),
1261            }
1262        })
1263    }
1264}
1265
1266// Endpoint lookup and welcome-flow pack_id validation moved to
1267// `greentic_deploy_spec::engine::messaging` (PR-4.2h) so the
1268// operator-store-server validates identically.
1269
1270// `fresh_environment` moved to `greentic_deploy_spec::engine` (PR-4.2a) so
1271// the operator-store-server seeds envs identically.
1272
1273/// Persist `op_key` as a trusted entry on `env_dir`'s trust root and shape
1274/// the typed [`TrustRootSeed`] outcome. Shared body of `bootstrap_trust_root`
1275/// and `seed_trust_root_if_absent` — invariant is that the env flock is held
1276/// at the call site (both callers wrap in `self.transact`).
1277fn seed_op_key(
1278    env_dir: &Path,
1279    op_key: crate::operator_key::OperatorKey,
1280) -> Result<TrustRootSeed, StoreError> {
1281    let trust = store_trust_root::add_trusted_key(
1282        env_dir,
1283        TrustedKey {
1284            key_id: op_key.key_id.clone(),
1285            public_key_pem: op_key.public_pem.clone(),
1286        },
1287    )?;
1288    Ok(TrustRootSeed {
1289        key_id: op_key.key_id,
1290        public_key_pem: op_key.public_pem,
1291        trusted_key_count: trust.keys.len(),
1292    })
1293}
1294
1295// ---------------------------------------------------------------------------
1296// Trait impl — thin forwarders to the inherent methods above.
1297//
1298// PR-3a.16: every inherent method landed independently (PR-3a.2..3a.15);
1299// now that all 30 exist, this block wires the `EnvironmentMutations` trait
1300// so callers can use `dyn EnvironmentMutations` / generic `T: EnvironmentMutations`.
1301// ---------------------------------------------------------------------------
1302
1303impl EnvironmentMutations for LocalFsStore {
1304    /// See [`LocalFsStore::create_environment`].
1305    fn create_environment(
1306        &self,
1307        env_id: &EnvId,
1308        name: String,
1309        host_config: EnvironmentHostConfig,
1310    ) -> Result<Environment, StoreError> {
1311        self.create_environment(env_id, name, host_config)
1312    }
1313
1314    /// See [`LocalFsStore::update_environment`].
1315    fn update_environment(
1316        &self,
1317        env_id: &EnvId,
1318        patch: UpdateEnvironmentPayload,
1319    ) -> Result<Environment, StoreError> {
1320        self.update_environment(env_id, patch)
1321    }
1322
1323    /// Reads the persisted env via [`EnvironmentStore::load`] — the trait's
1324    /// one read verb, surfaced here so `&dyn EnvironmentMutations` callers
1325    /// (the remote dispatch) can evaluate client-side preconditions.
1326    fn load_environment(&self, env_id: &EnvId) -> Result<Environment, StoreError> {
1327        EnvironmentStore::load(self, env_id)
1328    }
1329
1330    /// A seeded env has the trust-root file present — the seed/add paths always
1331    /// write ≥1 key, mirroring the `tr_path.exists()` check in
1332    /// [`LocalFsStore::seed_trust_root_if_absent`].
1333    fn trust_root_is_seeded(&self, env_id: &EnvId) -> Result<bool, StoreError> {
1334        let env_dir = self.env_dir(env_id)?;
1335        Ok(trust_root_path(&env_dir).exists())
1336    }
1337
1338    /// See [`LocalFsStore::migrate_merge_bindings`].
1339    fn migrate_merge_bindings(
1340        &self,
1341        target_env_id: &EnvId,
1342        payload: MigrateMergePayload,
1343    ) -> Result<(Vec<String>, Vec<String>), StoreError> {
1344        self.migrate_merge_bindings(target_env_id, payload)
1345    }
1346
1347    /// See [`LocalFsStore::stage_revision`].
1348    fn stage_revision(
1349        &self,
1350        env_id: &EnvId,
1351        payload: StageRevisionPayload,
1352        idempotency_key: IdempotencyKey,
1353    ) -> Result<Revision, StoreError> {
1354        self.stage_revision(env_id, payload, idempotency_key)
1355    }
1356
1357    /// See [`LocalFsStore::warm_revision`].
1358    fn warm_revision(
1359        &self,
1360        env_id: &EnvId,
1361        payload: WarmRevisionPayload,
1362        idempotency_key: IdempotencyKey,
1363    ) -> Result<RevisionTransitionOutcome, StoreError> {
1364        self.warm_revision(env_id, payload, idempotency_key)
1365    }
1366
1367    /// See [`LocalFsStore::drain_revision`].
1368    fn drain_revision(
1369        &self,
1370        env_id: &EnvId,
1371        revision_id: RevisionId,
1372        idempotency_key: IdempotencyKey,
1373    ) -> Result<RevisionTransitionOutcome, StoreError> {
1374        self.drain_revision(env_id, revision_id, idempotency_key)
1375    }
1376
1377    /// See [`LocalFsStore::archive_revision`].
1378    fn archive_revision(
1379        &self,
1380        env_id: &EnvId,
1381        revision_id: RevisionId,
1382        idempotency_key: IdempotencyKey,
1383    ) -> Result<RevisionTransitionOutcome, StoreError> {
1384        self.archive_revision(env_id, revision_id, idempotency_key)
1385    }
1386
1387    /// See [`LocalFsStore::add_bundle`].
1388    fn add_bundle(
1389        &self,
1390        env_id: &EnvId,
1391        payload: AddBundlePayload,
1392        idempotency_key: IdempotencyKey,
1393    ) -> Result<BundleDeployment, StoreError> {
1394        self.add_bundle(env_id, payload, idempotency_key)
1395    }
1396
1397    /// See [`LocalFsStore::update_bundle`].
1398    fn update_bundle(
1399        &self,
1400        env_id: &EnvId,
1401        payload: UpdateBundlePayload,
1402        idempotency_key: IdempotencyKey,
1403    ) -> Result<BundleDeployment, StoreError> {
1404        self.update_bundle(env_id, payload, idempotency_key)
1405    }
1406
1407    /// See [`LocalFsStore::remove_bundle`].
1408    fn remove_bundle(
1409        &self,
1410        env_id: &EnvId,
1411        deployment_id: DeploymentId,
1412        idempotency_key: IdempotencyKey,
1413    ) -> Result<RemoveBundleOutcome, StoreError> {
1414        self.remove_bundle(env_id, deployment_id, idempotency_key)
1415    }
1416
1417    /// See [`LocalFsStore::add_pack_binding`].
1418    fn add_pack_binding(
1419        &self,
1420        env_id: &EnvId,
1421        binding: EnvPackBinding,
1422        idempotency_key: IdempotencyKey,
1423    ) -> Result<EnvPackBinding, StoreError> {
1424        self.add_pack_binding(env_id, binding, idempotency_key)
1425    }
1426
1427    /// See [`LocalFsStore::update_pack_binding`].
1428    fn update_pack_binding(
1429        &self,
1430        env_id: &EnvId,
1431        slot: CapabilitySlot,
1432        binding: EnvPackBinding,
1433        idempotency_key: IdempotencyKey,
1434    ) -> Result<(EnvPackBinding, u64), StoreError> {
1435        self.update_pack_binding(env_id, slot, binding, idempotency_key)
1436    }
1437
1438    /// See [`LocalFsStore::remove_pack_binding`].
1439    fn remove_pack_binding(
1440        &self,
1441        env_id: &EnvId,
1442        slot: CapabilitySlot,
1443        idempotency_key: IdempotencyKey,
1444    ) -> Result<(EnvPackBinding, u64), StoreError> {
1445        self.remove_pack_binding(env_id, slot, idempotency_key)
1446    }
1447
1448    /// See [`LocalFsStore::rollback_pack_binding`].
1449    fn rollback_pack_binding(
1450        &self,
1451        env_id: &EnvId,
1452        slot: CapabilitySlot,
1453        idempotency_key: IdempotencyKey,
1454    ) -> Result<(EnvPackBinding, u64), StoreError> {
1455        self.rollback_pack_binding(env_id, slot, idempotency_key)
1456    }
1457
1458    /// See [`LocalFsStore::add_extension_binding`].
1459    fn add_extension_binding(
1460        &self,
1461        env_id: &EnvId,
1462        binding: ExtensionBinding,
1463        idempotency_key: IdempotencyKey,
1464    ) -> Result<ExtensionBinding, StoreError> {
1465        self.add_extension_binding(env_id, binding, idempotency_key)
1466    }
1467
1468    /// See [`LocalFsStore::update_extension_binding`].
1469    fn update_extension_binding(
1470        &self,
1471        env_id: &EnvId,
1472        key: ExtensionKey,
1473        binding: ExtensionBinding,
1474        idempotency_key: IdempotencyKey,
1475    ) -> Result<(ExtensionBinding, u64), StoreError> {
1476        self.update_extension_binding(env_id, key, binding, idempotency_key)
1477    }
1478
1479    /// See [`LocalFsStore::remove_extension_binding`].
1480    fn remove_extension_binding(
1481        &self,
1482        env_id: &EnvId,
1483        key: ExtensionKey,
1484        idempotency_key: IdempotencyKey,
1485    ) -> Result<(ExtensionBinding, u64), StoreError> {
1486        self.remove_extension_binding(env_id, key, idempotency_key)
1487    }
1488
1489    /// See [`LocalFsStore::rollback_extension_binding`].
1490    fn rollback_extension_binding(
1491        &self,
1492        env_id: &EnvId,
1493        key: ExtensionKey,
1494        idempotency_key: IdempotencyKey,
1495    ) -> Result<(ExtensionBinding, u64), StoreError> {
1496        self.rollback_extension_binding(env_id, key, idempotency_key)
1497    }
1498
1499    /// See [`LocalFsStore::set_traffic_split`].
1500    fn set_traffic_split(
1501        &self,
1502        env_id: &EnvId,
1503        payload: SetTrafficSplitPayload,
1504        idempotency_key: IdempotencyKey,
1505    ) -> Result<ApplyTrafficSplitOutcome, StoreError> {
1506        self.set_traffic_split(env_id, payload, idempotency_key)
1507    }
1508
1509    /// See [`LocalFsStore::rollback_traffic_split`].
1510    fn rollback_traffic_split(
1511        &self,
1512        env_id: &EnvId,
1513        deployment_id: DeploymentId,
1514        idempotency_key: IdempotencyKey,
1515    ) -> Result<RollbackTrafficSplitOutcome, StoreError> {
1516        self.rollback_traffic_split(env_id, deployment_id, idempotency_key)
1517    }
1518
1519    /// See [`LocalFsStore::add_messaging_endpoint`].
1520    fn add_messaging_endpoint(
1521        &self,
1522        env_id: &EnvId,
1523        payload: AddMessagingEndpointPayload,
1524        idempotency_key: IdempotencyKey,
1525    ) -> Result<MessagingEndpoint, StoreError> {
1526        self.add_messaging_endpoint(env_id, payload, idempotency_key)
1527    }
1528
1529    /// See [`LocalFsStore::link_messaging_bundle`].
1530    fn link_messaging_bundle(
1531        &self,
1532        env_id: &EnvId,
1533        endpoint_id: MessagingEndpointId,
1534        bundle_id: BundleId,
1535        updated_by: String,
1536        idempotency_key: IdempotencyKey,
1537    ) -> Result<MessagingEndpoint, StoreError> {
1538        self.link_messaging_bundle(env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
1539    }
1540
1541    /// See [`LocalFsStore::unlink_messaging_bundle`].
1542    fn unlink_messaging_bundle(
1543        &self,
1544        env_id: &EnvId,
1545        endpoint_id: MessagingEndpointId,
1546        bundle_id: BundleId,
1547        updated_by: String,
1548        idempotency_key: IdempotencyKey,
1549    ) -> Result<MessagingEndpoint, StoreError> {
1550        self.unlink_messaging_bundle(env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
1551    }
1552
1553    /// See [`LocalFsStore::set_messaging_welcome_flow`].
1554    fn set_messaging_welcome_flow(
1555        &self,
1556        env_id: &EnvId,
1557        payload: SetMessagingWelcomeFlowPayload,
1558        idempotency_key: IdempotencyKey,
1559    ) -> Result<MessagingEndpoint, StoreError> {
1560        self.set_messaging_welcome_flow(env_id, payload, idempotency_key)
1561    }
1562
1563    /// See [`LocalFsStore::remove_messaging_endpoint`].
1564    fn remove_messaging_endpoint(
1565        &self,
1566        env_id: &EnvId,
1567        endpoint_id: MessagingEndpointId,
1568    ) -> Result<MessagingEndpointId, StoreError> {
1569        self.remove_messaging_endpoint(env_id, endpoint_id)
1570    }
1571
1572    /// See [`LocalFsStore::rotate_messaging_webhook_secret`].
1573    fn rotate_messaging_webhook_secret(
1574        &self,
1575        env_id: &EnvId,
1576        endpoint_id: MessagingEndpointId,
1577        updated_by: String,
1578        idempotency_key: IdempotencyKey,
1579    ) -> Result<MessagingEndpoint, StoreError> {
1580        self.rotate_messaging_webhook_secret(env_id, endpoint_id, updated_by, idempotency_key)
1581    }
1582
1583    /// See [`LocalFsStore::bootstrap_trust_root`].
1584    fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError> {
1585        self.bootstrap_trust_root(env_id)
1586    }
1587
1588    /// See [`LocalFsStore::seed_trust_root_if_absent`].
1589    fn seed_trust_root_if_absent(
1590        &self,
1591        env_id: &EnvId,
1592    ) -> Result<Option<TrustRootSeed>, StoreError> {
1593        self.seed_trust_root_if_absent(env_id)
1594    }
1595
1596    /// See [`LocalFsStore::add_trusted_key`].
1597    fn add_trusted_key(
1598        &self,
1599        env_id: &EnvId,
1600        key_id: String,
1601        public_key_pem: String,
1602        idempotency_key: IdempotencyKey,
1603    ) -> Result<TrustRootAddOutcome, StoreError> {
1604        self.add_trusted_key(env_id, key_id, public_key_pem, idempotency_key)
1605    }
1606
1607    /// See [`LocalFsStore::remove_trusted_key`].
1608    fn remove_trusted_key(
1609        &self,
1610        env_id: &EnvId,
1611        key_id: String,
1612        idempotency_key: IdempotencyKey,
1613    ) -> Result<TrustRootRemoveOutcome, StoreError> {
1614        self.remove_trusted_key(env_id, key_id, idempotency_key)
1615    }
1616}
1617
1618#[cfg(test)]
1619mod warm_revision_tests {
1620    //! Direct tests for the typed `warm_revision` verb (PR-3a.6). `drain` and
1621    //! `archive` are covered through the CLI integration tests in
1622    //! `cli::revisions`; `warm` is not CLI-wired yet (the closure-shaped
1623    //! `warm_with_health_gate` path still owns the gate consumer) so its
1624    //! typed-verb behavior is locked in here.
1625    use super::*;
1626    use crate::environment::lifecycle::{HealthCheckId, HealthGateFailure, LifecycleError};
1627    use crate::environment::store::EnvironmentStore;
1628    use chrono::{TimeZone, Utc};
1629    use greentic_deploy_spec::{
1630        BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
1631        Environment, EnvironmentHostConfig, PartyId, RevenueShareEntry, Revision, RevisionId,
1632        RevisionLifecycle, RouteBinding, SchemaVersion, TenantSelector,
1633    };
1634    use std::collections::BTreeMap;
1635    use std::path::PathBuf;
1636    use tempfile::tempdir;
1637
1638    const ENV_ID: &str = "local";
1639
1640    fn env_id() -> EnvId {
1641        EnvId::try_from(ENV_ID).unwrap()
1642    }
1643
1644    fn seed_one_staged() -> (LocalFsStore, EnvId, RevisionId) {
1645        let dir = tempdir().unwrap();
1646        let store = LocalFsStore::new(dir.path().to_path_buf());
1647        let did = DeploymentId::new();
1648        let rid = RevisionId::new();
1649        let env = Environment {
1650            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
1651            environment_id: env_id(),
1652            name: ENV_ID.to_string(),
1653            host_config: EnvironmentHostConfig {
1654                env_id: env_id(),
1655                region: None,
1656                tenant_org_id: None,
1657                listen_addr: None,
1658                public_base_url: None,
1659                gui_enabled: None,
1660            },
1661            packs: Vec::new(),
1662            credentials_ref: None,
1663            bundles: vec![BundleDeployment {
1664                schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
1665                deployment_id: did,
1666                env_id: env_id(),
1667                bundle_id: BundleId::new("fast2flow"),
1668                customer_id: CustomerId::new("local-dev"),
1669                status: BundleDeploymentStatus::Active,
1670                current_revisions: vec![rid],
1671                route_binding: RouteBinding {
1672                    hosts: vec!["fast2flow.local".to_string()],
1673                    path_prefixes: Vec::new(),
1674                    tenant_selector: TenantSelector {
1675                        tenant: "default".to_string(),
1676                        team: "default".to_string(),
1677                    },
1678                },
1679                revenue_share: vec![RevenueShareEntry {
1680                    party_id: PartyId::new("greentic"),
1681                    basis_points: 10_000,
1682                }],
1683                revenue_policy_ref: PathBuf::from("revenue.json"),
1684                usage: None,
1685                created_at: Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap(),
1686                authorization_ref: PathBuf::from("auth.json"),
1687                config_overrides: BTreeMap::new(),
1688            }],
1689            revisions: vec![Revision {
1690                schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
1691                revision_id: rid,
1692                env_id: env_id(),
1693                bundle_id: BundleId::new("fast2flow"),
1694                deployment_id: did,
1695                sequence: 1,
1696                created_at: Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap(),
1697                bundle_digest: "sha256:00".to_string(),
1698                bundle_source_uri: None,
1699                pack_list: Vec::new(),
1700                pack_list_lock_ref: PathBuf::new(),
1701                pack_config_refs: Vec::new(),
1702                config_digest: "sha256:00".to_string(),
1703                signature_sidecar_ref: PathBuf::from("rev.sig"),
1704                lifecycle: RevisionLifecycle::Staged,
1705                staged_at: Some(Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap()),
1706                warmed_at: None,
1707                drain_seconds: 30,
1708                abort_metrics: Vec::new(),
1709            }],
1710            traffic_splits: Vec::new(),
1711            messaging_endpoints: Vec::new(),
1712            extensions: Vec::new(),
1713            revocation: Default::default(),
1714            retention: Default::default(),
1715            health: Default::default(),
1716        };
1717        store.save(&env).unwrap();
1718        // `keep()` consumes the tempdir's `Drop` guard so the dir survives the
1719        // test scope without leaking via `mem::forget`.
1720        let _ = dir.keep();
1721        (store, env_id(), rid)
1722    }
1723
1724    fn idem() -> IdempotencyKey {
1725        IdempotencyKey::new(ulid::Ulid::new().to_string()).unwrap()
1726    }
1727
1728    #[test]
1729    fn warm_revision_with_passing_gate_lands_ready_and_stamps_warmed_at() {
1730        let (store, env_id, rid) = seed_one_staged();
1731        let outcome = store
1732            .warm_revision(
1733                &env_id,
1734                WarmRevisionPayload {
1735                    revision_id: rid,
1736                    health_gate: Ok(()),
1737                    expected_lifecycle: RevisionLifecycle::Staged,
1738                },
1739                idem(),
1740            )
1741            .unwrap();
1742        assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Ready);
1743        assert!(outcome.revision.warmed_at.is_some());
1744        assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Staged);
1745        // Persisted.
1746        let env = store.load(&env_id).unwrap();
1747        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1748    }
1749
1750    #[test]
1751    fn warm_revision_with_failing_gate_persists_failed_and_surfaces_health_gate_error() {
1752        let (store, env_id, rid) = seed_one_staged();
1753        let err = store
1754            .warm_revision(
1755                &env_id,
1756                WarmRevisionPayload {
1757                    revision_id: rid,
1758                    health_gate: Err(HealthGateFailure {
1759                        failed_checks: vec![HealthCheckId::RouteTable],
1760                        message: "missing routes".to_string(),
1761                    }),
1762                    expected_lifecycle: RevisionLifecycle::Staged,
1763                },
1764                idem(),
1765            )
1766            .unwrap_err();
1767        match err {
1768            StoreError::Lifecycle(inner) => match *inner {
1769                LifecycleError::HealthGateFailed {
1770                    revision_id,
1771                    failed_checks,
1772                    ..
1773                } => {
1774                    assert_eq!(revision_id, rid);
1775                    assert_eq!(failed_checks, vec![HealthCheckId::RouteTable]);
1776                }
1777                other => panic!("expected HealthGateFailed, got {other:?}"),
1778            },
1779            other => panic!("expected StoreError::Lifecycle, got {other:?}"),
1780        }
1781        // Failed state is durable per the B9 contract.
1782        let env = store.load(&env_id).unwrap();
1783        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1784    }
1785
1786    #[test]
1787    fn drain_revision_advances_ready_to_draining() {
1788        let (store, env_id, rid) = seed_one_staged();
1789        // First warm the revision to Ready (drain only accepts Ready as a start).
1790        store
1791            .warm_revision(
1792                &env_id,
1793                WarmRevisionPayload {
1794                    revision_id: rid,
1795                    health_gate: Ok(()),
1796                    expected_lifecycle: RevisionLifecycle::Staged,
1797                },
1798                idem(),
1799            )
1800            .unwrap();
1801        let outcome = store.drain_revision(&env_id, rid, idem()).unwrap();
1802        assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Draining);
1803        assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Ready);
1804    }
1805
1806    #[test]
1807    fn archive_revision_walks_draining_through_inactive_to_archived() {
1808        let (store, env_id, rid) = seed_one_staged();
1809        store
1810            .warm_revision(
1811                &env_id,
1812                WarmRevisionPayload {
1813                    revision_id: rid,
1814                    health_gate: Ok(()),
1815                    expected_lifecycle: RevisionLifecycle::Staged,
1816                },
1817                idem(),
1818            )
1819            .unwrap();
1820        store.drain_revision(&env_id, rid, idem()).unwrap();
1821        let outcome = store.archive_revision(&env_id, rid, idem()).unwrap();
1822        assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Archived);
1823        // Starting lifecycle must surface `Draining` so the CLI eviction-emit
1824        // discriminator can branch correctly (archive's chain walks
1825        // Draining→Inactive→Archived in one hop, so the final lifecycle alone
1826        // can't tell us we crossed the eviction boundary).
1827        assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Draining);
1828    }
1829
1830    /// PR-3a.6b regression: a concurrent mutation that changes the revision's
1831    /// lifecycle AFTER gate evaluation but BEFORE the typed verb acquires the
1832    /// flock must be rejected. Simulated here by supplying an
1833    /// `expected_lifecycle` that doesn't match the revision's on-disk state.
1834    #[test]
1835    fn warm_with_concurrent_lifecycle_change_rejects() {
1836        let (store, env_id, rid) = seed_one_staged();
1837        // The revision is `Staged` on disk, but the caller claims it observed
1838        // `Ready` (simulating a concurrent drain that landed between gate-eval
1839        // and verb dispatch). The precondition must reject.
1840        let err = store
1841            .warm_revision(
1842                &env_id,
1843                WarmRevisionPayload {
1844                    revision_id: rid,
1845                    health_gate: Ok(()),
1846                    expected_lifecycle: RevisionLifecycle::Ready,
1847                },
1848                idem(),
1849            )
1850            .unwrap_err();
1851        match err {
1852            StoreError::Lifecycle(inner) => match *inner {
1853                LifecycleError::Conflict {
1854                    revision_id: conflict_rid,
1855                    actual,
1856                    expected_starts,
1857                } => {
1858                    assert_eq!(conflict_rid, rid);
1859                    assert_eq!(actual, RevisionLifecycle::Staged);
1860                    assert_eq!(expected_starts, vec![RevisionLifecycle::Ready]);
1861                }
1862                other => panic!("expected LifecycleError::Conflict, got {other:?}"),
1863            },
1864            other => panic!("expected StoreError::Lifecycle, got {other:?}"),
1865        }
1866        // Env untouched — revision stays Staged.
1867        let env = store.load(&env_id).unwrap();
1868        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Staged);
1869    }
1870
1871    /// Complement of `warm_with_concurrent_lifecycle_change_rejects`:
1872    /// an idempotent retry against an already-Ready revision must
1873    /// succeed regardless of the `expected_lifecycle` value, because
1874    /// the chain walk is a no-op and the gate is never fired.
1875    #[test]
1876    fn warm_idempotent_retry_skips_precondition() {
1877        let (store, env_id, rid) = seed_one_staged();
1878        // Warm once (Staged → Ready).
1879        store
1880            .warm_revision(
1881                &env_id,
1882                WarmRevisionPayload {
1883                    revision_id: rid,
1884                    health_gate: Ok(()),
1885                    expected_lifecycle: RevisionLifecycle::Staged,
1886                },
1887                idem(),
1888            )
1889            .unwrap();
1890        // Retry with a stale `expected_lifecycle` (Staged, not Ready).
1891        // Must succeed because the revision is already at the chain's
1892        // final state.
1893        let outcome = store
1894            .warm_revision(
1895                &env_id,
1896                WarmRevisionPayload {
1897                    revision_id: rid,
1898                    health_gate: Ok(()),
1899                    expected_lifecycle: RevisionLifecycle::Staged,
1900                },
1901                idem(),
1902            )
1903            .unwrap();
1904        assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Ready);
1905    }
1906}
1907
1908#[cfg(test)]
1909mod bootstrap_typed_verb_tests {
1910    //! Direct tests for the typed `ensure_local_environment` verb (PR-3a.12).
1911    //! The CLI-layer tests in `cli::bootstrap` exercise the full wrapper
1912    //! including `refresh_local_runtime_stub`; these lock in the store-level
1913    //! typed-verb behavior: Created, AlreadyExists, Healed, and
1914    //! public_base_url overwrite rejection.
1915    use super::*;
1916    use crate::defaults::{
1917        LOCAL_DEPLOYER_PACK, LOCAL_ENV_ID, LOCAL_SECRETS_PACK, LOCAL_SESSIONS_PACK,
1918        LOCAL_STATE_PACK, LOCAL_TELEMETRY_PACK,
1919    };
1920    use crate::environment::bootstrap::{EnsureLocalEnvironmentPayload, LocalEnvOutcome};
1921    use crate::environment::store::EnvironmentStore;
1922    use greentic_deploy_spec::{CapabilitySlot, EnvId, EnvPackBinding, PackDescriptor, PackId};
1923    use tempfile::TempDir;
1924
1925    fn store() -> (TempDir, LocalFsStore) {
1926        let tmp = TempDir::new().expect("tempdir");
1927        let s = LocalFsStore::new(tmp.path().to_path_buf());
1928        (tmp, s)
1929    }
1930
1931    fn env_id() -> EnvId {
1932        EnvId::try_from(LOCAL_ENV_ID).unwrap()
1933    }
1934
1935    fn payload(public_base_url: Option<&str>) -> EnsureLocalEnvironmentPayload {
1936        EnsureLocalEnvironmentPayload {
1937            public_base_url: public_base_url.map(ToString::to_string),
1938        }
1939    }
1940
1941    #[test]
1942    fn creates_env_when_missing() {
1943        let (_tmp, store) = store();
1944        let (env, outcome) = store
1945            .ensure_local_environment(&env_id(), payload(None))
1946            .expect("create");
1947        assert_eq!(outcome, LocalEnvOutcome::Created);
1948        assert_eq!(env.environment_id.as_str(), LOCAL_ENV_ID);
1949        assert_eq!(env.name, LOCAL_ENV_ID);
1950        assert_eq!(env.packs.len(), 5);
1951        env.validate().expect("spec-valid");
1952    }
1953
1954    #[test]
1955    fn creates_env_with_public_base_url() {
1956        let (_tmp, store) = store();
1957        let (env, outcome) = store
1958            .ensure_local_environment(&env_id(), payload(Some("https://example.com")))
1959            .expect("create with url");
1960        assert_eq!(outcome, LocalEnvOutcome::Created);
1961        assert_eq!(
1962            env.host_config.public_base_url.as_deref(),
1963            Some("https://example.com")
1964        );
1965    }
1966
1967    #[test]
1968    fn returns_already_exists_on_second_call() {
1969        let (_tmp, store) = store();
1970        let (first, _) = store
1971            .ensure_local_environment(&env_id(), payload(None))
1972            .expect("first");
1973        let (second, outcome) = store
1974            .ensure_local_environment(&env_id(), payload(None))
1975            .expect("second");
1976        assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
1977        assert_eq!(first, second);
1978    }
1979
1980    #[test]
1981    fn rejects_public_base_url_when_env_exists() {
1982        let (_tmp, store) = store();
1983        store
1984            .ensure_local_environment(&env_id(), payload(None))
1985            .expect("first");
1986        let err = store
1987            .ensure_local_environment(&env_id(), payload(Some("https://example.com")))
1988            .unwrap_err();
1989        assert!(
1990            matches!(err, StoreError::InvalidArgument(ref msg) if msg.contains("already exists")),
1991            "expected InvalidArgument, got {err:?}"
1992        );
1993    }
1994
1995    /// Seed an empty `local` env (all 5 slots missing) — mimics `op env create local`.
1996    fn seed_empty_local_env(store: &LocalFsStore) -> greentic_deploy_spec::Environment {
1997        let eid = env_id();
1998        let env = greentic_deploy_spec::Environment {
1999            schema: greentic_deploy_spec::SchemaVersion::new(
2000                greentic_deploy_spec::SchemaVersion::ENVIRONMENT_V1,
2001            ),
2002            environment_id: eid.clone(),
2003            name: LOCAL_ENV_ID.to_string(),
2004            host_config: greentic_deploy_spec::EnvironmentHostConfig {
2005                env_id: eid,
2006                region: None,
2007                tenant_org_id: None,
2008                listen_addr: None,
2009                public_base_url: None,
2010                gui_enabled: None,
2011            },
2012            packs: Vec::new(),
2013            credentials_ref: None,
2014            bundles: Vec::new(),
2015            revisions: Vec::new(),
2016            traffic_splits: Vec::new(),
2017            messaging_endpoints: Vec::new(),
2018            extensions: Vec::new(),
2019            revocation: Default::default(),
2020            retention: Default::default(),
2021            health: Default::default(),
2022        };
2023        store.save(&env).expect("seed");
2024        env
2025    }
2026
2027    fn custom_binding(slot: CapabilitySlot, descriptor: &str) -> EnvPackBinding {
2028        EnvPackBinding {
2029            slot,
2030            kind: PackDescriptor::try_new(descriptor).expect("valid"),
2031            pack_ref: PackId::new(descriptor),
2032            answers_ref: None,
2033            generation: 0,
2034            previous_binding_ref: None,
2035        }
2036    }
2037
2038    #[test]
2039    fn webhook_provision_ctx_trims_and_blanks_owner() {
2040        let (_tmp, store) = store();
2041        let mut env = seed_empty_local_env(&store);
2042        // No Secrets binding → custodial dev-store; no owner → None.
2043        let (owner, custodial) = webhook_provision_ctx(&env);
2044        assert_eq!(owner, None);
2045        assert!(custodial);
2046        // A padded owner is trimmed.
2047        env.host_config.tenant_org_id = Some("  tenant-default  ".to_string());
2048        assert_eq!(
2049            webhook_provision_ctx(&env).0.as_deref(),
2050            Some("tenant-default")
2051        );
2052        // A blank owner collapses to None.
2053        env.host_config.tenant_org_id = Some("   ".to_string());
2054        assert_eq!(webhook_provision_ctx(&env).0, None);
2055    }
2056
2057    #[test]
2058    fn rotate_messaging_webhook_secret_rejects_non_dev_store_backend() {
2059        let (_tmp, store) = store();
2060        let mut env = seed_empty_local_env(&store);
2061        // A tenant-owned env bound to the Vault secrets backend (non-custodial).
2062        env.host_config.tenant_org_id = Some("tenant-default".to_string());
2063        env.packs.push(custom_binding(
2064            CapabilitySlot::Secrets,
2065            crate::defaults::VAULT_SECRETS_PACK,
2066        ));
2067        store.save(&env).unwrap();
2068
2069        // Add stamps the owner-tenant ref without writing a dev-store value.
2070        let added = store
2071            .add_messaging_endpoint(
2072                &env_id(),
2073                AddMessagingEndpointPayload {
2074                    provider_id: "tg".to_string(),
2075                    provider_type: "telegram".to_string(),
2076                    display_name: "t".to_string(),
2077                    secret_refs: Vec::new(),
2078                    webhook_secret_ref: None,
2079                    updated_by: "tester".to_string(),
2080                },
2081                IdempotencyKey::new(ulid::Ulid::new().to_string()).unwrap(),
2082            )
2083            .unwrap();
2084        assert!(
2085            added
2086                .webhook_secret_ref
2087                .as_ref()
2088                .expect("telegram endpoint stamps a ref")
2089                .as_str()
2090                .starts_with("secret://local/tenant-default/_/"),
2091            "add must stamp the owner-tenant ref"
2092        );
2093
2094        // Rotate must fail closed — the control plane cannot rotate a Vault value.
2095        let err = store
2096            .rotate_messaging_webhook_secret(
2097                &env_id(),
2098                added.endpoint_id,
2099                "tester".to_string(),
2100                IdempotencyKey::new(ulid::Ulid::new().to_string()).unwrap(),
2101            )
2102            .unwrap_err();
2103        assert!(
2104            matches!(err, StoreError::Conflict(ref m) if m.contains("non-dev-store")),
2105            "expected Conflict, got {err:?}"
2106        );
2107    }
2108
2109    #[test]
2110    fn heals_env_with_no_packs() {
2111        let (_tmp, store) = store();
2112        seed_empty_local_env(&store);
2113        let (env, outcome) = store
2114            .ensure_local_environment(&env_id(), payload(None))
2115            .expect("heal");
2116        match outcome {
2117            LocalEnvOutcome::Healed { added_slots } => {
2118                assert_eq!(
2119                    added_slots,
2120                    vec![
2121                        CapabilitySlot::Deployer,
2122                        CapabilitySlot::Secrets,
2123                        CapabilitySlot::Telemetry,
2124                        CapabilitySlot::Sessions,
2125                        CapabilitySlot::State,
2126                    ]
2127                );
2128            }
2129            other => panic!("expected Healed, got {other:?}"),
2130        }
2131        assert_eq!(env.packs.len(), 5);
2132        env.validate().expect("spec-valid after heal");
2133        // Re-run: now fully bound, should be AlreadyExists.
2134        let (_, outcome2) = store
2135            .ensure_local_environment(&env_id(), payload(None))
2136            .expect("second");
2137        assert_eq!(outcome2, LocalEnvOutcome::AlreadyExists);
2138    }
2139
2140    #[test]
2141    fn heals_env_with_partial_packs() {
2142        let (_tmp, store) = store();
2143        let mut env = seed_empty_local_env(&store);
2144        env.packs.push(custom_binding(
2145            CapabilitySlot::Deployer,
2146            LOCAL_DEPLOYER_PACK,
2147        ));
2148        store.save(&env).expect("partial save");
2149
2150        let (env, outcome) = store
2151            .ensure_local_environment(&env_id(), payload(None))
2152            .expect("heal");
2153        match outcome {
2154            LocalEnvOutcome::Healed { added_slots } => {
2155                assert_eq!(
2156                    added_slots,
2157                    vec![
2158                        CapabilitySlot::Secrets,
2159                        CapabilitySlot::Telemetry,
2160                        CapabilitySlot::Sessions,
2161                        CapabilitySlot::State,
2162                    ]
2163                );
2164            }
2165            other => panic!("expected Healed, got {other:?}"),
2166        }
2167        assert_eq!(env.packs.len(), 5);
2168    }
2169
2170    #[test]
2171    fn heal_preserves_user_bound_non_default_descriptor() {
2172        let (_tmp, store) = store();
2173        let mut env = seed_empty_local_env(&store);
2174        let custom_secrets = "greentic.secrets.aws-secrets-manager@1.0.0";
2175        env.packs
2176            .push(custom_binding(CapabilitySlot::Secrets, custom_secrets));
2177        store.save(&env).expect("custom-secrets save");
2178
2179        let (env, outcome) = store
2180            .ensure_local_environment(&env_id(), payload(None))
2181            .expect("heal");
2182        match outcome {
2183            LocalEnvOutcome::Healed { added_slots } => {
2184                assert_eq!(
2185                    added_slots,
2186                    vec![
2187                        CapabilitySlot::Deployer,
2188                        CapabilitySlot::Telemetry,
2189                        CapabilitySlot::Sessions,
2190                        CapabilitySlot::State,
2191                    ]
2192                );
2193            }
2194            other => panic!("expected Healed, got {other:?}"),
2195        }
2196        let secrets_desc = env
2197            .packs
2198            .iter()
2199            .find(|b| b.slot == CapabilitySlot::Secrets)
2200            .map(|b| b.kind.as_str())
2201            .expect("secrets slot");
2202        assert_eq!(secrets_desc, custom_secrets);
2203    }
2204
2205    #[test]
2206    fn default_bindings_cover_expected_descriptors() {
2207        let (_tmp, store) = store();
2208        let (env, _) = store
2209            .ensure_local_environment(&env_id(), payload(None))
2210            .expect("create");
2211        let by_slot: std::collections::BTreeMap<CapabilitySlot, &str> = env
2212            .packs
2213            .iter()
2214            .map(|b| (b.slot, b.kind.as_str()))
2215            .collect();
2216        assert_eq!(by_slot[&CapabilitySlot::Deployer], LOCAL_DEPLOYER_PACK);
2217        assert_eq!(by_slot[&CapabilitySlot::Secrets], LOCAL_SECRETS_PACK);
2218        assert_eq!(by_slot[&CapabilitySlot::Telemetry], LOCAL_TELEMETRY_PACK);
2219        assert_eq!(by_slot[&CapabilitySlot::Sessions], LOCAL_SESSIONS_PACK);
2220        assert_eq!(by_slot[&CapabilitySlot::State], LOCAL_STATE_PACK);
2221    }
2222}