greentic_deployer/environment/mutations.rs
1//! [`EnvironmentMutations`] — typed-verb trait for state mutations on a
2//! Greentic environment.
3//!
4//! Phase D rescope (2026-06-09): this trait replaces the closure-based
5//! [`LocalFsStore::transact`](super::LocalFsStore::transact) pattern with one
6//! method per logical CLI verb. See `plans/next-gen-deployment.md` §13.5 #2
7//! for the architectural rationale; in short: closures can't cross the A8
8//! HTTP wire contract (`greentic_deploy_spec::remote`), so the deployer-CLI's
9//! seam against a remote store has to be typed verbs, not opaque
10//! `FnOnce(&Locked)` closures.
11//!
12//! This module ships in **PR-3a.1 as signatures only** — there are no impls,
13//! and no callers reach the trait yet. PR-3a.2..3a.16 migrate one verb group
14//! at a time, each adding the `LocalFsStore` impl + flipping the matching
15//! CLI helper from `store.transact(|locked| …)` to a typed call. PR-3b lands
16//! `HttpEnvironmentStore` implementing the same trait over the A8 HTTP
17//! contract.
18//!
19//! Signatures here are derived from the existing 33 `LocalFsStore::transact`
20//! sites in `src/cli/*` (15 logical verb groups, ~28 methods). They may
21//! tweak as PR-3a.2..16 add impls — flag drift in code review.
22
23use greentic_deploy_spec::{
24 BundleDeployment, BundleId, CapabilitySlot, DeploymentId, EnvId, EnvPackBinding, Environment,
25 EnvironmentHostConfig, ExtensionBinding, IdempotencyKey, MessagingEndpoint,
26 MessagingEndpointId, Revision, RevisionId,
27};
28
29use super::StoreError;
30
31// PR-4.2a/4.2b/4.2c: the env-lifecycle payload shapes (`ExtensionKey`,
32// `FieldUpdate`, `UpdateEnvironmentPayload`, `MigrateSeedPayload`,
33// `MigrateMergePayload`), the revision verb group's
34// (`StageRevisionPayload`, `WarmRevisionPayload`,
35// `RevisionTransitionOutcome`) and the traffic verb group's
36// (`SetTrafficSplitPayload`, `ApplyTrafficSplitOutcome`,
37// `RollbackTrafficSplitOutcome`) moved to `greentic_deploy_spec::engine` so
38// the operator-store-server applies the same verb semantics (and wire
39// encoding) as `LocalFsStore`. Re-exported here so every existing
40// `environment::mutations::…` path keeps working. The revision payloads no
41// longer carry `idempotency_key` — the key rides the trait methods (and the
42// A8 `Idempotency-Key` header), matching every other verb group. (The
43// traffic outcomes gained an `environment` snapshot: the CLI emits
44// `TrafficSplitApplied` telemetry from it, identical local and remote.)
45// PR-4.2f: the trust-root group's wire shapes (`AddTrustedKeyPayload`,
46// `TrustRootSeed`, `TrustRootAddOutcome`, `TrustRootRemoveOutcome`)
47// followed — shapes only; the pure transforms need crypto and live in
48// `greentic-operator-trust`.
49// PR-4.2g: the bundles group's shapes (`AddBundlePayload`,
50// `UpdateBundlePayload`, `RemoveBundleOutcome`) followed, dropping their
51// embedded `idempotency_key` — the key rides the trait methods (and the A8
52// `Idempotency-Key` header), matching every other verb group.
53// PR-4.2h: the messaging group's shapes (`AddMessagingEndpointPayload`,
54// `SetMessagingWelcomeFlowPayload`) followed, likewise dropping their
55// embedded `idempotency_key` — though in this group the key is also DOMAIN
56// state (replay detection via the `updated_by` stamp), so the engine
57// transforms receive it as a parameter.
58pub use greentic_deploy_spec::engine::{
59 AddBundlePayload, AddMessagingEndpointPayload, AddTrustedKeyPayload, ApplyTrafficSplitOutcome,
60 ExtensionKey, FieldUpdate, MigrateMergePayload, MigrateSeedPayload, RemoveBundleOutcome,
61 RevisionTransitionOutcome, RollbackTrafficSplitOutcome, SetMessagingWelcomeFlowPayload,
62 SetTrafficSplitPayload, StageRevisionPayload, TrustRootAddOutcome, TrustRootRemoveOutcome,
63 TrustRootSeed, UpdateBundlePayload, UpdateEnvironmentPayload, WarmRevisionPayload,
64};
65
66/// The typed-verb persistence operations a Greentic environment store
67/// performs in response to `op …` CLI verbs.
68///
69/// Replaces the closure-based `LocalFsStore::transact` pattern with one
70/// method per logical verb. All methods take `&self` — concurrency is the
71/// impl's responsibility (flock for `LocalFsStore`, optimistic CAS via
72/// `If-Match` for `HttpEnvironmentStore` against an A8-compliant server).
73///
74/// Methods that need idempotency replay (per A8 §2) take an
75/// [`IdempotencyKey`]; methods that are intrinsically idempotent (e.g.
76/// `seed_trust_root_if_absent`) do not.
77///
78/// **Errors**: all methods return [`StoreError`]. Impls may map their
79/// transport-specific errors into the existing variants; new variants land
80/// alongside the impl that needs them.
81pub trait EnvironmentMutations: Send + Sync {
82 // -------------------------------------------------------------
83 // Environment lifecycle
84 // `op env create | update | set-public-url`
85 // `op config set`
86 //
87 // `op env init` / `gtc setup` is NOT on this trait — it's local-FS
88 // only (default-binding heal + runtime stub), so it stays inherent
89 // on `LocalFsStore`. The HTTP backend has its own bootstrap path.
90 // -------------------------------------------------------------
91
92 /// Create a fresh environment with empty bundles/revisions/packs.
93 /// Rejects if the env already exists.
94 fn create_environment(
95 &self,
96 env_id: &EnvId,
97 name: String,
98 host_config: EnvironmentHostConfig,
99 ) -> Result<Environment, StoreError>;
100
101 /// Patch the named scalar fields on an existing environment.
102 /// [`FieldUpdate::Keep`] fields are skipped, [`FieldUpdate::Set`]
103 /// writes the new value, [`FieldUpdate::Clear`] resets optional
104 /// fields to `None`. The full updated `Environment` is returned.
105 /// Covers what was previously split across `update_environment` (no
106 /// `listen_addr`), `set_public_url` (single field), and `set_config`
107 /// (host-level fields including `listen_addr`). One verb, one HTTP
108 /// endpoint, one impl body per backend.
109 fn update_environment(
110 &self,
111 env_id: &EnvId,
112 patch: UpdateEnvironmentPayload,
113 ) -> Result<Environment, StoreError>;
114
115 // -------------------------------------------------------------
116 // Reads
117 // (no dedicated CLI verb — used by the remote dispatch to
118 // evaluate client-side preconditions before a mutation)
119 // -------------------------------------------------------------
120
121 /// Read the current environment state. This is the single READ verb on an
122 /// otherwise mutation-only trait: the remote dispatch needs it to evaluate
123 /// client-side preconditions — e.g. the `warm` health-gate's
124 /// `expected_lifecycle`, which the local closure-based path reads inline
125 /// under its flock. Maps to `GET /environments/{env_id}` on the HTTP
126 /// backend and delegates to
127 /// [`EnvironmentStore::load`](super::EnvironmentStore::load) on
128 /// `LocalFsStore`.
129 fn load_environment(&self, env_id: &EnvId) -> Result<Environment, StoreError>;
130
131 /// True when the env's trust root holds at least one operator key. Maps to
132 /// `GET /environments/{env_id}/trust-root` on the HTTP backend and to a
133 /// trust-root file read on `LocalFsStore`. A read-only probe (like
134 /// [`Self::load_environment`]) — remote `env apply --check` uses it to diff
135 /// a declared `trust_root` instead of assuming it is seeded (the env
136 /// document does not carry trust-root state).
137 fn trust_root_is_seeded(&self, env_id: &EnvId) -> Result<bool, StoreError>;
138
139 // -------------------------------------------------------------
140 // Migration
141 // `op env migrate-dev --apply`
142 // -------------------------------------------------------------
143
144 /// Merge pack bindings and extension bindings into `target_env_id`.
145 /// Skips slots / extension keys already bound in the target. Returns
146 /// the list of newly-merged slots + extension key strings (in the
147 /// form `"<kind_path>::<instance_id>"`).
148 ///
149 /// `payload.seed_if_missing` is the optional seed-on-create-target
150 /// branch used by `op env migrate-dev` to migrate a legacy `dev` env
151 /// into a fresh `local` target atomically — load, fallback-to-seed,
152 /// merge, save all happen under one lock.
153 fn migrate_merge_bindings(
154 &self,
155 target_env_id: &EnvId,
156 payload: MigrateMergePayload,
157 ) -> Result<(Vec<String>, Vec<String>), StoreError>;
158
159 // -------------------------------------------------------------
160 // Revision lifecycle
161 // `op revisions stage | warm | drain | archive`
162 // -------------------------------------------------------------
163
164 /// Stage a fresh revision under `deployment_id`. The caller supplies
165 /// the pinned artifact pointers; `LocalFsStore`'s CLI helper resolves
166 /// them from a local `.gtbundle` upstream of this call so the trait
167 /// stays storage-only.
168 ///
169 /// `idempotency_key`: A8 §2 — same-key replay returns the originally
170 /// staged `Revision` without re-minting the ULID or advancing the
171 /// sequence. Local impl accepts and ignores; HTTP backends cache it.
172 fn stage_revision(
173 &self,
174 env_id: &EnvId,
175 payload: StageRevisionPayload,
176 idempotency_key: IdempotencyKey,
177 ) -> Result<Revision, StoreError>;
178
179 /// Transition a revision through its `warm` lifecycle chain, applying the
180 /// client-evaluated health-gate outcome. The deployer CLI runs the
181 /// runner-side health checks locally and ships the result in
182 /// [`WarmRevisionPayload::health_gate`]: `Ok(())` advances the revision
183 /// to `Ready`; `Err(failure)` flips it to `Failed` atomically. This
184 /// replaces the closure-based gate so the operation can cross the A8
185 /// HTTP wire contract.
186 fn warm_revision(
187 &self,
188 env_id: &EnvId,
189 payload: WarmRevisionPayload,
190 idempotency_key: IdempotencyKey,
191 ) -> Result<RevisionTransitionOutcome, StoreError>;
192
193 /// Drain a `Ready` revision (graceful step-down → `Drained`).
194 /// `idempotency_key` is required for A8 mutation consistency even though
195 /// drain is logically idempotent — the key enables audit-event replay.
196 fn drain_revision(
197 &self,
198 env_id: &EnvId,
199 revision_id: RevisionId,
200 idempotency_key: IdempotencyKey,
201 ) -> Result<RevisionTransitionOutcome, StoreError>;
202
203 /// Archive a `Drained` / `Failed` revision (terminal).
204 /// `idempotency_key` is required for A8 mutation consistency even though
205 /// archive is logically idempotent — the key enables audit-event replay.
206 fn archive_revision(
207 &self,
208 env_id: &EnvId,
209 revision_id: RevisionId,
210 idempotency_key: IdempotencyKey,
211 ) -> Result<RevisionTransitionOutcome, StoreError>;
212
213 // -------------------------------------------------------------
214 // Bundle deployment CRUD
215 // `op bundles add | update | remove`
216 // -------------------------------------------------------------
217
218 /// `idempotency_key` is required for A8 §2 mutation replay; the local
219 /// impl accepts and ignores, the HTTP backend caches the original
220 /// outcome.
221 fn add_bundle(
222 &self,
223 env_id: &EnvId,
224 payload: AddBundlePayload,
225 idempotency_key: IdempotencyKey,
226 ) -> Result<BundleDeployment, StoreError>;
227
228 /// `idempotency_key` is required for A8 §2 mutation replay; the local
229 /// impl accepts and ignores, the HTTP backend caches the original
230 /// outcome.
231 fn update_bundle(
232 &self,
233 env_id: &EnvId,
234 payload: UpdateBundlePayload,
235 idempotency_key: IdempotencyKey,
236 ) -> Result<BundleDeployment, StoreError>;
237
238 /// Remove a bundle deployment from the env. Refuses if the deployment
239 /// still has live traffic splits or non-archived revisions —
240 /// callers must `op traffic clear` and archive revisions first.
241 ///
242 /// Also drops archived revisions for the same `deployment_id` so the
243 /// env stays compact. The pruned IDs are surfaced on the outcome so
244 /// the destructive side effect is explicit on the contract —
245 /// HTTP backends can apply a separate authorization check against
246 /// the prune set, the CLI logs the IDs in the audit target.
247 ///
248 /// `idempotency_key` is required for A8 §2 mutation replay; the local
249 /// impl accepts and ignores, the HTTP backend caches the original
250 /// outcome.
251 fn remove_bundle(
252 &self,
253 env_id: &EnvId,
254 deployment_id: DeploymentId,
255 idempotency_key: IdempotencyKey,
256 ) -> Result<RemoveBundleOutcome, StoreError>;
257
258 // -------------------------------------------------------------
259 // Env-pack binding CRUD
260 // `op env-packs add | update | remove | rollback`
261 // -------------------------------------------------------------
262
263 fn add_pack_binding(
264 &self,
265 env_id: &EnvId,
266 binding: EnvPackBinding,
267 idempotency_key: IdempotencyKey,
268 ) -> Result<EnvPackBinding, StoreError>;
269
270 /// Returns `(new_binding, new_generation)` — the bumped audit generation
271 /// is surfaced for downstream observability.
272 fn update_pack_binding(
273 &self,
274 env_id: &EnvId,
275 slot: CapabilitySlot,
276 binding: EnvPackBinding,
277 idempotency_key: IdempotencyKey,
278 ) -> Result<(EnvPackBinding, u64), StoreError>;
279
280 fn remove_pack_binding(
281 &self,
282 env_id: &EnvId,
283 slot: CapabilitySlot,
284 idempotency_key: IdempotencyKey,
285 ) -> Result<(EnvPackBinding, u64), StoreError>;
286
287 fn rollback_pack_binding(
288 &self,
289 env_id: &EnvId,
290 slot: CapabilitySlot,
291 idempotency_key: IdempotencyKey,
292 ) -> Result<(EnvPackBinding, u64), StoreError>;
293
294 // -------------------------------------------------------------
295 // Extension binding CRUD
296 // `op extensions add | update | remove | rollback`
297 // -------------------------------------------------------------
298
299 fn add_extension_binding(
300 &self,
301 env_id: &EnvId,
302 binding: ExtensionBinding,
303 idempotency_key: IdempotencyKey,
304 ) -> Result<ExtensionBinding, StoreError>;
305
306 fn update_extension_binding(
307 &self,
308 env_id: &EnvId,
309 key: ExtensionKey,
310 binding: ExtensionBinding,
311 idempotency_key: IdempotencyKey,
312 ) -> Result<(ExtensionBinding, u64), StoreError>;
313
314 fn remove_extension_binding(
315 &self,
316 env_id: &EnvId,
317 key: ExtensionKey,
318 idempotency_key: IdempotencyKey,
319 ) -> Result<(ExtensionBinding, u64), StoreError>;
320
321 fn rollback_extension_binding(
322 &self,
323 env_id: &EnvId,
324 key: ExtensionKey,
325 idempotency_key: IdempotencyKey,
326 ) -> Result<(ExtensionBinding, u64), StoreError>;
327
328 // -------------------------------------------------------------
329 // Traffic
330 // `op traffic set | rollback`
331 // -------------------------------------------------------------
332
333 fn set_traffic_split(
334 &self,
335 env_id: &EnvId,
336 payload: SetTrafficSplitPayload,
337 idempotency_key: IdempotencyKey,
338 ) -> Result<ApplyTrafficSplitOutcome, StoreError>;
339
340 fn rollback_traffic_split(
341 &self,
342 env_id: &EnvId,
343 deployment_id: DeploymentId,
344 idempotency_key: IdempotencyKey,
345 ) -> Result<RollbackTrafficSplitOutcome, StoreError>;
346
347 // -------------------------------------------------------------
348 // Messaging endpoints
349 // `op messaging endpoint add | link-bundle | unlink-bundle
350 // | set-welcome-flow | remove | rotate-webhook-secret`
351 // -------------------------------------------------------------
352
353 /// `idempotency_key` is both A8 §2 transport metadata AND domain state
354 /// in this group: the key is embedded in the endpoint's `updated_by`
355 /// stamp and drives same-key replay detection (see
356 /// `greentic_deploy_spec::engine::messaging`).
357 fn add_messaging_endpoint(
358 &self,
359 env_id: &EnvId,
360 payload: AddMessagingEndpointPayload,
361 idempotency_key: IdempotencyKey,
362 ) -> Result<MessagingEndpoint, StoreError>;
363
364 fn link_messaging_bundle(
365 &self,
366 env_id: &EnvId,
367 endpoint_id: MessagingEndpointId,
368 bundle_id: BundleId,
369 updated_by: String,
370 idempotency_key: IdempotencyKey,
371 ) -> Result<MessagingEndpoint, StoreError>;
372
373 fn unlink_messaging_bundle(
374 &self,
375 env_id: &EnvId,
376 endpoint_id: MessagingEndpointId,
377 bundle_id: BundleId,
378 updated_by: String,
379 idempotency_key: IdempotencyKey,
380 ) -> Result<MessagingEndpoint, StoreError>;
381
382 /// `idempotency_key` semantics as on
383 /// [`Self::add_messaging_endpoint`] — transport metadata that doubles
384 /// as the replay-detection stamp.
385 fn set_messaging_welcome_flow(
386 &self,
387 env_id: &EnvId,
388 payload: SetMessagingWelcomeFlowPayload,
389 idempotency_key: IdempotencyKey,
390 ) -> Result<MessagingEndpoint, StoreError>;
391
392 fn remove_messaging_endpoint(
393 &self,
394 env_id: &EnvId,
395 endpoint_id: MessagingEndpointId,
396 ) -> Result<MessagingEndpointId, StoreError>;
397
398 fn rotate_messaging_webhook_secret(
399 &self,
400 env_id: &EnvId,
401 endpoint_id: MessagingEndpointId,
402 updated_by: String,
403 idempotency_key: IdempotencyKey,
404 ) -> Result<MessagingEndpoint, StoreError>;
405
406 // -------------------------------------------------------------
407 // Trust root
408 // `op trust-root bootstrap | add | remove`
409 // (`seed_trust_root_if_absent` is called from `op env init`)
410 // -------------------------------------------------------------
411
412 /// Load (or generate) the backend's operator signing key and add it to
413 /// the env trust root. Idempotent re-grant: a second call with the same
414 /// operator key is a no-op on the key set (case-insensitive `key_id`
415 /// dedup), and it never rejects on an existing trust root.
416 fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError>;
417
418 /// Idempotent variant called from `op env init`: returns `Some(seed)`
419 /// when a key was minted, `None` when a trust root already existed.
420 fn seed_trust_root_if_absent(
421 &self,
422 env_id: &EnvId,
423 ) -> Result<Option<TrustRootSeed>, StoreError>;
424
425 /// `idempotency_key` is required for A8 mutation consistency even though
426 /// `add_trusted_key` is intrinsically idempotent on `key_id` collision —
427 /// the key enables HTTP-backend audit-event replay. Local-FS impls accept
428 /// and ignore it; HTTP impls cache it for replay.
429 fn add_trusted_key(
430 &self,
431 env_id: &EnvId,
432 key_id: String,
433 public_key_pem: String,
434 idempotency_key: IdempotencyKey,
435 ) -> Result<TrustRootAddOutcome, StoreError>;
436
437 /// `idempotency_key` is required for A8 mutation consistency. `remove`
438 /// is logically idempotent on the trust-root state, but the wire-shape
439 /// `removed_public_key_pem` field would be `null` on retry (the original
440 /// PEM is gone) — so a retry without replay loses recovery material AND
441 /// audit fidelity. The key enables HTTP-backend replay of the original
442 /// response/audit; local-FS impls accept and ignore it.
443 fn remove_trusted_key(
444 &self,
445 env_id: &EnvId,
446 key_id: String,
447 idempotency_key: IdempotencyKey,
448 ) -> Result<TrustRootRemoveOutcome, StoreError>;
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 /// Compile-time guard: the trait stays object-safe so future code can
456 /// hold `&dyn EnvironmentMutations` / `Box<dyn EnvironmentMutations>`
457 /// (e.g. for runtime selection between `LocalFsStore` and
458 /// `HttpEnvironmentStore` in PR-3c).
459 #[allow(dead_code)]
460 fn _is_object_safe(_: &dyn EnvironmentMutations) {}
461
462 // `FieldUpdate` / `UpdateEnvironmentPayload` / `ExtensionKey` unit tests
463 // moved to `greentic_deploy_spec::engine` alongside the types (PR-4.2a).
464}