Skip to main content

greentic_deployer/cli/
env_manifest.rs

1//! `greentic.env-manifest.v1` — the declarative desired-state document
2//! consumed by `gtc op env apply` (PR-1 of `plans/env-manifest-apply.md`).
3//!
4//! The manifest declares the desired *wiring* of one environment: env
5//! identity, trust root, secrets, bundle deployments with route
6//! bindings, and messaging endpoints with their bundle links. It is a
7//! durable document keyed by resource natural keys, designed to live in
8//! version control and be re-applied — NOT a recorded wizard-answers file
9//! and NOT a batch of per-verb payloads (see the design doc §4 for why
10//! those shapes were rejected).
11//!
12//! This module owns the serde types plus the manifest-*shape* validation
13//! (everything checkable without touching the store or the filesystem).
14//! Environment-dependent validation, artifact digesting, diffing, and
15//! execution live in [`super::env_apply`].
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::path::PathBuf;
19
20use greentic_deploy_spec::CapabilitySlot;
21use greentic_deploy_spec::GUI_DEFAULT_ENV_ID;
22use qa_spec::spec::ListSpec;
23use qa_spec::spec::question::QuestionPolicy;
24use qa_spec::{AnswerSet, Expr, FormSpec, QuestionSpec, QuestionType};
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28use greentic_deploy_spec::BundleDeploymentStatus;
29
30use super::OpError;
31use super::bundles::{RevenueShareEntryPayload, RouteBindingPayload, TenantSelectorPayload};
32
33/// Exact `schema` discriminator the manifest must carry.
34pub const ENV_MANIFEST_SCHEMA_V1: &str = "greentic.env-manifest.v1";
35
36/// Top-level manifest document. `deny_unknown_fields` everywhere so a typo
37/// fails loudly at parse time instead of silently no-opping.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct EnvManifest {
41    /// Must equal [`ENV_MANIFEST_SCHEMA_V1`].
42    pub schema: String,
43    pub environment: ManifestEnvironment,
44    /// `"bootstrap"` seeds the env trust root with the local operator key
45    /// (idempotent). Absent = skip the step.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub trust_root: Option<TrustRootDirective>,
48    /// Dev-store secret entries — always-put (`op secrets get` is
49    /// not-yet-implemented, so values cannot be diffed until A9).
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub secrets: Vec<ManifestSecret>,
52    /// Env-pack bindings (capability slots that `binds_in_packs`). Each slot
53    /// must be a core capability (not Messaging/Extension). Applied after
54    /// trust-root, before secrets.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub packs: Vec<ManifestPack>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub bundles: Vec<ManifestBundle>,
59    /// Extension bindings (N-per-env, open namespace). Applied after bundles,
60    /// before messaging endpoints.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub extensions: Vec<ManifestExtension>,
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub messaging_endpoints: Vec<ManifestEndpoint>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct ManifestEnvironment {
70    /// Environment id. Apply bootstraps `local` (via `env init`, seeding the
71    /// default env-pack bindings). Any other id must ALREADY exist — apply
72    /// reconciles a named env but does not create one; create it explicitly
73    /// first via `op env create <id>` (locally) or the remote operator store.
74    pub id: String,
75    /// When set, persisted via the `env set-public-url` path. Absent/`null`
76    /// means "leave whatever is there" (upsert — apply never clears it).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub public_base_url: Option<String>,
79    /// Human-readable display name. Absent = leave untouched; set = reconciled
80    /// via `op config set` on the existing env.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub name: Option<String>,
83    /// Cloud region tag. Absent = leave untouched.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub region: Option<String>,
86    /// Tenant organization id. Absent = leave untouched.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub tenant_org_id: Option<String>,
89    /// Bind address for the runtime's local HTTP listener (parsed as
90    /// `SocketAddr` during shape validation). Absent = leave untouched.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub listen_addr: Option<String>,
93    /// Whether the runtime serves the built-in webchat GUI. Absent = leave the
94    /// stored value unchanged (upsert, like the other host-config fields); the
95    /// env-id default — on for `local`, off otherwise — applies only when the
96    /// stored value is unset. `true`/`false` is an explicit choice reconciled
97    /// via `op config set`.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub gui_enabled: Option<bool>,
100}
101
102impl ManifestEnvironment {
103    /// True if the manifest declares any field that flows through `op config
104    /// set` / the `UpdateHostConfig` apply step. `public_base_url` is excluded
105    /// on purpose — it's reconciled by the separate `SetPublicUrl` step.
106    pub(crate) fn declares_host_config(&self) -> bool {
107        self.name.is_some()
108            || self.region.is_some()
109            || self.tenant_org_id.is_some()
110            || self.listen_addr.is_some()
111            || self.gui_enabled.is_some()
112    }
113}
114
115/// v1 accepts only the string `"bootstrap"`. A future
116/// `{ "additional_keys": [...] }` shape extends this enum without a schema
117/// bump.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum TrustRootDirective {
121    Bootstrap,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct ManifestSecret {
127    /// Dev-store path `<tenant>/<team>/<pack>/<name>` — exactly the
128    /// `SecretsPutPayload.path` shape.
129    pub path: String,
130    /// Name of the environment variable holding the value — apply reads
131    /// `$from_env` at apply time. Absent ⇒ the value is supplied
132    /// interactively (a masked paste prompt) and read back from the env's
133    /// secrets store on re-apply. Secret VALUES never appear in the manifest
134    /// either way.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub from_env: Option<String>,
137}
138
139/// One env-pack binding: a core capability slot bound to a pack descriptor.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct ManifestPack {
143    /// The capability slot (must satisfy `binds_in_packs()`).
144    pub slot: CapabilitySlot,
145    /// Pack descriptor string, e.g. `greentic.secrets.dev-store@1.0.0`.
146    pub kind: String,
147    /// Pack reference (registry id or local path).
148    pub pack_ref: String,
149    /// Optional answers file relative to the manifest directory.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub answers_ref: Option<PathBuf>,
152}
153
154/// One extension binding in the open-namespace `extensions[]` section.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ManifestExtension {
158    /// Pack descriptor string, e.g. `acme.oauth.auth0@1.0.0`.
159    pub kind: String,
160    /// Pack reference (registry id or local path).
161    pub pack_ref: String,
162    /// Instance selector for N instances of the same extension type.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub instance_id: Option<String>,
165    /// Optional answers file relative to the manifest directory.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub answers_ref: Option<PathBuf>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct ManifestBundle {
173    /// Natural key — unique within the manifest.
174    pub bundle_id: String,
175    /// Single-revision form (100 % traffic): local `.gtbundle`. Relative
176    /// paths resolve against the manifest file's directory (not the CWD),
177    /// so manifests are relocatable. Mutually exclusive with `revisions`.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub bundle_path: Option<PathBuf>,
180    /// Multi-revision / traffic-split form: each entry names a revision
181    /// with its own bundle artifact and optional weight. Mutually
182    /// exclusive with `bundle_path`. The wizard (`answers_to_manifest`)
183    /// always produces the single-revision form; multi-revision is
184    /// JSON-first (hand-authored or generated).
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub revisions: Option<Vec<ManifestRevision>>,
187    /// Billing principal (P6/B10): required for non-`local` environments.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub customer_id: Option<String>,
190    /// Revenue-share split (G2). Absent = leave untouched (`greentic@10000`
191    /// on a fresh deploy). When set, the entries' `basis_points` must sum to
192    /// exactly 10 000; applied at create time and reconciled via
193    /// `bundles update` for an existing deployment.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub revenue_share: Option<Vec<RevenueShareEntryPayload>>,
196    /// Deployment status (G3): `active` | `paused` | `archived`. Absent =
197    /// leave untouched. Reconciled via `bundles update` against an existing
198    /// deployment; a freshly-created deployment is always `active` and a
199    /// declared non-`active` status converges on the next apply.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub status: Option<BundleDeploymentStatus>,
202    /// Forwarded verbatim with `op deploy`'s three-valued semantics:
203    /// absent = leave untouched, `{}` = explicit clear, non-empty = replace.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
206    /// Absent = same as `op deploy`: empty binding on fresh add, untouched
207    /// on re-deploy.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub route_binding: Option<RouteBindingPayload>,
210    /// Optional `oci://` pull ref recorded on the staged revision so a K8s
211    /// worker can fetch the bundle at boot. May stand alone (URI-only): apply
212    /// fetches it once to derive the integrity digest, so no local `bundle_path`
213    /// is needed on the apply host. When it rides alongside a local
214    /// `bundle_path`, the path supplies the artifact and any scheme the worker
215    /// understands is fine here; URI-only requires `oci://` (the only scheme
216    /// apply fetches today). Single-revision form only; multi-revision carries
217    /// the ref per `revisions[]` entry. Absent = local `bundle_path` required.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub bundle_source_uri: Option<String>,
220    /// Optional `sha256:<hex>` integrity pin for the resolved artifact. When
221    /// set, apply fails closed unless the artifact (local or fetched) hashes to
222    /// this; when absent, apply records the computed digest (trust-on-first-use).
223    /// Recommended for a `bundle_source_uri`-only bundle so the pull is pinned.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub bundle_digest: Option<String>,
226}
227
228/// One revision in a multi-revision bundle entry. Each carries its own
229/// bundle artifact path and optional traffic weight / drain / abort knobs.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct ManifestRevision {
233    /// Manifest-local handle — unique within the bundle's `revisions[]`.
234    pub name: String,
235    /// Local `.gtbundle`. Same path-resolution rules as
236    /// [`ManifestBundle::bundle_path`].
237    pub bundle_path: PathBuf,
238    /// Traffic weight as a percentage (0..=100). Mutually exclusive with
239    /// `weight_bps` on the same revision.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub weight_percent: Option<u32>,
242    /// Traffic weight in basis points (0..=10 000). Mutually exclusive
243    /// with `weight_percent` on the same revision.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub weight_bps: Option<u32>,
246    /// Per-revision drain window override (seconds). Forwarded to
247    /// `RevisionStagePayload.drain_seconds`.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub drain_seconds: Option<u32>,
250    /// Abort-metric names for canary evaluation. Reserved for the
251    /// canary-evaluation engine (not consumed by apply today).
252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
253    pub abort_metrics: Vec<String>,
254    /// Optional `oci://`/`repo://`/`store://` pull ref recorded on this staged
255    /// revision so a K8s worker can fetch it at boot. `bundle_path` stays
256    /// required (integrity digest). Absent = local-serve only.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub bundle_source_uri: Option<String>,
259    /// Optional `sha256:<hex>` integrity pin for `bundle_path`. When set, apply
260    /// fails closed unless the artifact hashes to this; absent = record the
261    /// computed digest.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub bundle_digest: Option<String>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct ManifestEndpoint {
269    /// Manifest-local handle AND the endpoint's `display_name` AND (on
270    /// create) its `provider_id` instance identity. Upsert natural key:
271    /// apply matches an existing endpoint by `(provider_type, name)`.
272    pub name: String,
273    /// Provider class, e.g. `messaging.telegram.bot`.
274    pub provider_type: String,
275    /// `bundle_id`s this endpoint admits. Each must be declared in this
276    /// manifest's `bundles[]` or already exist in the environment.
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub links: Vec<String>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub welcome_flow: Option<ManifestWelcomeFlow>,
281    /// Forwarded to `EndpointAddPayload.secret_refs` on create. Drift on an
282    /// existing endpoint is reported as a warning (no update verb exists).
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    pub secret_refs: Vec<String>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct ManifestWelcomeFlow {
290    pub bundle_id: String,
291    pub pack_id: String,
292    pub flow_id: String,
293}
294
295impl EnvManifest {
296    /// Manifest-shape validation: everything checkable without the store or
297    /// the filesystem. Runs before any artifact digesting or env read so a
298    /// malformed manifest fails fast with no side effects.
299    pub fn validate_shape(&self) -> Result<(), OpError> {
300        if self.schema != ENV_MANIFEST_SCHEMA_V1 {
301            return Err(OpError::InvalidArgument(format!(
302                "manifest schema `{}` is not the expected `{ENV_MANIFEST_SCHEMA_V1}`",
303                self.schema
304            )));
305        }
306        if self.environment.id.trim().is_empty() {
307            return Err(OpError::InvalidArgument(
308                "environment.id must not be empty".to_string(),
309            ));
310        }
311        // listen_addr: parse-validate as SocketAddr at shape time.
312        if let Some(raw) = &self.environment.listen_addr {
313            raw.parse::<std::net::SocketAddr>().map_err(|e| {
314                OpError::InvalidArgument(format!(
315                    "environment.listen_addr `{raw}` is not a valid socket address: {e}"
316                ))
317            })?;
318        }
319
320        // Env-pack bindings: each slot must bind in packs, unique slots,
321        // kind must parse as PackDescriptor, pack_ref non-empty.
322        let mut pack_slots = BTreeSet::new();
323        for p in &self.packs {
324            if !p.slot.binds_in_packs() {
325                return Err(OpError::InvalidArgument(format!(
326                    "packs[]: slot `{}` does not bind in packs — use \
327                     messaging_endpoints[] or extensions[] instead",
328                    p.slot
329                )));
330            }
331            if !pack_slots.insert(p.slot) {
332                return Err(OpError::InvalidArgument(format!(
333                    "duplicate slot `{}` in manifest packs[]",
334                    p.slot
335                )));
336            }
337            greentic_deploy_spec::PackDescriptor::try_new(&p.kind).map_err(|e| {
338                OpError::InvalidArgument(format!("packs[] slot `{}`: kind: {e}", p.slot))
339            })?;
340            if p.pack_ref.trim().is_empty() {
341                return Err(OpError::InvalidArgument(format!(
342                    "packs[] slot `{}`: pack_ref must not be empty",
343                    p.slot
344                )));
345            }
346        }
347
348        // Extension bindings: unique (kind.path(), instance_id), kind parses,
349        // instance_id chars [a-z0-9-] non-empty when present, pack_ref non-empty.
350        let mut ext_keys = BTreeSet::new();
351        for ext in &self.extensions {
352            let descriptor =
353                greentic_deploy_spec::PackDescriptor::try_new(&ext.kind).map_err(|e| {
354                    OpError::InvalidArgument(format!("extensions[]: kind `{}`: {e}", ext.kind))
355                })?;
356            if ext.pack_ref.trim().is_empty() {
357                return Err(OpError::InvalidArgument(format!(
358                    "extensions[] kind `{}`: pack_ref must not be empty",
359                    ext.kind
360                )));
361            }
362            if let Some(inst) = &ext.instance_id
363                && (inst.is_empty()
364                    || !inst
365                        .chars()
366                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
367            {
368                return Err(OpError::InvalidArgument(format!(
369                    "extensions[] kind `{}`: instance_id `{inst}` must be non-empty \
370                     and contain only [a-z0-9-]",
371                    ext.kind
372                )));
373            }
374            let key = (
375                descriptor.path().to_string(),
376                ext.instance_id.as_deref().unwrap_or("").to_string(),
377            );
378            if !ext_keys.insert(key) {
379                return Err(OpError::InvalidArgument(format!(
380                    "duplicate extension (path `{}`, instance_id {:?}) in manifest extensions[]",
381                    descriptor.path(),
382                    ext.instance_id
383                )));
384            }
385        }
386
387        // Secrets: path shape + canonicality via the same checks
388        // `secrets.rs::put` applies (shared helper — the two surfaces cannot
389        // drift), so a bad path fails the whole apply here instead of
390        // mid-run. `from_env` *resolution* (var set + non-empty) needs
391        // process context and lives in `env_apply`'s validation.
392        let mut secret_paths = BTreeSet::new();
393        for s in &self.secrets {
394            let rel_path = s.path.trim_start_matches('/');
395            super::secrets::validate_dev_store_secret_path(rel_path)?;
396            if !secret_paths.insert(rel_path) {
397                return Err(OpError::InvalidArgument(format!(
398                    "duplicate secret path `{rel_path}` in manifest secrets[] \
399                     (order-dependent last-write-wins is never what you want)"
400                )));
401            }
402            if let Some(from_env) = &s.from_env
403                && from_env.trim().is_empty()
404            {
405                return Err(OpError::InvalidArgument(format!(
406                    "secret `{rel_path}`: from_env, when present, must name an environment \
407                     variable — omit it entirely for a pasted (interactively-supplied) secret"
408                )));
409            }
410        }
411
412        let mut bundle_ids = BTreeSet::new();
413        for b in &self.bundles {
414            if b.bundle_id.trim().is_empty() {
415                return Err(OpError::InvalidArgument(
416                    "bundles[].bundle_id must not be empty".to_string(),
417                ));
418            }
419            if !bundle_ids.insert(b.bundle_id.as_str()) {
420                return Err(OpError::InvalidArgument(format!(
421                    "duplicate bundle_id `{}` in manifest bundles[]",
422                    b.bundle_id
423                )));
424            }
425            // A bundle entry is single-revision (a `bundle_path` and/or a
426            // `bundle_source_uri`, 100 % traffic) XOR multi-revision
427            // (`revisions[]`). A single-revision entry needs at least one
428            // source; a remote-only source must be a `bundle_source_uri`.
429            match (&b.bundle_path, &b.revisions) {
430                (Some(_), Some(_)) => {
431                    return Err(OpError::InvalidArgument(format!(
432                        "bundle `{}`: `bundle_path` and `revisions` are mutually exclusive \
433                         — use `bundle_path` for single-revision (100 %) or `revisions` \
434                         for a traffic split",
435                        b.bundle_id
436                    )));
437                }
438                (None, Some(_)) => {
439                    // Multi-revision: the bundle-level `bundle_source_uri` and
440                    // `bundle_digest` are single-revision-only fields — a split
441                    // carries them per `revisions[]` entry.
442                    if b.bundle_source_uri.is_some() {
443                        return Err(OpError::InvalidArgument(format!(
444                            "bundle `{}`: bundle-level `bundle_source_uri` is for the \
445                             single-revision form — declare a per-revision \
446                             `bundle_source_uri` inside `revisions[]` for a traffic split",
447                            b.bundle_id
448                        )));
449                    }
450                    if b.bundle_digest.is_some() {
451                        return Err(OpError::InvalidArgument(format!(
452                            "bundle `{}`: bundle-level `bundle_digest` is for the \
453                             single-revision form — declare a per-revision \
454                             `bundle_digest` inside `revisions[]` for a traffic split",
455                            b.bundle_id
456                        )));
457                    }
458                }
459                (Some(_), None) => {}
460                (None, None) => {
461                    // Single-revision with no local artifact: a remote
462                    // `bundle_source_uri` is the only remaining source.
463                    if b.bundle_source_uri.is_none() {
464                        return Err(OpError::InvalidArgument(format!(
465                            "bundle `{}`: a single-revision bundle needs a `bundle_path` \
466                             or a `bundle_source_uri`",
467                            b.bundle_id
468                        )));
469                    }
470                }
471            }
472            // Integrity pin format (when declared): a `sha256:<hex>` string.
473            validate_digest_pin(&b.bundle_id, None, b.bundle_digest.as_deref())?;
474
475            // Per-revision validation (multi-revision form only).
476            if let Some(revisions) = &b.revisions {
477                if revisions.is_empty() {
478                    return Err(OpError::InvalidArgument(format!(
479                        "bundle `{}`: `revisions` must not be empty",
480                        b.bundle_id
481                    )));
482                }
483                let mut rev_names = BTreeSet::new();
484                for rev in revisions {
485                    if rev.name.trim().is_empty() {
486                        return Err(OpError::InvalidArgument(format!(
487                            "bundle `{}`: revision name must not be empty",
488                            b.bundle_id
489                        )));
490                    }
491                    if !rev_names.insert(rev.name.as_str()) {
492                        return Err(OpError::InvalidArgument(format!(
493                            "bundle `{}`: duplicate revision name `{}`",
494                            b.bundle_id, rev.name
495                        )));
496                    }
497                    // Per-revision: weight_percent and weight_bps are mutually exclusive.
498                    if rev.weight_percent.is_some() && rev.weight_bps.is_some() {
499                        return Err(OpError::InvalidArgument(format!(
500                            "bundle `{}`, revision `{}`: `weight_percent` and `weight_bps` \
501                             are mutually exclusive",
502                            b.bundle_id, rev.name
503                        )));
504                    }
505                    // Integrity pin format (when declared): a `sha256:<hex>` string.
506                    validate_digest_pin(
507                        &b.bundle_id,
508                        Some(&rev.name),
509                        rev.bundle_digest.as_deref(),
510                    )?;
511                }
512                // Weight consistency: all-set must sum to FULL_TRAFFIC_BPS;
513                // all-unset = equal split (computed at resolve time); mixed = error.
514                validate_revision_weights(&b.bundle_id, revisions)?;
515            }
516
517            // Revenue-share (G2): when declared, the split must be non-empty
518            // and sum to exactly FULL_TRAFFIC_BPS — mirrors the spec's
519            // `validate_revenue_share` so a bad split fails at manifest-shape
520            // time with a clear message instead of at store-save time.
521            if let Some(shares) = &b.revenue_share {
522                if shares.is_empty() {
523                    return Err(OpError::InvalidArgument(format!(
524                        "bundle `{}`: `revenue_share` must not be empty",
525                        b.bundle_id
526                    )));
527                }
528                let mut parties = BTreeSet::new();
529                let mut sum: u64 = 0;
530                for entry in shares {
531                    if entry.party_id.trim().is_empty() {
532                        return Err(OpError::InvalidArgument(format!(
533                            "bundle `{}`: revenue_share party_id must not be empty",
534                            b.bundle_id
535                        )));
536                    }
537                    if !parties.insert(entry.party_id.as_str()) {
538                        return Err(OpError::InvalidArgument(format!(
539                            "bundle `{}`: duplicate revenue_share party_id `{}`",
540                            b.bundle_id, entry.party_id
541                        )));
542                    }
543                    sum += u64::from(entry.basis_points);
544                }
545                if sum != u64::from(FULL_TRAFFIC_BPS) {
546                    return Err(OpError::InvalidArgument(format!(
547                        "bundle `{}`: revenue_share basis_points sum to {sum}, must be exactly \
548                         {FULL_TRAFFIC_BPS}",
549                        b.bundle_id
550                    )));
551                }
552            }
553
554            if let Some(rb) = &b.route_binding {
555                rb.validate()?;
556                for prefix in &rb.path_prefixes {
557                    if !prefix.starts_with('/') {
558                        return Err(OpError::InvalidArgument(format!(
559                            "bundle `{}` route_binding.path_prefixes entry `{prefix}` \
560                             must start with `/`",
561                            b.bundle_id
562                        )));
563                    }
564                }
565            }
566        }
567
568        let mut endpoint_names = BTreeSet::new();
569        for ep in &self.messaging_endpoints {
570            if ep.name.trim().is_empty() {
571                return Err(OpError::InvalidArgument(
572                    "messaging_endpoints[].name must not be empty".to_string(),
573                ));
574            }
575            if ep.provider_type.trim().is_empty() {
576                return Err(OpError::InvalidArgument(format!(
577                    "endpoint `{}`: provider_type must not be empty",
578                    ep.name
579                )));
580            }
581            if !endpoint_names.insert(ep.name.as_str()) {
582                return Err(OpError::InvalidArgument(format!(
583                    "duplicate endpoint name `{}` in manifest messaging_endpoints[]",
584                    ep.name
585                )));
586            }
587            let mut link_set = BTreeSet::new();
588            for link in &ep.links {
589                if !link_set.insert(link.as_str()) {
590                    return Err(OpError::InvalidArgument(format!(
591                        "endpoint `{}`: duplicate link `{link}` in links[]",
592                        ep.name
593                    )));
594                }
595            }
596        }
597        Ok(())
598    }
599}
600
601/// Full traffic in basis points (10 000 bps = 100 %).
602pub(crate) const FULL_TRAFFIC_BPS: u32 = 10_000;
603
604/// Validate an optional `sha256:<hex>` integrity pin. `None` = no pin (always
605/// ok). A present digest must be a non-empty `sha256:`-prefixed string; the
606/// byte-exact comparison against the resolved artifact happens at apply time.
607fn validate_digest_pin(
608    bundle_id: &str,
609    revision: Option<&str>,
610    digest: Option<&str>,
611) -> Result<(), OpError> {
612    let Some(digest) = digest else {
613        return Ok(());
614    };
615    if digest.starts_with("sha256:") && digest.len() > "sha256:".len() {
616        return Ok(());
617    }
618    let location = match revision {
619        Some(name) => format!("bundle `{bundle_id}`, revision `{name}`"),
620        None => format!("bundle `{bundle_id}`"),
621    };
622    Err(OpError::InvalidArgument(format!(
623        "{location}: bundle_digest `{digest}` must be a `sha256:<hex>` string"
624    )))
625}
626
627/// Validate the weight consistency of a multi-revision bundle entry.
628///
629/// Three cases:
630/// - **All unset**: equal split — computed at resolve time, nothing to
631///   validate here.
632/// - **All set** (via `weight_percent` or `weight_bps`): must sum to
633///   exactly [`FULL_TRAFFIC_BPS`] (10 000 bps).
634/// - **Mixed** (some set, some unset): error — no implicit remainder.
635fn validate_revision_weights(
636    bundle_id: &str,
637    revisions: &[ManifestRevision],
638) -> Result<(), OpError> {
639    let has_weight: Vec<bool> = revisions
640        .iter()
641        .map(|r| r.weight_percent.is_some() || r.weight_bps.is_some())
642        .collect();
643    let all_set = has_weight.iter().all(|&w| w);
644    let none_set = has_weight.iter().all(|&w| !w);
645    if !all_set && !none_set {
646        return Err(OpError::InvalidArgument(format!(
647            "bundle `{bundle_id}`: either ALL revisions must declare a weight or NONE \
648             (equal split) — mixing set and unset weights is not allowed"
649        )));
650    }
651    if all_set {
652        let sum: u32 = revisions
653            .iter()
654            .map(|r| effective_bps_single(r).expect("all_set guarantees a weight"))
655            .sum();
656        if sum != FULL_TRAFFIC_BPS {
657            return Err(OpError::InvalidArgument(format!(
658                "bundle `{bundle_id}`: revision weights sum to {sum} bps, must be exactly \
659                 {FULL_TRAFFIC_BPS} (100 %)"
660            )));
661        }
662    }
663    Ok(())
664}
665
666/// Resolve one revision's declared weight to basis points. `None` when the
667/// revision has no weight (equal-split case).
668fn effective_bps_single(rev: &ManifestRevision) -> Option<u32> {
669    if let Some(pct) = rev.weight_percent {
670        // 1 % = 100 bps.
671        Some(pct * 100)
672    } else {
673        rev.weight_bps
674    }
675}
676
677/// Compute the effective weight in basis points for every revision in a
678/// multi-revision entry. Callers have already passed `validate_shape`, so
679/// weights are either all-set or all-unset.
680///
681/// - **All set**: each revision's declared value (percent * 100 or bps).
682/// - **All unset**: equal split — `floor(10000 / n)` per revision,
683///   remainder added to the first.
684pub(crate) fn compute_effective_weights_bps(revisions: &[ManifestRevision]) -> Vec<u32> {
685    let n = revisions.len() as u32;
686    assert!(n > 0, "validated: revisions is non-empty");
687    if revisions[0].weight_percent.is_none() && revisions[0].weight_bps.is_none() {
688        // Equal split.
689        let base = FULL_TRAFFIC_BPS / n;
690        let remainder = FULL_TRAFFIC_BPS - base * n;
691        (0..n)
692            .map(|i| if i == 0 { base + remainder } else { base })
693            .collect()
694    } else {
695        revisions
696            .iter()
697            .map(|r| effective_bps_single(r).expect("validated: all-set"))
698            .collect()
699    }
700}
701
702/// Skeleton manifest for `op env apply --emit-answers-template`: one worked
703/// example entry per section, ready to edit. Secret entries name an
704/// environment VARIABLE (`from_env`) — values never appear in a manifest.
705///
706/// A verbatim literal (not a serialized [`EnvManifest`]) so the emitted
707/// file keeps the authoring order (`schema` first) instead of serde_json's
708/// alphabetical keys. Guarded by a round-trip test: the template must
709/// deserialize through [`EnvManifest`] (`deny_unknown_fields`) and pass
710/// [`EnvManifest::validate_shape`], so template and types cannot drift.
711pub const MANIFEST_TEMPLATE_JSON: &str = r#"{
712  "schema": "greentic.env-manifest.v1",
713  "environment": {
714    "id": "local",
715    "public_base_url": null
716  },
717  "trust_root": "bootstrap",
718  "secrets": [
719    {
720      "path": "default/_/messaging-telegram/telegram_bot_token",
721      "from_env": "TELEGRAM_BOT_TOKEN"
722    }
723  ],
724  "bundles": [
725    {
726      "bundle_id": "example-bundle",
727      "bundle_path": "example-bundle.gtbundle",
728      "route_binding": {
729        "path_prefixes": ["/example"],
730        "tenant_selector": { "tenant": "default", "team": "default" }
731      }
732    }
733  ],
734  "messaging_endpoints": [
735    {
736      "name": "example-endpoint",
737      "provider_type": "messaging.telegram.bot",
738      "links": ["example-bundle"],
739      "welcome_flow": {
740        "bundle_id": "example-bundle",
741        "pack_id": "example-pack",
742        "flow_id": "main"
743      }
744    }
745  ]
746}
747"#;
748
749/// Hand-written JSON Schema for the manifest (`op env apply --schema`),
750/// following the existing convention (A1 schemars wiring is still deferred).
751pub fn manifest_schema() -> Value {
752    serde_json::json!({
753        "$schema": "https://json-schema.org/draft/2020-12/schema",
754        "title": "EnvManifest",
755        "description": "greentic.env-manifest.v1 — declarative environment wiring for `gtc op env apply`",
756        "type": "object",
757        "required": ["schema", "environment"],
758        "additionalProperties": false,
759        "properties": {
760            "schema": {"const": ENV_MANIFEST_SCHEMA_V1},
761            "environment": {
762                "type": "object",
763                "required": ["id"],
764                "additionalProperties": false,
765                "properties": {
766                    "id": {"type": "string", "description": "Environment id; `local` bootstraps via env init; any other id must already exist (apply reconciles, the operator store creates)"},
767                    "public_base_url": {"type": ["string", "null"], "description": "origin-only URL; absent = leave untouched"},
768                    "name": {"type": ["string", "null"], "description": "display name; absent = leave untouched (or default to id on create)"},
769                    "region": {"type": ["string", "null"], "description": "cloud region tag; absent = leave untouched"},
770                    "tenant_org_id": {"type": ["string", "null"], "description": "tenant organization id; absent = leave untouched"},
771                    "listen_addr": {"type": ["string", "null"], "description": "bind address (SocketAddr); absent = leave untouched"},
772                    "gui_enabled": {"type": ["boolean", "null"], "description": "serve the built-in webchat GUI; absent/null = leave the stored value unchanged (upsert) — the env-id default (on for local, off elsewhere) applies only when the stored value is unset"}
773                }
774            },
775            "trust_root": {"enum": ["bootstrap", null], "description": "`bootstrap` seeds the operator key (idempotent)"},
776            "secrets": {
777                "type": "array",
778                "description": "dev-store secret entries; always-put (values cannot be diffed until A9)",
779                "items": {
780                    "type": "object",
781                    "required": ["path"],
782                    "additionalProperties": false,
783                    "properties": {
784                        "path": {"type": "string", "description": "<tenant>/<team>/<pack>/<name>"},
785                        "from_env": {"type": "string", "description": "env var holding the value; omit for a pasted (interactively-supplied) secret. Values never appear in the manifest either way"}
786                    }
787                }
788            },
789            "packs": {
790                "type": "array",
791                "description": "env-pack bindings (core capability slots); applied after trust-root, before secrets",
792                "items": {
793                    "type": "object",
794                    "required": ["slot", "kind", "pack_ref"],
795                    "additionalProperties": false,
796                    "properties": {
797                        "slot": {"type": "string", "description": "capability slot (must satisfy binds_in_packs)"},
798                        "kind": {"type": "string", "description": "PackDescriptor — `<namespace>.<id>@<semver>`"},
799                        "pack_ref": {"type": "string", "description": "pack reference (registry id or local path)"},
800                        "answers_ref": {"type": ["string", "null"], "description": "optional answers file relative to the manifest"}
801                    }
802                }
803            },
804            "bundles": {
805                "type": "array",
806                "items": {
807                    "type": "object",
808                    "required": ["bundle_id"],
809                    "additionalProperties": false,
810                    "properties": {
811                        "bundle_id": {"type": "string"},
812                        "bundle_path": {"type": ["string", "null"], "description": "single-revision form: local .gtbundle; relative to the manifest file; mutually exclusive with `revisions`"},
813                        "revisions": {
814                            "type": "array",
815                            "description": "multi-revision / traffic-split form; mutually exclusive with `bundle_path`",
816                            "items": {
817                                "type": "object",
818                                "required": ["name", "bundle_path"],
819                                "additionalProperties": false,
820                                "properties": {
821                                    "name": {"type": "string", "description": "manifest-local handle, unique within the bundle"},
822                                    "bundle_path": {"type": "string", "description": "local .gtbundle; relative to the manifest file"},
823                                    "weight_percent": {"type": ["integer", "null"], "description": "0..100; mutually exclusive with weight_bps"},
824                                    "weight_bps": {"type": ["integer", "null"], "description": "0..10000; mutually exclusive with weight_percent"},
825                                    "drain_seconds": {"type": ["integer", "null"], "description": "per-revision drain window override"},
826                                    "abort_metrics": {"type": "array", "items": {"type": "string"}, "description": "reserved for canary evaluation"},
827                                    "bundle_source_uri": {"type": ["string", "null"], "description": "oci://repo://store:// pull ref for K8s boot; rides alongside bundle_path (digest source); absent = local-serve only"},
828                                    "bundle_digest": {"type": ["string", "null"], "description": "optional sha256:<hex> integrity pin for bundle_path; verified at apply"}
829                                }
830                            }
831                        },
832                        "customer_id": {"type": ["string", "null"], "description": "required for non-local envs (B10)"},
833                        "revenue_share": {
834                            "type": ["array", "null"],
835                            "description": "G2: billing split; basis_points must sum to 10000; absent=untouched (greentic@10000)",
836                            "items": {
837                                "type": "object",
838                                "required": ["party_id", "basis_points"],
839                                "additionalProperties": false,
840                                "properties": {
841                                    "party_id": {"type": "string"},
842                                    "basis_points": {"type": "integer", "description": "0..10000; all entries sum to 10000"}
843                                }
844                            }
845                        },
846                        "status": {"type": ["string", "null"], "enum": ["active", "paused", "archived", null], "description": "G3: deployment status; absent=untouched; reconciled against an existing deployment"},
847                        "config_overrides": {"type": ["object", "null"], "description": "<pack_id> -> <key> -> <json>; absent=untouched, {}=clear, map=replace"},
848                        "route_binding": {
849                            "type": ["object", "null"],
850                            "properties": {
851                                "hosts": {"type": "array", "items": {"type": "string"}},
852                                "path_prefixes": {"type": "array", "items": {"type": "string"}},
853                                "tenant_selector": {
854                                    "type": ["object", "null"],
855                                    "required": ["tenant", "team"],
856                                    "properties": {"tenant": {"type": "string"}, "team": {"type": "string"}}
857                                }
858                            }
859                        },
860                        "bundle_source_uri": {"type": ["string", "null"], "description": "single-revision oci:// pull ref for K8s boot; may stand alone (apply fetches it once for the digest) or ride alongside bundle_path; absent = local bundle_path required"},
861                        "bundle_digest": {"type": ["string", "null"], "description": "optional sha256:<hex> integrity pin for the resolved artifact; verified at apply, else the computed digest is recorded"}
862                    }
863                }
864            },
865            "extensions": {
866                "type": "array",
867                "description": "extension bindings (N-per-env open namespace); applied after bundles, before endpoints",
868                "items": {
869                    "type": "object",
870                    "required": ["kind", "pack_ref"],
871                    "additionalProperties": false,
872                    "properties": {
873                        "kind": {"type": "string", "description": "PackDescriptor — `<namespace>.<id>@<semver>`"},
874                        "pack_ref": {"type": "string", "description": "pack reference (registry id or local path)"},
875                        "instance_id": {"type": ["string", "null"], "description": "instance selector for N instances of the same type; [a-z0-9-]"},
876                        "answers_ref": {"type": ["string", "null"], "description": "optional answers file relative to the manifest"}
877                    }
878                }
879            },
880            "messaging_endpoints": {
881                "type": "array",
882                "items": {
883                    "type": "object",
884                    "required": ["name", "provider_type"],
885                    "additionalProperties": false,
886                    "properties": {
887                        "name": {"type": "string", "description": "natural key: matches existing endpoints by (provider_type, display_name)"},
888                        "provider_type": {"type": "string"},
889                        "links": {"type": "array", "items": {"type": "string"}},
890                        "welcome_flow": {
891                            "type": ["object", "null"],
892                            "required": ["bundle_id", "pack_id", "flow_id"],
893                            "additionalProperties": false,
894                            "properties": {
895                                "bundle_id": {"type": "string"},
896                                "pack_id": {"type": "string"},
897                                "flow_id": {"type": "string"}
898                            }
899                        },
900                        "secret_refs": {"type": "array", "items": {"type": "string"}}
901                    }
902                }
903            }
904        }
905    })
906}
907
908/// Form id of the env-manifest authoring form ([`manifest_form_spec`]).
909pub const ENV_MANIFEST_FORM_ID: &str = "greentic.env-manifest";
910
911/// Version paired with [`ENV_MANIFEST_FORM_ID`]. Answer sets carry it and
912/// [`answers_to_manifest`] rejects a mismatch — bump it whenever the
913/// question set changes shape, so stale answer files fail loudly instead of
914/// converting wrong.
915pub const ENV_MANIFEST_FORM_VERSION: &str = "1";
916
917/// [`manifest_form_spec_for_env`] for the default env id ([`GUI_DEFAULT_ENV_ID`],
918/// i.e. `local`). Back-compat entry point for callers that don't yet thread the
919/// target env id; prefer the `_for_env` form so the `webchat_gui` default
920/// reflects the env (off for non-local).
921pub fn manifest_form_spec() -> FormSpec {
922    manifest_form_spec_for_env(GUI_DEFAULT_ENV_ID)
923}
924
925/// The one `qa_spec::FormSpec` for authoring a manifest. The greentic-setup
926/// terminal wizard, the future web UI, and Adaptive-Card front-ends all
927/// render these same questions; [`answers_to_manifest`] converts the
928/// resulting [`AnswerSet`] into a typed [`EnvManifest`] — the manifest stays
929/// the durable artifact, answers are an input mechanism.
930///
931/// `env_id` is the environment the wizard targets; it sets the `webchat_gui`
932/// default (on for `local`, off elsewhere — see [`GUI_DEFAULT_ENV_ID`]) so a
933/// non-local pass doesn't default the loopback-only console on. It does not
934/// otherwise constrain the answers.
935///
936/// Conventions (each pinned by a test):
937/// - Repeating manifest sections (`secrets[]`, `bundles[]`,
938///   `messaging_endpoints[]`) are `List` questions; an answer is an array of
939///   objects keyed by the row field ids.
940/// - Secret-adjacent questions ask for the env-var NAME (`from_env`), never
941///   a value — no question carries `secret: true`. Unset variables are the
942///   apply engine's concern (missing-inputs contract + TTY fill-in).
943/// - `required` is the manifest's validation truth, and doubles as the
944///   normal-mode marker under greentic-setup's `advanced || required`
945///   wizard filter: fields the manifest allows to be absent
946///   (`public_base_url`, `customer_id`, `config_overrides`, route binding,
947///   welcome flow, …) are `required: false` and surface in advanced mode.
948///   The three `List` sections are `required: false` — an empty section is
949///   a valid manifest, so absence must pass [`qa_spec::validate()`], and the
950///   qa prompt loop walks `List` questions regardless of `required` (its
951///   normal-mode filter exempts tables). `trust_root_bootstrap` stays
952///   `required` (a `false` answer is valid; the prompt fills the default).
953/// - Nested string arrays (`links`, `route_path_prefixes`, …) are
954///   comma-separated `String` questions — qa-spec `List` rows cannot nest
955///   lists. [`answers_to_manifest`] owns the split.
956pub fn manifest_form_spec_for_env(env_id: &str) -> FormSpec {
957    let mut environment_id = question(
958        "environment_id",
959        QuestionType::String,
960        "Environment id",
961        "Environment to apply to. `local` bootstraps with default env-pack \
962         bindings; any other id must already exist (apply reconciles it; \
963         non-local env creation is reserved for the operator store).",
964        true,
965    );
966    environment_id.default_value = Some("local".to_string());
967
968    let public_base_url = question(
969        "public_base_url",
970        QuestionType::String,
971        "Public base URL",
972        "Origin-only URL persisted on the environment (e.g. \
973         https://bots.example.com). Leave empty to keep the current value.",
974        false,
975    );
976
977    let mut trust_root_bootstrap = question(
978        "trust_root_bootstrap",
979        QuestionType::Boolean,
980        "Bootstrap the trust root?",
981        "Seed the environment trust root with the local operator key \
982         (idempotent; required once before bundles can be staged).",
983        true,
984    );
985    trust_root_bootstrap.default_value = Some("true".to_string());
986
987    let mut webchat_gui = question(
988        "webchat_gui",
989        QuestionType::Boolean,
990        "Add a webchat GUI?",
991        "Serve the built-in webchat console so you can chat with this \
992         environment by opening its URL in a browser. On by default for \
993         `local`; the chat path is loopback-only and unauthenticated, so \
994         keep it off for environments exposed on a public URL unless you \
995         intend it.",
996        true,
997    );
998    // Env-aware default so a non-local wizard pass doesn't silently enable the
999    // loopback-only console: matches `resolved_gui_enabled()` (on for the
1000    // `local` env id, off elsewhere) — the single home of the policy lives in
1001    // `GUI_DEFAULT_ENV_ID`. `Some(_)` answers still override either way.
1002    webchat_gui.default_value = Some((env_id == GUI_DEFAULT_ENV_ID).to_string());
1003
1004    let mut secrets = question(
1005        "secrets",
1006        QuestionType::List,
1007        "Secrets",
1008        "Dev-store secret entries. Each secret's value comes either from a \
1009         named environment variable or from a value you paste in — values \
1010         never go into a manifest.",
1011        false,
1012    );
1013    // Not `required`: a sensible default of `env` keeps older answer rows
1014    // (which only carried `from_env`) valid, and drives the prompt default.
1015    let mut secret_source = question(
1016        "source",
1017        QuestionType::Enum,
1018        "Secret source",
1019        "`env` reads the value from a named environment variable at apply \
1020         time; `paste` lets you enter the value interactively — it is stored \
1021         in the env's secrets store, never in the manifest.",
1022        false,
1023    );
1024    secret_source.choices = Some(vec!["env".to_string(), "paste".to_string()]);
1025    secret_source.default_value = Some("env".to_string());
1026
1027    let mut secret_from_env = question(
1028        "from_env",
1029        QuestionType::String,
1030        "Environment variable name",
1031        "Name of the variable holding the secret value (e.g. \
1032         TELEGRAM_BOT_TOKEN) — the name, never the value. Required when the \
1033         source is `env`.",
1034        false,
1035    );
1036    secret_from_env.visible_if = Some(Expr::Eq {
1037        left: Box::new(Expr::Var {
1038            path: "source".to_string(),
1039        }),
1040        right: Box::new(Expr::Literal {
1041            value: Value::String("env".to_string()),
1042        }),
1043    });
1044
1045    secrets.list = Some(ListSpec {
1046        min_items: None,
1047        max_items: None,
1048        fields: vec![
1049            question(
1050                "path",
1051                QuestionType::String,
1052                "Secret path",
1053                "`<tenant>/<team>/<pack>/<name>`, e.g. \
1054                 default/_/messaging-telegram/telegram_bot_token",
1055                true,
1056            ),
1057            secret_source,
1058            secret_from_env,
1059        ],
1060        item_label: Some("secret".to_string()),
1061    });
1062
1063    let mut bundles = question(
1064        "bundles",
1065        QuestionType::List,
1066        "Bundles",
1067        "Bundle deployments for this environment.",
1068        false,
1069    );
1070    bundles.list = Some(ListSpec {
1071        min_items: None,
1072        max_items: None,
1073        fields: vec![
1074            question(
1075                "bundle_id",
1076                QuestionType::String,
1077                "Bundle id",
1078                "Natural key — unique within the manifest.",
1079                true,
1080            ),
1081            question(
1082                "bundle_path",
1083                QuestionType::String,
1084                "Bundle path",
1085                "Local `.gtbundle`. Relative paths resolve against the \
1086                 manifest file's directory.",
1087                true,
1088            ),
1089            question(
1090                "customer_id",
1091                QuestionType::String,
1092                "Customer id",
1093                "Billing principal — required by apply for non-`local` \
1094                 environments.",
1095                false,
1096            ),
1097            question(
1098                "config_overrides",
1099                QuestionType::String,
1100                "Config overrides (JSON)",
1101                "JSON object `{\"<pack_id>\": {\"<key>\": <value>}}`. Empty \
1102                 = leave untouched; `{}` = explicit clear.",
1103                false,
1104            ),
1105            question(
1106                "route_hosts",
1107                QuestionType::String,
1108                "Route hosts",
1109                "Comma-separated host names for the route binding.",
1110                false,
1111            ),
1112            {
1113                // Defaults to `/<bundle_id>` (the everyday single-prefix
1114                // route); the operator overrides for multi-prefix or custom
1115                // routes. `computed_overridable` ⇒ shown as the prompt
1116                // default, not force-applied.
1117                let mut q = question(
1118                    "route_path_prefixes",
1119                    QuestionType::String,
1120                    "Route path prefixes",
1121                    "Comma-separated HTTP path prefixes, each starting with `/` \
1122                     (e.g. /legal).",
1123                    false,
1124                );
1125                q.computed = Some(Expr::Concat {
1126                    parts: vec![
1127                        Expr::Literal {
1128                            value: Value::String("/".to_string()),
1129                        },
1130                        Expr::Var {
1131                            path: "bundle_id".to_string(),
1132                        },
1133                    ],
1134                });
1135                q.computed_overridable = true;
1136                q
1137            },
1138            {
1139                // Defaults to the bundle id so each bundle gets its own
1140                // tenant scope out of the box.
1141                let mut q = question(
1142                    "route_tenant",
1143                    QuestionType::String,
1144                    "Route tenant",
1145                    "Tenant for the route binding's tenant selector — set \
1146                     together with `route_team`.",
1147                    false,
1148                );
1149                q.computed = Some(Expr::Var {
1150                    path: "bundle_id".to_string(),
1151                });
1152                q.computed_overridable = true;
1153                q
1154            },
1155            {
1156                // Defaults to the `default` team (the common single-team
1157                // case); paired with `route_tenant` to form the selector.
1158                let mut q = question(
1159                    "route_team",
1160                    QuestionType::String,
1161                    "Route team",
1162                    "Team for the route binding's tenant selector — set \
1163                     together with `route_tenant`.",
1164                    false,
1165                );
1166                q.default_value = Some("default".to_string());
1167                q
1168            },
1169        ],
1170        item_label: Some("bundle".to_string()),
1171    });
1172
1173    let mut messaging_endpoints = question(
1174        "messaging_endpoints",
1175        QuestionType::List,
1176        "Messaging endpoints",
1177        "Messaging endpoints and their bundle links.",
1178        false,
1179    );
1180    messaging_endpoints.list = Some(ListSpec {
1181        min_items: None,
1182        max_items: None,
1183        fields: vec![
1184            question(
1185                "name",
1186                QuestionType::String,
1187                "Endpoint name",
1188                "Manifest-local handle and display name. Upsert key \
1189                 together with the provider type.",
1190                true,
1191            ),
1192            question(
1193                "provider_type",
1194                QuestionType::String,
1195                "Provider type",
1196                "Provider class, e.g. messaging.telegram.bot.",
1197                true,
1198            ),
1199            {
1200                // Defaults to the endpoint name, which by convention matches
1201                // the bundle id it fronts (e.g. endpoint `legal` admits
1202                // bundle `legal`). Override to admit several bundles.
1203                let mut q = question(
1204                    "links",
1205                    QuestionType::String,
1206                    "Linked bundle ids",
1207                    "Comma-separated `bundle_id`s this endpoint admits.",
1208                    false,
1209                );
1210                q.computed = Some(Expr::Var {
1211                    path: "name".to_string(),
1212                });
1213                q.computed_overridable = true;
1214                q
1215            },
1216            question(
1217                "welcome_bundle_id",
1218                QuestionType::String,
1219                "Welcome flow: bundle id",
1220                "Set the three welcome_* fields together (or none).",
1221                false,
1222            ),
1223            question(
1224                "welcome_pack_id",
1225                QuestionType::String,
1226                "Welcome flow: pack id",
1227                "Set the three welcome_* fields together (or none).",
1228                false,
1229            ),
1230            question(
1231                "welcome_flow_id",
1232                QuestionType::String,
1233                "Welcome flow: flow id",
1234                "Set the three welcome_* fields together (or none).",
1235                false,
1236            ),
1237            question(
1238                "secret_refs",
1239                QuestionType::String,
1240                "Secret refs",
1241                "Comma-separated secret refs forwarded on endpoint create.",
1242                false,
1243            ),
1244        ],
1245        item_label: Some("Messaging endpoint".to_string()),
1246    });
1247
1248    FormSpec {
1249        id: ENV_MANIFEST_FORM_ID.to_string(),
1250        title: "Environment setup".to_string(),
1251        version: ENV_MANIFEST_FORM_VERSION.to_string(),
1252        description: Some(format!(
1253            "Authors a `{ENV_MANIFEST_SCHEMA_V1}` manifest — the durable, \
1254             re-appliable desired-state document for one environment."
1255        )),
1256        presentation: None,
1257        progress_policy: None,
1258        secrets_policy: None,
1259        store: Vec::new(),
1260        validations: Vec::new(),
1261        includes: Vec::new(),
1262        // Secrets come LAST: the terminal wizard derives the required
1263        // secret paths from the bundles/endpoints just authored and asks
1264        // only for the env-var name, so the section is most useful after
1265        // those are known. Other front-ends render the same order.
1266        questions: vec![
1267            environment_id,
1268            public_base_url,
1269            trust_root_bootstrap,
1270            webchat_gui,
1271            bundles,
1272            messaging_endpoints,
1273            secrets,
1274        ],
1275    }
1276}
1277
1278/// [`QuestionSpec`] constructor. Spells out every field (no
1279/// `..Default::default()`) on purpose: a field added to qa-spec's
1280/// `QuestionSpec` becomes a compile error here, forcing a deliberate
1281/// default instead of silently inheriting one.
1282fn question(
1283    id: &str,
1284    kind: QuestionType,
1285    title: &str,
1286    description: &str,
1287    required: bool,
1288) -> QuestionSpec {
1289    QuestionSpec {
1290        id: id.to_string(),
1291        kind,
1292        title: title.to_string(),
1293        title_i18n: None,
1294        description: Some(description.to_string()),
1295        description_i18n: None,
1296        required,
1297        choices: None,
1298        default_value: None,
1299        secret: false,
1300        visible_if: None,
1301        constraint: None,
1302        list: None,
1303        computed: None,
1304        policy: QuestionPolicy::default(),
1305        computed_overridable: false,
1306    }
1307}
1308
1309/// Convert a [`manifest_form_spec`] answer set into a typed [`EnvManifest`].
1310///
1311/// Pure conversion: errors only on values that cannot map onto the manifest
1312/// types (wrong JSON type, half-set field pairs, unparseable
1313/// `config_overrides`). Callers run `qa_spec::validate` on the answers
1314/// first for required/constraint enforcement, and the apply engine runs
1315/// [`EnvManifest::validate_shape`] on the result — this function does not
1316/// duplicate either. Lenient on absence (missing sections → empty) so a
1317/// minimal hand-written answers file converts.
1318///
1319/// Convention reminder for new fields: every `Vec<String>` manifest field
1320/// (`links`, `route_hosts`, `route_path_prefixes`, `secret_refs`) is a
1321/// comma-separated `String` question and MUST come through
1322/// [`split_csv`] — a plain `req_row_string` would smuggle the commas into
1323/// a single entry.
1324pub fn answers_to_manifest(answers: &AnswerSet) -> Result<EnvManifest, OpError> {
1325    if answers.form_id != ENV_MANIFEST_FORM_ID {
1326        return Err(OpError::InvalidArgument(format!(
1327            "answers form_id `{}` is not `{ENV_MANIFEST_FORM_ID}`",
1328            answers.form_id
1329        )));
1330    }
1331    if answers.spec_version != ENV_MANIFEST_FORM_VERSION {
1332        return Err(OpError::InvalidArgument(format!(
1333            "answers spec_version `{}` is not `{ENV_MANIFEST_FORM_VERSION}` \
1334             — re-run the wizard against the current form",
1335            answers.spec_version
1336        )));
1337    }
1338    let map = answers
1339        .answers
1340        .as_object()
1341        .ok_or_else(|| OpError::InvalidArgument("answers must be a JSON object".to_string()))?;
1342
1343    let environment_id = opt_string(map, "environment_id")?.ok_or_else(|| {
1344        OpError::InvalidArgument("answers: environment_id must be a non-empty string".to_string())
1345    })?;
1346    let public_base_url = opt_string(map, "public_base_url")?;
1347    let trust_root = match map.get("trust_root_bootstrap") {
1348        None | Some(Value::Null) | Some(Value::Bool(false)) => None,
1349        Some(Value::Bool(true)) => Some(TrustRootDirective::Bootstrap),
1350        Some(other) => {
1351            return Err(OpError::InvalidArgument(format!(
1352                "answers: trust_root_bootstrap must be a boolean, got {other}"
1353            )));
1354        }
1355    };
1356    // Absent ⇒ `None` (leave the env-id default to resolve); the wizard always
1357    // supplies this (required, default `true`), so a missing value only comes
1358    // from a minimal hand-written answers file.
1359    let gui_enabled = match map.get("webchat_gui") {
1360        None | Some(Value::Null) => None,
1361        Some(Value::Bool(b)) => Some(*b),
1362        Some(other) => {
1363            return Err(OpError::InvalidArgument(format!(
1364                "answers: webchat_gui must be a boolean, got {other}"
1365            )));
1366        }
1367    };
1368
1369    let mut secrets = Vec::new();
1370    for (idx, row) in rows(map, "secrets")?.iter().enumerate() {
1371        let row = row_object("secrets", idx, row)?;
1372        let path = req_row_string("secrets", idx, row, "path")?;
1373        // `source` selects where the value comes from. Defaulting to `env`
1374        // keeps older answer rows (which only carried `from_env`) working.
1375        let source =
1376            opt_row_string("secrets", idx, row, "source")?.unwrap_or_else(|| "env".to_string());
1377        let from_env = match source.as_str() {
1378            "env" => Some(req_row_string("secrets", idx, row, "from_env")?),
1379            "paste" => None,
1380            other => {
1381                return Err(OpError::InvalidArgument(format!(
1382                    "answers: secrets[{idx}]: source must be `env` or `paste`, got `{other}`"
1383                )));
1384            }
1385        };
1386        secrets.push(ManifestSecret { path, from_env });
1387    }
1388
1389    let mut bundles = Vec::new();
1390    for (idx, row) in rows(map, "bundles")?.iter().enumerate() {
1391        let row = row_object("bundles", idx, row)?;
1392        let bundle_id = req_row_string("bundles", idx, row, "bundle_id")?;
1393        let config_overrides = match opt_row_string("bundles", idx, row, "config_overrides")? {
1394            None => None,
1395            Some(raw) => Some(
1396                serde_json::from_str::<BTreeMap<String, BTreeMap<String, Value>>>(&raw).map_err(
1397                    |err| {
1398                        OpError::InvalidArgument(format!(
1399                            "answers: bundles[{idx}] (`{bundle_id}`): config_overrides is \
1400                             not a `<pack_id> -> <key> -> <value>` JSON object: {err}"
1401                        ))
1402                    },
1403                )?,
1404            ),
1405        };
1406        let hosts = split_csv(opt_row_string("bundles", idx, row, "route_hosts")?);
1407        let path_prefixes = split_csv(opt_row_string("bundles", idx, row, "route_path_prefixes")?);
1408        let tenant_selector = match (
1409            opt_row_string("bundles", idx, row, "route_tenant")?,
1410            opt_row_string("bundles", idx, row, "route_team")?,
1411        ) {
1412            (Some(tenant), Some(team)) => Some(TenantSelectorPayload { tenant, team }),
1413            (None, None) => None,
1414            _ => {
1415                return Err(OpError::InvalidArgument(format!(
1416                    "answers: bundles[{idx}] (`{bundle_id}`): set route_tenant and \
1417                     route_team together (or neither)"
1418                )));
1419            }
1420        };
1421        let route_binding =
1422            if hosts.is_empty() && path_prefixes.is_empty() && tenant_selector.is_none() {
1423                None
1424            } else {
1425                Some(RouteBindingPayload {
1426                    hosts,
1427                    path_prefixes,
1428                    tenant_selector,
1429                })
1430            };
1431        bundles.push(ManifestBundle {
1432            bundle_id,
1433            bundle_path: Some(PathBuf::from(req_row_string(
1434                "bundles",
1435                idx,
1436                row,
1437                "bundle_path",
1438            )?)),
1439            revisions: None,
1440            customer_id: opt_row_string("bundles", idx, row, "customer_id")?,
1441            revenue_share: None,
1442            status: None,
1443            config_overrides,
1444            route_binding,
1445            // The wizard authors local-serve bundles; OCI pull refs and digest
1446            // pins are JSON-first (hand-authored for K8s deployments).
1447            bundle_source_uri: None,
1448            bundle_digest: None,
1449        });
1450    }
1451
1452    let mut messaging_endpoints = Vec::new();
1453    for (idx, row) in rows(map, "messaging_endpoints")?.iter().enumerate() {
1454        let row = row_object("messaging_endpoints", idx, row)?;
1455        let name = req_row_string("messaging_endpoints", idx, row, "name")?;
1456        let welcome_flow = match (
1457            opt_row_string("messaging_endpoints", idx, row, "welcome_bundle_id")?,
1458            opt_row_string("messaging_endpoints", idx, row, "welcome_pack_id")?,
1459            opt_row_string("messaging_endpoints", idx, row, "welcome_flow_id")?,
1460        ) {
1461            (Some(bundle_id), Some(pack_id), Some(flow_id)) => Some(ManifestWelcomeFlow {
1462                bundle_id,
1463                pack_id,
1464                flow_id,
1465            }),
1466            (None, None, None) => None,
1467            _ => {
1468                return Err(OpError::InvalidArgument(format!(
1469                    "answers: messaging_endpoints[{idx}] (`{name}`): set \
1470                     welcome_bundle_id, welcome_pack_id and welcome_flow_id \
1471                     together (or none)"
1472                )));
1473            }
1474        };
1475        messaging_endpoints.push(ManifestEndpoint {
1476            name,
1477            provider_type: req_row_string("messaging_endpoints", idx, row, "provider_type")?,
1478            links: split_csv(opt_row_string("messaging_endpoints", idx, row, "links")?),
1479            welcome_flow,
1480            secret_refs: split_csv(opt_row_string(
1481                "messaging_endpoints",
1482                idx,
1483                row,
1484                "secret_refs",
1485            )?),
1486        });
1487    }
1488
1489    Ok(EnvManifest {
1490        schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
1491        environment: ManifestEnvironment {
1492            id: environment_id,
1493            public_base_url,
1494            name: None,
1495            region: None,
1496            tenant_org_id: None,
1497            listen_addr: None,
1498            gui_enabled,
1499        },
1500        trust_root,
1501        secrets,
1502        packs: Vec::new(),
1503        bundles,
1504        extensions: Vec::new(),
1505        messaging_endpoints,
1506    })
1507}
1508
1509/// A `List` answer: absent/null → empty, anything but an array → error.
1510fn rows<'a>(map: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a [Value], OpError> {
1511    const EMPTY: &[Value] = &[];
1512    match map.get(key) {
1513        None | Some(Value::Null) => Ok(EMPTY),
1514        Some(Value::Array(items)) => Ok(items.as_slice()),
1515        Some(other) => Err(OpError::InvalidArgument(format!(
1516            "answers: {key} must be an array, got {other}"
1517        ))),
1518    }
1519}
1520
1521fn row_object<'a>(
1522    section: &str,
1523    idx: usize,
1524    row: &'a Value,
1525) -> Result<&'a serde_json::Map<String, Value>, OpError> {
1526    row.as_object().ok_or_else(|| {
1527        OpError::InvalidArgument(format!(
1528            "answers: {section}[{idx}] must be an object, got {row}"
1529        ))
1530    })
1531}
1532
1533/// Optional string answer: absent/null/blank → `None`; non-string → error.
1534fn opt_string(map: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, OpError> {
1535    opt_string_at(map, key, key)
1536}
1537
1538/// [`opt_string`] with a caller-supplied error label (`section[idx].key`
1539/// for row fields), so every type error keeps the offending value.
1540fn opt_string_at(
1541    map: &serde_json::Map<String, Value>,
1542    key: &str,
1543    label: &str,
1544) -> Result<Option<String>, OpError> {
1545    match map.get(key) {
1546        None | Some(Value::Null) => Ok(None),
1547        Some(Value::String(s)) => {
1548            let trimmed = s.trim();
1549            Ok((!trimmed.is_empty()).then(|| trimmed.to_string()))
1550        }
1551        Some(other) => Err(OpError::InvalidArgument(format!(
1552            "answers: {label} must be a string, got {other}"
1553        ))),
1554    }
1555}
1556
1557fn opt_row_string(
1558    section: &str,
1559    idx: usize,
1560    row: &serde_json::Map<String, Value>,
1561    key: &str,
1562) -> Result<Option<String>, OpError> {
1563    opt_string_at(row, key, &format!("{section}[{idx}].{key}"))
1564}
1565
1566fn req_row_string(
1567    section: &str,
1568    idx: usize,
1569    row: &serde_json::Map<String, Value>,
1570    key: &str,
1571) -> Result<String, OpError> {
1572    opt_row_string(section, idx, row, key)?.ok_or_else(|| {
1573        OpError::InvalidArgument(format!(
1574            "answers: {section}[{idx}].{key} must be a non-empty string"
1575        ))
1576    })
1577}
1578
1579/// Split a comma-separated answer into trimmed, non-empty entries.
1580fn split_csv(value: Option<String>) -> Vec<String> {
1581    value
1582        .map(|raw| {
1583            raw.split(',')
1584                .map(str::trim)
1585                .filter(|entry| !entry.is_empty())
1586                .map(str::to_string)
1587                .collect()
1588        })
1589        .unwrap_or_default()
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594    use super::*;
1595
1596    fn minimal(schema: &str) -> EnvManifest {
1597        serde_json::from_value(serde_json::json!({
1598            "schema": schema,
1599            "environment": {"id": "local"}
1600        }))
1601        .expect("minimal manifest parses")
1602    }
1603
1604    #[test]
1605    fn schema_mismatch_rejected() {
1606        let err = minimal("greentic.env-manifest.v2")
1607            .validate_shape()
1608            .unwrap_err();
1609        assert!(matches!(err, OpError::InvalidArgument(_)), "{err}");
1610    }
1611
1612    #[test]
1613    fn unknown_top_level_field_rejected_at_parse() {
1614        let err = serde_json::from_value::<EnvManifest>(serde_json::json!({
1615            "schema": ENV_MANIFEST_SCHEMA_V1,
1616            "environment": {"id": "local"},
1617            "bundlez": []
1618        }))
1619        .unwrap_err();
1620        assert!(err.to_string().contains("bundlez"), "{err}");
1621    }
1622
1623    #[test]
1624    fn valid_secrets_pass_shape_validation() {
1625        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1626            "schema": ENV_MANIFEST_SCHEMA_V1,
1627            "environment": {"id": "local"},
1628            "secrets": [
1629                {"path": "legal/_/messaging-telegram/telegram_bot_token", "from_env": "A"},
1630                {"path": "accounting/_/messaging-telegram/telegram_bot_token", "from_env": "B"}
1631            ]
1632        }))
1633        .unwrap();
1634        manifest.validate_shape().expect("valid");
1635    }
1636
1637    #[test]
1638    fn non_canonical_secret_path_rejected_at_shape() {
1639        // Same checks as `op secrets put` (shared helper): wrong depth,
1640        // non-canonical team, non-canonical name.
1641        for path in [
1642            "credentials/aws",
1643            "legal/default/messaging-telegram/telegram_bot_token",
1644            "legal/_/messaging-telegram/BOT-TOKEN",
1645        ] {
1646            let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1647                "schema": ENV_MANIFEST_SCHEMA_V1,
1648                "environment": {"id": "local"},
1649                "secrets": [{"path": path, "from_env": "X"}]
1650            }))
1651            .unwrap();
1652            let err = manifest.validate_shape().unwrap_err();
1653            assert!(
1654                matches!(err, OpError::InvalidArgument(_)),
1655                "path `{path}` got {err}"
1656            );
1657        }
1658    }
1659
1660    #[test]
1661    fn duplicate_secret_path_rejected() {
1662        // The dup check runs on the trimmed path, so a leading `/` cannot
1663        // smuggle in a duplicate.
1664        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1665            "schema": ENV_MANIFEST_SCHEMA_V1,
1666            "environment": {"id": "local"},
1667            "secrets": [
1668                {"path": "legal/_/p/tok", "from_env": "A"},
1669                {"path": "/legal/_/p/tok", "from_env": "B"}
1670            ]
1671        }))
1672        .unwrap();
1673        let err = manifest.validate_shape().unwrap_err();
1674        assert!(err.to_string().contains("duplicate secret path"), "{err}");
1675    }
1676
1677    #[test]
1678    fn empty_from_env_rejected() {
1679        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1680            "schema": ENV_MANIFEST_SCHEMA_V1,
1681            "environment": {"id": "local"},
1682            "secrets": [{"path": "legal/_/p/tok", "from_env": "  "}]
1683        }))
1684        .unwrap();
1685        let err = manifest.validate_shape().unwrap_err();
1686        assert!(err.to_string().contains("from_env"), "{err}");
1687    }
1688
1689    #[test]
1690    fn paste_secret_omits_from_env_and_validates() {
1691        // A paste-sourced secret carries no `from_env`; validate_shape accepts
1692        // it and serialization omits the field (no plaintext, no empty key).
1693        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1694            "schema": ENV_MANIFEST_SCHEMA_V1,
1695            "environment": {"id": "local"},
1696            "secrets": [{"path": "legal/_/p/tok"}]
1697        }))
1698        .unwrap();
1699        manifest
1700            .validate_shape()
1701            .expect("paste secret is shape-valid");
1702        assert_eq!(manifest.secrets[0].from_env, None);
1703        let json = serde_json::to_value(&manifest).unwrap();
1704        assert!(
1705            json["secrets"][0].get("from_env").is_none(),
1706            "absent from_env is omitted, not serialized as null"
1707        );
1708    }
1709
1710    #[test]
1711    fn answers_to_manifest_maps_secret_source() {
1712        // `source: env` keeps `from_env`.
1713        let env_set = answers(serde_json::json!({
1714            "environment_id": "local",
1715            "secrets": [{"path": "legal/_/p/tok", "source": "env", "from_env": "LEGAL_TOK"}]
1716        }));
1717        assert_eq!(
1718            answers_to_manifest(&env_set).unwrap().secrets[0]
1719                .from_env
1720                .as_deref(),
1721            Some("LEGAL_TOK")
1722        );
1723
1724        // `source: paste` drops `from_env`.
1725        let paste_set = answers(serde_json::json!({
1726            "environment_id": "local",
1727            "secrets": [{"path": "legal/_/p/tok", "source": "paste"}]
1728        }));
1729        assert_eq!(
1730            answers_to_manifest(&paste_set).unwrap().secrets[0].from_env,
1731            None
1732        );
1733
1734        // No `source` defaults to `env` (back-compat with older answer rows).
1735        let legacy_set = answers(serde_json::json!({
1736            "environment_id": "local",
1737            "secrets": [{"path": "legal/_/p/tok", "from_env": "LEGACY"}]
1738        }));
1739        assert_eq!(
1740            answers_to_manifest(&legacy_set).unwrap().secrets[0]
1741                .from_env
1742                .as_deref(),
1743            Some("LEGACY")
1744        );
1745
1746        // An unknown source is a clear error, not a silent default.
1747        let bad_set = answers(serde_json::json!({
1748            "environment_id": "local",
1749            "secrets": [{"path": "legal/_/p/tok", "source": "vault"}]
1750        }));
1751        let err = answers_to_manifest(&bad_set).unwrap_err();
1752        assert!(err.to_string().contains("source must be"), "{err}");
1753    }
1754
1755    #[test]
1756    fn form_spec_secrets_models_env_or_paste() {
1757        let spec = manifest_form_spec();
1758        let secrets = spec
1759            .questions
1760            .iter()
1761            .find(|q| q.id == "secrets")
1762            .expect("secrets question");
1763        let list = secrets.list.as_ref().expect("secrets is a list");
1764
1765        let source = list
1766            .fields
1767            .iter()
1768            .find(|f| f.id == "source")
1769            .expect("source column");
1770        assert_eq!(source.kind, QuestionType::Enum);
1771        assert_eq!(
1772            source.choices.as_deref(),
1773            Some(&["env".to_string(), "paste".to_string()][..])
1774        );
1775        assert_eq!(source.default_value.as_deref(), Some("env"));
1776        assert!(!source.required, "source defaults to env, never required");
1777
1778        let from_env = list
1779            .fields
1780            .iter()
1781            .find(|f| f.id == "from_env")
1782            .expect("from_env column");
1783        assert!(
1784            !from_env.required,
1785            "from_env is needed only for env-sourced secrets"
1786        );
1787        assert_eq!(
1788            from_env.visible_if,
1789            Some(Expr::Eq {
1790                left: Box::new(Expr::Var {
1791                    path: "source".to_string()
1792                }),
1793                right: Box::new(Expr::Literal {
1794                    value: Value::String("env".to_string())
1795                }),
1796            }),
1797            "from_env is shown only when source == env"
1798        );
1799    }
1800
1801    #[test]
1802    fn duplicate_bundle_id_rejected() {
1803        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1804            "schema": ENV_MANIFEST_SCHEMA_V1,
1805            "environment": {"id": "local"},
1806            "bundles": [
1807                {"bundle_id": "a", "bundle_path": "a.gtbundle"},
1808                {"bundle_id": "a", "bundle_path": "b.gtbundle"}
1809            ]
1810        }))
1811        .unwrap();
1812        let err = manifest.validate_shape().unwrap_err();
1813        assert!(err.to_string().contains("duplicate bundle_id"), "{err}");
1814    }
1815
1816    #[test]
1817    fn duplicate_endpoint_name_rejected() {
1818        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1819            "schema": ENV_MANIFEST_SCHEMA_V1,
1820            "environment": {"id": "local"},
1821            "messaging_endpoints": [
1822                {"name": "n", "provider_type": "messaging.telegram.bot"},
1823                {"name": "n", "provider_type": "messaging.telegram.bot"}
1824            ]
1825        }))
1826        .unwrap();
1827        let err = manifest.validate_shape().unwrap_err();
1828        assert!(err.to_string().contains("duplicate endpoint name"), "{err}");
1829    }
1830
1831    #[test]
1832    fn tenant_selector_without_matcher_rejected() {
1833        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1834            "schema": ENV_MANIFEST_SCHEMA_V1,
1835            "environment": {"id": "local"},
1836            "bundles": [{
1837                "bundle_id": "a",
1838                "bundle_path": "a.gtbundle",
1839                "route_binding": {"tenant_selector": {"tenant": "t", "team": "d"}}
1840            }]
1841        }))
1842        .unwrap();
1843        let err = manifest.validate_shape().unwrap_err();
1844        assert!(err.to_string().contains("tenant_selector"), "{err}");
1845    }
1846
1847    #[test]
1848    fn path_prefix_must_start_with_slash() {
1849        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1850            "schema": ENV_MANIFEST_SCHEMA_V1,
1851            "environment": {"id": "local"},
1852            "bundles": [{
1853                "bundle_id": "a",
1854                "bundle_path": "a.gtbundle",
1855                "route_binding": {"path_prefixes": ["legal"]}
1856            }]
1857        }))
1858        .unwrap();
1859        let err = manifest.validate_shape().unwrap_err();
1860        assert!(err.to_string().contains("must start with `/`"), "{err}");
1861    }
1862
1863    #[test]
1864    fn duplicate_link_in_endpoint_rejected() {
1865        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1866            "schema": ENV_MANIFEST_SCHEMA_V1,
1867            "environment": {"id": "local"},
1868            "messaging_endpoints": [{
1869                "name": "n",
1870                "provider_type": "messaging.telegram.bot",
1871                "links": ["bundle-a", "bundle-a"]
1872            }]
1873        }))
1874        .unwrap();
1875        let err = manifest.validate_shape().unwrap_err();
1876        assert!(err.to_string().contains("duplicate link"), "{err}");
1877        assert!(err.to_string().contains("bundle-a"), "{err}");
1878    }
1879
1880    #[test]
1881    fn trust_root_bootstrap_parses() {
1882        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1883            "schema": ENV_MANIFEST_SCHEMA_V1,
1884            "environment": {"id": "local"},
1885            "trust_root": "bootstrap"
1886        }))
1887        .unwrap();
1888        assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
1889        manifest.validate_shape().expect("valid");
1890    }
1891
1892    #[test]
1893    fn template_round_trips_through_manifest_and_shape_validation() {
1894        // The `--emit-answers-template` skeleton and the serde types must
1895        // never drift: the template parses under `deny_unknown_fields` AND
1896        // passes shape validation (canonical secret path, route binding
1897        // rules, ...) as-is.
1898        let manifest: EnvManifest =
1899            serde_json::from_str(MANIFEST_TEMPLATE_JSON).expect("template parses as EnvManifest");
1900        manifest
1901            .validate_shape()
1902            .expect("template passes validate_shape");
1903        assert_eq!(manifest.schema, ENV_MANIFEST_SCHEMA_V1);
1904        // Every section carries a worked example.
1905        assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
1906        assert!(!manifest.secrets.is_empty());
1907        assert!(!manifest.bundles.is_empty());
1908        assert!(!manifest.messaging_endpoints.is_empty());
1909    }
1910
1911    #[test]
1912    fn two_dept_worked_example_parses() {
1913        // The full §3 worked example from the design doc.
1914        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1915            "schema": ENV_MANIFEST_SCHEMA_V1,
1916            "environment": {"id": "local", "public_base_url": null},
1917            "trust_root": "bootstrap",
1918            "secrets": [
1919                {
1920                    "path": "legal/_/messaging-telegram/telegram_bot_token",
1921                    "from_env": "TELEGRAM_LEGAL_BOT_TOKEN"
1922                },
1923                {
1924                    "path": "accounting/_/messaging-telegram/telegram_bot_token",
1925                    "from_env": "TELEGRAM_ACCOUNTING_BOT_TOKEN"
1926                }
1927            ],
1928            "bundles": [
1929                {
1930                    "bundle_id": "realbot-legal",
1931                    "bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
1932                    "route_binding": {
1933                        "hosts": [],
1934                        "path_prefixes": ["/legal"],
1935                        "tenant_selector": {"tenant": "legal", "team": "default"}
1936                    }
1937                },
1938                {
1939                    "bundle_id": "realbot-accounting",
1940                    "bundle_path": "bundle-workspace-accounting/realbot-accounting.gtbundle",
1941                    "route_binding": {
1942                        "hosts": [],
1943                        "path_prefixes": ["/accounting"],
1944                        "tenant_selector": {"tenant": "accounting", "team": "default"}
1945                    }
1946                }
1947            ],
1948            "messaging_endpoints": [
1949                {
1950                    "name": "realbot-legal",
1951                    "provider_type": "messaging.telegram.bot",
1952                    "links": ["realbot-legal"]
1953                },
1954                {
1955                    "name": "realbot-accounting",
1956                    "provider_type": "messaging.telegram.bot",
1957                    "links": ["realbot-accounting"]
1958                }
1959            ]
1960        }))
1961        .unwrap();
1962        manifest.validate_shape().expect("worked example is valid");
1963        assert_eq!(manifest.secrets.len(), 2);
1964        assert_eq!(manifest.bundles.len(), 2);
1965        assert_eq!(manifest.messaging_endpoints.len(), 2);
1966    }
1967
1968    /// Composite id (`list.field`) for every question, the same notation the
1969    /// coverage table uses.
1970    fn question_ids(spec: &FormSpec) -> BTreeSet<String> {
1971        let mut ids = BTreeSet::new();
1972        for q in &spec.questions {
1973            match &q.list {
1974                Some(list) => {
1975                    for field in &list.fields {
1976                        assert!(
1977                            ids.insert(format!("{}.{}", q.id, field.id)),
1978                            "duplicate question id {}.{}",
1979                            q.id,
1980                            field.id
1981                        );
1982                    }
1983                }
1984                None => {
1985                    assert!(ids.insert(q.id.clone()), "duplicate question id {}", q.id);
1986                }
1987            }
1988        }
1989        ids
1990    }
1991
1992    fn answers(value: Value) -> AnswerSet {
1993        AnswerSet {
1994            form_id: ENV_MANIFEST_FORM_ID.to_string(),
1995            spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
1996            answers: value,
1997            meta: None,
1998        }
1999    }
2000
2001    #[test]
2002    fn form_spec_never_asks_for_secret_values() {
2003        // The design rule: secret questions ask for env-var NAMES, so no
2004        // question is secret-flagged and every List question carries its row
2005        // definition.
2006        let spec = manifest_form_spec();
2007        for q in &spec.questions {
2008            assert!(!q.secret, "`{}` must not be a secret question", q.id);
2009            match q.kind {
2010                QuestionType::List => {
2011                    let list = q.list.as_ref().unwrap_or_else(|| {
2012                        panic!("List question `{}` is missing its row definition", q.id)
2013                    });
2014                    assert!(!list.fields.is_empty(), "`{}` has no row fields", q.id);
2015                    for field in &list.fields {
2016                        assert!(!field.secret, "`{}.{}` must not be secret", q.id, field.id);
2017                    }
2018                }
2019                _ => assert!(q.list.is_none(), "`{}` is not a List but has rows", q.id),
2020            }
2021        }
2022    }
2023
2024    fn webchat_gui_default(spec: &FormSpec) -> Option<&str> {
2025        spec.questions
2026            .iter()
2027            .find(|q| q.id == "webchat_gui")
2028            .expect("webchat_gui question")
2029            .default_value
2030            .as_deref()
2031    }
2032
2033    #[test]
2034    fn webchat_gui_default_is_env_aware() {
2035        // `local` opts the loopback console in by default; any other env id
2036        // defaults it OFF so a non-local wizard pass doesn't silently enable
2037        // an unauthenticated surface. Mirrors `resolved_gui_enabled()`.
2038        assert_eq!(
2039            webchat_gui_default(&manifest_form_spec_for_env(GUI_DEFAULT_ENV_ID)),
2040            Some("true")
2041        );
2042        assert_eq!(
2043            webchat_gui_default(&manifest_form_spec_for_env("prod")),
2044            Some("false")
2045        );
2046        // Back-compat entry point keeps the historical local default.
2047        assert_eq!(webchat_gui_default(&manifest_form_spec()), Some("true"));
2048    }
2049
2050    #[test]
2051    fn required_marks_the_normal_mode_surface() {
2052        // `required` is validation truth AND the normal-mode marker under
2053        // greentic-setup's `advanced || required` wizard filter. Everything
2054        // the manifest allows to be absent must stay non-required.
2055        let spec = manifest_form_spec();
2056        let mut required = BTreeSet::new();
2057        for q in &spec.questions {
2058            if q.required {
2059                required.insert(q.id.clone());
2060            }
2061            for field in q.list.iter().flat_map(|l| &l.fields) {
2062                if field.required {
2063                    required.insert(format!("{}.{}", q.id, field.id));
2064                }
2065            }
2066        }
2067        let expected: BTreeSet<String> = [
2068            "environment_id",
2069            "trust_root_bootstrap",
2070            "webchat_gui",
2071            // `secrets.from_env` is no longer required (a paste secret omits
2072            // it); `secrets.source` defaults to `env`, so it is not required
2073            // either.
2074            "secrets.path",
2075            "bundles.bundle_id",
2076            "bundles.bundle_path",
2077            "messaging_endpoints.name",
2078            "messaging_endpoints.provider_type",
2079        ]
2080        .into_iter()
2081        .map(str::to_string)
2082        .collect();
2083        assert_eq!(required, expected);
2084    }
2085
2086    #[test]
2087    fn derived_row_defaults_evaluate_from_sibling_columns() {
2088        // The everyday-wiring defaults: a single-bundle `legal` setup should
2089        // pre-fill route prefix `/legal`, tenant `legal`, team `default`, and
2090        // endpoint link `legal` — each derived from a sibling column in the
2091        // same row via `computed` (+ `computed_overridable`, so the wizard
2092        // surfaces them as overridable prompt defaults) or a static default.
2093        let spec = manifest_form_spec();
2094
2095        fn list_fields<'a>(spec: &'a FormSpec, id: &str) -> &'a [QuestionSpec] {
2096            spec.questions
2097                .iter()
2098                .find(|q| q.id == id)
2099                .and_then(|q| q.list.as_ref())
2100                .map(|l| l.fields.as_slice())
2101                .unwrap_or_else(|| panic!("list `{id}` missing"))
2102        }
2103        fn field<'a>(fields: &'a [QuestionSpec], id: &str) -> &'a QuestionSpec {
2104            fields
2105                .iter()
2106                .find(|f| f.id == id)
2107                .unwrap_or_else(|| panic!("field `{id}` missing"))
2108        }
2109        // Row context as the wizard builds it: keys are sibling field ids.
2110        let bundle_row = serde_json::json!({ "bundle_id": "legal" });
2111        let endpoint_row = serde_json::json!({ "name": "legal" });
2112
2113        let bundles = list_fields(&spec, "bundles");
2114        let prefixes = field(bundles, "route_path_prefixes");
2115        assert!(prefixes.computed_overridable);
2116        assert_eq!(
2117            prefixes
2118                .computed
2119                .as_ref()
2120                .and_then(|e| e.evaluate_value(&bundle_row)),
2121            Some(serde_json::json!("/legal"))
2122        );
2123        let tenant = field(bundles, "route_tenant");
2124        assert!(tenant.computed_overridable);
2125        assert_eq!(
2126            tenant
2127                .computed
2128                .as_ref()
2129                .and_then(|e| e.evaluate_value(&bundle_row)),
2130            Some(serde_json::json!("legal"))
2131        );
2132        assert_eq!(
2133            field(bundles, "route_team").default_value.as_deref(),
2134            Some("default")
2135        );
2136
2137        let endpoints = list_fields(&spec, "messaging_endpoints");
2138        let links = field(endpoints, "links");
2139        assert!(links.computed_overridable);
2140        assert_eq!(
2141            links
2142                .computed
2143                .as_ref()
2144                .and_then(|e| e.evaluate_value(&endpoint_row)),
2145            Some(serde_json::json!("legal"))
2146        );
2147
2148        // Custom row-add labels for the terminal wizard prompt.
2149        let label = |id: &str| {
2150            spec.questions
2151                .iter()
2152                .find(|q| q.id == id)
2153                .and_then(|q| q.list.as_ref())
2154                .and_then(|l| l.item_label.clone())
2155        };
2156        assert_eq!(label("bundles").as_deref(), Some("bundle"));
2157        assert_eq!(
2158            label("messaging_endpoints").as_deref(),
2159            Some("Messaging endpoint")
2160        );
2161    }
2162
2163    #[test]
2164    fn form_questions_and_manifest_fields_cover_each_other() {
2165        // Bidirectional drift guard: every manifest field (leaf of
2166        // `manifest_schema()`) maps to a question, and every question maps
2167        // to a manifest field. Adding a field to the manifest or a question
2168        // to the form fails this test until the mapping (and the
2169        // counterpart) exists. `""` marks fields `answers_to_manifest`
2170        // produces as constants.
2171        const FIELD_TO_QUESTION: &[(&str, &str)] = &[
2172            ("schema", ""),
2173            ("environment.id", "environment_id"),
2174            ("environment.public_base_url", "public_base_url"),
2175            ("environment.name", ""),
2176            ("environment.region", ""),
2177            ("environment.tenant_org_id", ""),
2178            ("environment.listen_addr", ""),
2179            ("environment.gui_enabled", "webchat_gui"),
2180            ("trust_root", "trust_root_bootstrap"),
2181            ("secrets[].path", "secrets.path"),
2182            ("secrets[].from_env", "secrets.from_env"),
2183            ("packs[].slot", ""),
2184            ("packs[].kind", ""),
2185            ("packs[].pack_ref", ""),
2186            ("packs[].answers_ref", ""),
2187            ("bundles[].bundle_id", "bundles.bundle_id"),
2188            ("bundles[].bundle_path", "bundles.bundle_path"),
2189            // OCI/repo/store pull ref + digest pin are JSON-first (no form question).
2190            ("bundles[].bundle_source_uri", ""),
2191            ("bundles[].bundle_digest", ""),
2192            // Multi-revision fields are JSON-first (no form question).
2193            ("bundles[].revisions[].name", ""),
2194            ("bundles[].revisions[].bundle_path", ""),
2195            ("bundles[].revisions[].weight_percent", ""),
2196            ("bundles[].revisions[].weight_bps", ""),
2197            ("bundles[].revisions[].drain_seconds", ""),
2198            ("bundles[].revisions[].abort_metrics", ""),
2199            ("bundles[].revisions[].bundle_source_uri", ""),
2200            ("bundles[].revisions[].bundle_digest", ""),
2201            ("bundles[].customer_id", "bundles.customer_id"),
2202            // revenue_share / status are JSON-first (no form question).
2203            ("bundles[].revenue_share[].party_id", ""),
2204            ("bundles[].revenue_share[].basis_points", ""),
2205            ("bundles[].status", ""),
2206            ("bundles[].config_overrides", "bundles.config_overrides"),
2207            ("bundles[].route_binding.hosts", "bundles.route_hosts"),
2208            (
2209                "bundles[].route_binding.path_prefixes",
2210                "bundles.route_path_prefixes",
2211            ),
2212            (
2213                "bundles[].route_binding.tenant_selector.tenant",
2214                "bundles.route_tenant",
2215            ),
2216            (
2217                "bundles[].route_binding.tenant_selector.team",
2218                "bundles.route_team",
2219            ),
2220            ("extensions[].kind", ""),
2221            ("extensions[].pack_ref", ""),
2222            ("extensions[].instance_id", ""),
2223            ("extensions[].answers_ref", ""),
2224            ("messaging_endpoints[].name", "messaging_endpoints.name"),
2225            (
2226                "messaging_endpoints[].provider_type",
2227                "messaging_endpoints.provider_type",
2228            ),
2229            ("messaging_endpoints[].links", "messaging_endpoints.links"),
2230            (
2231                "messaging_endpoints[].welcome_flow.bundle_id",
2232                "messaging_endpoints.welcome_bundle_id",
2233            ),
2234            (
2235                "messaging_endpoints[].welcome_flow.pack_id",
2236                "messaging_endpoints.welcome_pack_id",
2237            ),
2238            (
2239                "messaging_endpoints[].welcome_flow.flow_id",
2240                "messaging_endpoints.welcome_flow_id",
2241            ),
2242            (
2243                "messaging_endpoints[].secret_refs",
2244                "messaging_endpoints.secret_refs",
2245            ),
2246        ];
2247
2248        fn collect_leaves(node: &Value, prefix: &str, out: &mut BTreeSet<String>) {
2249            if let Some(items) = node.get("items") {
2250                if items.get("properties").is_some() {
2251                    collect_leaves(items, &format!("{prefix}[]"), out);
2252                } else {
2253                    out.insert(prefix.to_string());
2254                }
2255                return;
2256            }
2257            if let Some(props) = node.get("properties").and_then(Value::as_object) {
2258                for (key, sub) in props {
2259                    let path = if prefix.is_empty() {
2260                        key.clone()
2261                    } else {
2262                        format!("{prefix}.{key}")
2263                    };
2264                    collect_leaves(sub, &path, out);
2265                }
2266                return;
2267            }
2268            out.insert(prefix.to_string());
2269        }
2270
2271        let mut schema_leaves = BTreeSet::new();
2272        collect_leaves(&manifest_schema(), "", &mut schema_leaves);
2273        let mapped_fields: BTreeSet<String> = FIELD_TO_QUESTION
2274            .iter()
2275            .map(|(field, _)| field.to_string())
2276            .collect();
2277        assert_eq!(
2278            schema_leaves, mapped_fields,
2279            "manifest fields and the coverage table drifted — map every \
2280             schema leaf to a question (or `\"\"` for constants)"
2281        );
2282
2283        // `secrets.source` is a UI-only discriminator: it selects whether a
2284        // secret carries `from_env` (env-sourced) or omits it (paste-sourced).
2285        // It drives `secrets[].from_env`'s presence rather than mapping to a
2286        // manifest field of its own.
2287        const FORM_ONLY_QUESTIONS: &[&str] = &["secrets.source"];
2288        let mut mapped_questions: BTreeSet<String> = FIELD_TO_QUESTION
2289            .iter()
2290            .filter(|(_, q)| !q.is_empty())
2291            .map(|(_, q)| q.to_string())
2292            .collect();
2293        mapped_questions.extend(FORM_ONLY_QUESTIONS.iter().map(|q| q.to_string()));
2294        assert_eq!(
2295            question_ids(&manifest_form_spec()),
2296            mapped_questions,
2297            "form questions and the coverage table drifted — every question \
2298             must map to a manifest field (or be a declared form-only discriminator)"
2299        );
2300    }
2301
2302    #[test]
2303    fn answers_round_trip_to_valid_manifest() {
2304        let spec = manifest_form_spec();
2305        let set = answers(serde_json::json!({
2306            "environment_id": "local",
2307            "public_base_url": "https://bots.example.com",
2308            "trust_root_bootstrap": true,
2309            "webchat_gui": true,
2310            "secrets": [
2311                {
2312                    "path": "legal/_/messaging-telegram/telegram_bot_token",
2313                    "from_env": "TELEGRAM_LEGAL_BOT_TOKEN"
2314                }
2315            ],
2316            "bundles": [
2317                {
2318                    "bundle_id": "realbot-legal",
2319                    "bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
2320                    "customer_id": "acme",
2321                    "config_overrides": "{\"realbot\": {\"mode\": \"prod\"}}",
2322                    "route_path_prefixes": "/legal, /legal-archive",
2323                    "route_tenant": "legal",
2324                    "route_team": "default"
2325                }
2326            ],
2327            "messaging_endpoints": [
2328                {
2329                    "name": "realbot-legal",
2330                    "provider_type": "messaging.telegram.bot",
2331                    "links": "realbot-legal, realbot-audit",
2332                    "welcome_bundle_id": "realbot-legal",
2333                    "welcome_pack_id": "realbot",
2334                    "welcome_flow_id": "main"
2335                }
2336            ]
2337        }));
2338
2339        let report = qa_spec::validate(&spec, &set.answers);
2340        assert!(report.valid, "answers must pass the form spec: {report:?}");
2341
2342        let manifest = answers_to_manifest(&set).expect("converts");
2343        manifest.validate_shape().expect("round-trip passes shape");
2344
2345        assert_eq!(manifest.environment.id, "local");
2346        assert_eq!(
2347            manifest.environment.public_base_url.as_deref(),
2348            Some("https://bots.example.com")
2349        );
2350        assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
2351        assert_eq!(manifest.secrets.len(), 1);
2352        assert_eq!(
2353            manifest.secrets[0].from_env.as_deref(),
2354            Some("TELEGRAM_LEGAL_BOT_TOKEN"),
2355            "from_env carries the variable NAME"
2356        );
2357        let bundle = &manifest.bundles[0];
2358        assert_eq!(bundle.customer_id.as_deref(), Some("acme"));
2359        assert_eq!(
2360            bundle.config_overrides.as_ref().unwrap()["realbot"]["mode"],
2361            serde_json::json!("prod")
2362        );
2363        let rb = bundle.route_binding.as_ref().expect("route binding built");
2364        assert_eq!(rb.path_prefixes, ["/legal", "/legal-archive"]);
2365        assert!(rb.hosts.is_empty());
2366        let selector = rb.tenant_selector.as_ref().expect("selector built");
2367        assert_eq!(
2368            (selector.tenant.as_str(), selector.team.as_str()),
2369            ("legal", "default")
2370        );
2371        let ep = &manifest.messaging_endpoints[0];
2372        assert_eq!(ep.links, ["realbot-legal", "realbot-audit"]);
2373        assert_eq!(
2374            ep.welcome_flow,
2375            Some(ManifestWelcomeFlow {
2376                bundle_id: "realbot-legal".to_string(),
2377                pack_id: "realbot".to_string(),
2378                flow_id: "main".to_string(),
2379            })
2380        );
2381        assert!(ep.secret_refs.is_empty());
2382    }
2383
2384    #[test]
2385    fn minimal_answers_convert_leniently() {
2386        // Conversion is lenient on absence (qa_spec::validate owns
2387        // required-ness): a bare environment_id yields a valid empty
2388        // manifest with no trust-root directive.
2389        let manifest = answers_to_manifest(&answers(serde_json::json!({
2390            "environment_id": "demo",
2391            "trust_root_bootstrap": false
2392        })))
2393        .expect("converts");
2394        manifest.validate_shape().expect("valid shape");
2395        assert_eq!(manifest.environment.id, "demo");
2396        assert_eq!(manifest.environment.public_base_url, None);
2397        assert_eq!(manifest.trust_root, None);
2398        assert!(manifest.secrets.is_empty());
2399        assert!(manifest.bundles.is_empty());
2400        assert!(manifest.messaging_endpoints.is_empty());
2401    }
2402
2403    #[test]
2404    fn minimal_answers_pass_form_validation() {
2405        // An empty section is a valid manifest, so the `List` sections must
2406        // be `required: false`: minimal answers (no lists at all) pass
2407        // qa_spec::validate — the wizard's declined tables don't trip a
2408        // bogus required nag, and headless validation stays honest.
2409        let result = qa_spec::validate(
2410            &manifest_form_spec(),
2411            &serde_json::json!({
2412                "environment_id": "local",
2413                "trust_root_bootstrap": true,
2414                "webchat_gui": true
2415            }),
2416        );
2417        assert!(
2418            result.valid,
2419            "errors: {:?}, missing: {:?}, unknown: {:?}",
2420            result.errors, result.missing_required, result.unknown_fields
2421        );
2422    }
2423
2424    #[test]
2425    fn answers_conversion_errors_name_the_gap() {
2426        for (label, value, needle) in [
2427            (
2428                "missing environment_id",
2429                serde_json::json!({}),
2430                "environment_id",
2431            ),
2432            (
2433                "tenant without team",
2434                serde_json::json!({
2435                    "environment_id": "local",
2436                    "bundles": [{
2437                        "bundle_id": "b", "bundle_path": "b.gtbundle",
2438                        "route_tenant": "legal"
2439                    }]
2440                }),
2441                "route_team",
2442            ),
2443            (
2444                "partial welcome flow",
2445                serde_json::json!({
2446                    "environment_id": "local",
2447                    "messaging_endpoints": [{
2448                        "name": "n", "provider_type": "messaging.telegram.bot",
2449                        "welcome_bundle_id": "b"
2450                    }]
2451                }),
2452                "welcome_pack_id",
2453            ),
2454            (
2455                "config_overrides not an object",
2456                serde_json::json!({
2457                    "environment_id": "local",
2458                    "bundles": [{
2459                        "bundle_id": "b", "bundle_path": "b.gtbundle",
2460                        "config_overrides": "[1, 2]"
2461                    }]
2462                }),
2463                "config_overrides",
2464            ),
2465            (
2466                "row field of the wrong type",
2467                serde_json::json!({
2468                    "environment_id": "local",
2469                    "secrets": [{"path": "a/_/p/tok", "from_env": 7}]
2470                }),
2471                "secrets[0].from_env",
2472            ),
2473        ] {
2474            let err = answers_to_manifest(&answers(value)).unwrap_err();
2475            assert!(
2476                err.to_string().contains(needle),
2477                "{label}: expected `{needle}` in `{err}`"
2478            );
2479        }
2480    }
2481
2482    #[test]
2483    fn answers_form_identity_is_checked() {
2484        let mut set = answers(serde_json::json!({"environment_id": "local"}));
2485        set.form_id = "something.else".to_string();
2486        let err = answers_to_manifest(&set).unwrap_err();
2487        assert!(err.to_string().contains(ENV_MANIFEST_FORM_ID), "{err}");
2488
2489        let mut set = answers(serde_json::json!({"environment_id": "local"}));
2490        set.spec_version = "0".to_string();
2491        let err = answers_to_manifest(&set).unwrap_err();
2492        assert!(err.to_string().contains("spec_version"), "{err}");
2493    }
2494
2495    #[test]
2496    fn form_spec_enforces_required_row_fields() {
2497        // Guards the row field ids against typos: a secrets row without the
2498        // required `path` must fail qa-spec validation (not slide through as
2499        // an unknown field). `from_env` is optional now (paste secrets omit
2500        // it), so `path` is the row's required field to probe.
2501        let spec = manifest_form_spec();
2502        let report = qa_spec::validate(
2503            &spec,
2504            &serde_json::json!({
2505                "environment_id": "local",
2506                "trust_root_bootstrap": false,
2507                "secrets": [{"source": "env", "from_env": "X"}],
2508                "bundles": [],
2509                "messaging_endpoints": []
2510            }),
2511        );
2512        assert!(!report.valid);
2513        assert!(
2514            report
2515                .errors
2516                .iter()
2517                .any(|e| format!("{e:?}").contains("path")),
2518            "missing row field must be reported: {report:?}"
2519        );
2520    }
2521
2522    // --- multi-revision tests ---
2523
2524    #[test]
2525    fn multi_revision_deserialize_and_validate() {
2526        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2527            "schema": ENV_MANIFEST_SCHEMA_V1,
2528            "environment": {"id": "local"},
2529            "bundles": [{
2530                "bundle_id": "canary-test",
2531                "revisions": [
2532                    {"name": "stable", "bundle_path": "stable.gtbundle", "weight_bps": 9000},
2533                    {"name": "canary", "bundle_path": "canary.gtbundle", "weight_bps": 1000}
2534                ]
2535            }]
2536        }))
2537        .unwrap();
2538        manifest.validate_shape().expect("valid multi-revision");
2539        let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2540        assert_eq!(revs.len(), 2);
2541        assert_eq!(revs[0].name, "stable");
2542        assert_eq!(revs[1].weight_bps, Some(1000));
2543    }
2544
2545    #[test]
2546    fn multi_revision_equal_split_no_weights() {
2547        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2548            "schema": ENV_MANIFEST_SCHEMA_V1,
2549            "environment": {"id": "local"},
2550            "bundles": [{
2551                "bundle_id": "ab-test",
2552                "revisions": [
2553                    {"name": "a", "bundle_path": "a.gtbundle"},
2554                    {"name": "b", "bundle_path": "b.gtbundle"},
2555                    {"name": "c", "bundle_path": "c.gtbundle"}
2556                ]
2557            }]
2558        }))
2559        .unwrap();
2560        manifest.validate_shape().expect("valid equal-split");
2561        let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2562        let weights = compute_effective_weights_bps(revs);
2563        // 10000 / 3 = 3333 each, remainder 1 to first.
2564        assert_eq!(weights, vec![3334, 3333, 3333]);
2565        assert_eq!(weights.iter().sum::<u32>(), FULL_TRAFFIC_BPS);
2566    }
2567
2568    #[test]
2569    fn multi_revision_weight_percent_converts_to_bps() {
2570        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2571            "schema": ENV_MANIFEST_SCHEMA_V1,
2572            "environment": {"id": "local"},
2573            "bundles": [{
2574                "bundle_id": "pct-test",
2575                "revisions": [
2576                    {"name": "a", "bundle_path": "a.gtbundle", "weight_percent": 70},
2577                    {"name": "b", "bundle_path": "b.gtbundle", "weight_percent": 30}
2578                ]
2579            }]
2580        }))
2581        .unwrap();
2582        manifest.validate_shape().expect("valid percent weights");
2583        let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2584        let weights = compute_effective_weights_bps(revs);
2585        assert_eq!(weights, vec![7000, 3000]);
2586    }
2587
2588    #[test]
2589    fn multi_revision_weight_sum_not_10000_fails() {
2590        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2591            "schema": ENV_MANIFEST_SCHEMA_V1,
2592            "environment": {"id": "local"},
2593            "bundles": [{
2594                "bundle_id": "bad-sum",
2595                "revisions": [
2596                    {"name": "a", "bundle_path": "a.gtbundle", "weight_bps": 5000},
2597                    {"name": "b", "bundle_path": "b.gtbundle", "weight_bps": 3000}
2598                ]
2599            }]
2600        }))
2601        .unwrap();
2602        let err = manifest.validate_shape().unwrap_err();
2603        assert!(err.to_string().contains("8000 bps"), "{err}");
2604        assert!(err.to_string().contains("10000"), "{err}");
2605    }
2606
2607    #[test]
2608    fn both_bundle_path_and_revisions_fails() {
2609        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2610            "schema": ENV_MANIFEST_SCHEMA_V1,
2611            "environment": {"id": "local"},
2612            "bundles": [{
2613                "bundle_id": "both",
2614                "bundle_path": "both.gtbundle",
2615                "revisions": [
2616                    {"name": "a", "bundle_path": "a.gtbundle"}
2617                ]
2618            }]
2619        }))
2620        .unwrap();
2621        let err = manifest.validate_shape().unwrap_err();
2622        assert!(err.to_string().contains("mutually exclusive"), "{err}");
2623    }
2624
2625    #[test]
2626    fn bundle_source_uri_round_trips_single_and_multi() {
2627        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2628            "schema": ENV_MANIFEST_SCHEMA_V1,
2629            "environment": {"id": "local"},
2630            "bundles": [
2631                {"bundle_id": "single", "bundle_path": "x.gtbundle",
2632                 "bundle_source_uri": "oci://ex/single:v1"},
2633                {"bundle_id": "multi", "revisions": [
2634                    {"name": "a", "bundle_path": "a.gtbundle",
2635                     "bundle_source_uri": "oci://ex/multi:a"}
2636                ]}
2637            ]
2638        }))
2639        .unwrap();
2640        manifest.validate_shape().unwrap();
2641        assert_eq!(
2642            manifest.bundles[0].bundle_source_uri.as_deref(),
2643            Some("oci://ex/single:v1")
2644        );
2645        assert_eq!(
2646            manifest.bundles[1].revisions.as_ref().unwrap()[0]
2647                .bundle_source_uri
2648                .as_deref(),
2649            Some("oci://ex/multi:a")
2650        );
2651        // Survives a serialize round-trip.
2652        let back: EnvManifest =
2653            serde_json::from_value(serde_json::to_value(&manifest).unwrap()).unwrap();
2654        assert_eq!(
2655            back.bundles[0].bundle_source_uri.as_deref(),
2656            Some("oci://ex/single:v1")
2657        );
2658    }
2659
2660    #[test]
2661    fn bundle_with_no_path_no_uri_no_revisions_fails() {
2662        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2663            "schema": ENV_MANIFEST_SCHEMA_V1,
2664            "environment": {"id": "local"},
2665            "bundles": [{
2666                "bundle_id": "neither"
2667            }]
2668        }))
2669        .unwrap();
2670        let err = manifest.validate_shape().unwrap_err();
2671        assert!(
2672            err.to_string()
2673                .contains("needs a `bundle_path` or a `bundle_source_uri`"),
2674            "{err}"
2675        );
2676    }
2677
2678    #[test]
2679    fn single_revision_bundle_source_uri_only_is_valid() {
2680        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2681            "schema": ENV_MANIFEST_SCHEMA_V1,
2682            "environment": {"id": "local"},
2683            "bundles": [{
2684                "bundle_id": "remote-only",
2685                "bundle_source_uri": "oci://ex/remote-only:v1",
2686                "bundle_digest": "sha256:abc123"
2687            }]
2688        }))
2689        .unwrap();
2690        manifest.validate_shape().unwrap();
2691        assert!(manifest.bundles[0].bundle_path.is_none());
2692        assert_eq!(
2693            manifest.bundles[0].bundle_source_uri.as_deref(),
2694            Some("oci://ex/remote-only:v1")
2695        );
2696        assert_eq!(
2697            manifest.bundles[0].bundle_digest.as_deref(),
2698            Some("sha256:abc123")
2699        );
2700        // Survives a serialize round-trip.
2701        let back: EnvManifest =
2702            serde_json::from_value(serde_json::to_value(&manifest).unwrap()).unwrap();
2703        assert_eq!(
2704            back.bundles[0].bundle_digest.as_deref(),
2705            Some("sha256:abc123")
2706        );
2707    }
2708
2709    #[test]
2710    fn bundle_level_source_uri_with_revisions_fails() {
2711        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2712            "schema": ENV_MANIFEST_SCHEMA_V1,
2713            "environment": {"id": "local"},
2714            "bundles": [{
2715                "bundle_id": "split",
2716                "bundle_source_uri": "oci://ex/split:v1",
2717                "revisions": [
2718                    {"name": "a", "bundle_path": "a.gtbundle"}
2719                ]
2720            }]
2721        }))
2722        .unwrap();
2723        let err = manifest.validate_shape().unwrap_err();
2724        assert!(err.to_string().contains("single-revision form"), "{err}");
2725    }
2726
2727    #[test]
2728    fn bundle_level_digest_with_revisions_fails() {
2729        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2730            "schema": ENV_MANIFEST_SCHEMA_V1,
2731            "environment": {"id": "local"},
2732            "bundles": [{
2733                "bundle_id": "split-digest",
2734                "bundle_digest": "sha256:abc123",
2735                "revisions": [
2736                    {"name": "a", "bundle_path": "a.gtbundle"}
2737                ]
2738            }]
2739        }))
2740        .unwrap();
2741        let err = manifest.validate_shape().unwrap_err();
2742        assert!(err.to_string().contains("single-revision form"), "{err}");
2743    }
2744
2745    #[test]
2746    fn malformed_bundle_digest_fails() {
2747        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2748            "schema": ENV_MANIFEST_SCHEMA_V1,
2749            "environment": {"id": "local"},
2750            "bundles": [{
2751                "bundle_id": "bad-digest",
2752                "bundle_path": "x.gtbundle",
2753                "bundle_digest": "deadbeef"
2754            }]
2755        }))
2756        .unwrap();
2757        let err = manifest.validate_shape().unwrap_err();
2758        assert!(
2759            err.to_string().contains("must be a `sha256:<hex>` string"),
2760            "{err}"
2761        );
2762    }
2763
2764    #[test]
2765    fn malformed_per_revision_bundle_digest_fails() {
2766        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2767            "schema": ENV_MANIFEST_SCHEMA_V1,
2768            "environment": {"id": "local"},
2769            "bundles": [{
2770                "bundle_id": "split-bad-digest",
2771                "revisions": [
2772                    {"name": "a", "bundle_path": "a.gtbundle", "bundle_digest": "nope"}
2773                ]
2774            }]
2775        }))
2776        .unwrap();
2777        let err = manifest.validate_shape().unwrap_err();
2778        assert!(
2779            err.to_string().contains("must be a `sha256:<hex>` string"),
2780            "{err}"
2781        );
2782    }
2783
2784    #[test]
2785    fn duplicate_revision_name_fails() {
2786        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2787            "schema": ENV_MANIFEST_SCHEMA_V1,
2788            "environment": {"id": "local"},
2789            "bundles": [{
2790                "bundle_id": "dups",
2791                "revisions": [
2792                    {"name": "same", "bundle_path": "a.gtbundle", "weight_bps": 5000},
2793                    {"name": "same", "bundle_path": "b.gtbundle", "weight_bps": 5000}
2794                ]
2795            }]
2796        }))
2797        .unwrap();
2798        let err = manifest.validate_shape().unwrap_err();
2799        assert!(err.to_string().contains("duplicate revision name"), "{err}");
2800    }
2801
2802    #[test]
2803    fn mixed_set_unset_weights_fails() {
2804        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2805            "schema": ENV_MANIFEST_SCHEMA_V1,
2806            "environment": {"id": "local"},
2807            "bundles": [{
2808                "bundle_id": "mixed",
2809                "revisions": [
2810                    {"name": "a", "bundle_path": "a.gtbundle", "weight_bps": 5000},
2811                    {"name": "b", "bundle_path": "b.gtbundle"}
2812                ]
2813            }]
2814        }))
2815        .unwrap();
2816        let err = manifest.validate_shape().unwrap_err();
2817        assert!(err.to_string().contains("mixing set and unset"), "{err}");
2818    }
2819
2820    #[test]
2821    fn weight_percent_and_bps_on_same_revision_fails() {
2822        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2823            "schema": ENV_MANIFEST_SCHEMA_V1,
2824            "environment": {"id": "local"},
2825            "bundles": [{
2826                "bundle_id": "clash",
2827                "revisions": [
2828                    {"name": "a", "bundle_path": "a.gtbundle",
2829                     "weight_percent": 50, "weight_bps": 5000},
2830                    {"name": "b", "bundle_path": "b.gtbundle",
2831                     "weight_percent": 50, "weight_bps": 5000}
2832                ]
2833            }]
2834        }))
2835        .unwrap();
2836        let err = manifest.validate_shape().unwrap_err();
2837        assert!(err.to_string().contains("mutually exclusive"), "{err}");
2838    }
2839
2840    #[test]
2841    fn empty_revisions_array_fails() {
2842        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2843            "schema": ENV_MANIFEST_SCHEMA_V1,
2844            "environment": {"id": "local"},
2845            "bundles": [{
2846                "bundle_id": "empty-revs",
2847                "revisions": []
2848            }]
2849        }))
2850        .unwrap();
2851        let err = manifest.validate_shape().unwrap_err();
2852        assert!(err.to_string().contains("must not be empty"), "{err}");
2853    }
2854
2855    #[test]
2856    fn answers_to_manifest_stays_single_revision() {
2857        // The wizard always produces the single-revision form.
2858        let set = answers(serde_json::json!({
2859            "environment_id": "local",
2860            "trust_root_bootstrap": false,
2861            "bundles": [{
2862                "bundle_id": "b",
2863                "bundle_path": "b.gtbundle"
2864            }]
2865        }));
2866        let manifest = answers_to_manifest(&set).expect("converts");
2867        manifest.validate_shape().expect("valid shape");
2868        assert!(manifest.bundles[0].bundle_path.is_some());
2869        assert!(manifest.bundles[0].revisions.is_none());
2870    }
2871
2872    #[test]
2873    fn single_revision_equal_split_is_full_traffic() {
2874        let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2875            "schema": ENV_MANIFEST_SCHEMA_V1,
2876            "environment": {"id": "local"},
2877            "bundles": [{
2878                "bundle_id": "solo",
2879                "revisions": [
2880                    {"name": "only", "bundle_path": "only.gtbundle"}
2881                ]
2882            }]
2883        }))
2884        .unwrap();
2885        manifest.validate_shape().expect("valid");
2886        let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2887        let weights = compute_effective_weights_bps(revs);
2888        assert_eq!(weights, vec![FULL_TRAFFIC_BPS]);
2889    }
2890
2891    // --- G2/G3: revenue_share + status shape ---
2892
2893    fn bundle_with(extra: serde_json::Value) -> EnvManifest {
2894        let mut bundle = serde_json::json!({
2895            "bundle_id": "b",
2896            "bundle_path": "b.gtbundle"
2897        });
2898        bundle
2899            .as_object_mut()
2900            .unwrap()
2901            .extend(extra.as_object().unwrap().clone());
2902        serde_json::from_value(serde_json::json!({
2903            "schema": ENV_MANIFEST_SCHEMA_V1,
2904            "environment": {"id": "local"},
2905            "bundles": [bundle]
2906        }))
2907        .expect("deserialize")
2908    }
2909
2910    #[test]
2911    fn revenue_share_valid_sum_passes() {
2912        let manifest = bundle_with(serde_json::json!({
2913            "revenue_share": [
2914                {"party_id": "greentic", "basis_points": 8000},
2915                {"party_id": "partner", "basis_points": 2000}
2916            ]
2917        }));
2918        manifest.validate_shape().expect("valid 10000 sum");
2919        assert_eq!(manifest.bundles[0].revenue_share.as_ref().unwrap().len(), 2);
2920    }
2921
2922    #[test]
2923    fn revenue_share_wrong_sum_fails() {
2924        let manifest = bundle_with(serde_json::json!({
2925            "revenue_share": [
2926                {"party_id": "greentic", "basis_points": 8000},
2927                {"party_id": "partner", "basis_points": 1000}
2928            ]
2929        }));
2930        let err = manifest.validate_shape().unwrap_err();
2931        assert!(err.to_string().contains("9000"), "{err}");
2932        assert!(err.to_string().contains("10000"), "{err}");
2933    }
2934
2935    #[test]
2936    fn revenue_share_empty_fails() {
2937        let manifest = bundle_with(serde_json::json!({ "revenue_share": [] }));
2938        let err = manifest.validate_shape().unwrap_err();
2939        assert!(err.to_string().contains("must not be empty"), "{err}");
2940    }
2941
2942    #[test]
2943    fn revenue_share_duplicate_party_fails() {
2944        let manifest = bundle_with(serde_json::json!({
2945            "revenue_share": [
2946                {"party_id": "greentic", "basis_points": 5000},
2947                {"party_id": "greentic", "basis_points": 5000}
2948            ]
2949        }));
2950        let err = manifest.validate_shape().unwrap_err();
2951        assert!(
2952            err.to_string().contains("duplicate revenue_share party_id"),
2953            "{err}"
2954        );
2955    }
2956
2957    #[test]
2958    fn status_deserializes_lowercase() {
2959        for (text, want) in [
2960            ("active", BundleDeploymentStatus::Active),
2961            ("paused", BundleDeploymentStatus::Paused),
2962            ("archived", BundleDeploymentStatus::Archived),
2963        ] {
2964            let manifest = bundle_with(serde_json::json!({ "status": text }));
2965            manifest.validate_shape().expect("valid status");
2966            assert_eq!(manifest.bundles[0].status, Some(want), "status `{text}`");
2967        }
2968    }
2969
2970    #[test]
2971    fn unknown_status_rejected_at_parse() {
2972        let err = serde_json::from_value::<EnvManifest>(serde_json::json!({
2973            "schema": ENV_MANIFEST_SCHEMA_V1,
2974            "environment": {"id": "local"},
2975            "bundles": [{"bundle_id": "b", "bundle_path": "b.gtbundle", "status": "running"}]
2976        }))
2977        .unwrap_err();
2978        assert!(
2979            err.to_string().contains("status") || err.to_string().contains("variant"),
2980            "{err}"
2981        );
2982    }
2983}