Skip to main content

weft_core/api/
generations.rs

1use crate::api::openai_compat::AppState;
2use crate::app::{
3    binding_set_id_from_lock_bindings, closure_digest_from_lock_packages,
4    closure_id_from_lock_packages, scene_digest, AppGenerationSummaryMetadata,
5};
6use axum::extract::{Path, State};
7use axum::http::StatusCode;
8use axum::Json;
9use serde::Serialize;
10use sha2::{Digest, Sha512};
11use std::collections::{BTreeMap, BTreeSet};
12use std::io::{ErrorKind, Write};
13use std::path::{Path as FsPath, PathBuf};
14
15const WEFT_CLAW_REQUIRED_REAL_PACKAGES: &[&str] = &[
16    "prompt-system",
17    "workflow-orchestrator",
18    "tool-runtime-core",
19    "tool-shell",
20    "tool-files",
21    "tool-web",
22    "tool-git",
23];
24
25#[derive(Debug, Clone, Serialize)]
26struct GenerationPointerSnapshot {
27    path: String,
28    generation_id: Option<u64>,
29    read_error: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize)]
33struct GenerationIndexSnapshot {
34    path: String,
35    present: bool,
36    index: Option<crate::app::AppGenerationIndex>,
37    read_error: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize)]
41struct InstanceGenerationDiagnostics {
42    instance_dir: String,
43    active_pointer: GenerationPointerSnapshot,
44    previous_pointer: GenerationPointerSnapshot,
45    generation_index: GenerationIndexSnapshot,
46    consistency_report: Option<crate::app::GenerationIndexConsistencyReport>,
47}
48
49#[derive(Debug, Clone, Serialize)]
50struct GenerationDiagnosticsResponse {
51    app: String,
52    store_present: bool,
53    store_summary: crate::app::AppGenerationIndex,
54    instance: Option<InstanceGenerationDiagnostics>,
55}
56
57#[derive(Debug, Clone, Serialize)]
58struct ScalarDiff<T>
59where
60    T: Serialize,
61{
62    from: T,
63    to: T,
64    changed: bool,
65}
66
67#[derive(Debug, Clone, Serialize)]
68struct CollectionDiff<T>
69where
70    T: Serialize,
71{
72    from: Vec<T>,
73    to: Vec<T>,
74    added: Vec<T>,
75    removed: Vec<T>,
76    changed: bool,
77}
78
79#[derive(Debug, Clone, Serialize)]
80struct BindingChange<T>
81where
82    T: Serialize,
83{
84    capability: String,
85    from: T,
86    to: T,
87}
88
89#[derive(Debug, Clone, Serialize)]
90struct BindingCollectionDiff<T>
91where
92    T: Serialize,
93{
94    from: Vec<T>,
95    to: Vec<T>,
96    added: Vec<T>,
97    removed: Vec<T>,
98    changed_entries: Vec<BindingChange<T>>,
99    changed: bool,
100}
101
102#[derive(Debug, Clone, Serialize)]
103struct GenerationSummarySnapshot {
104    id: u64,
105    status: crate::app::GenerationStatus,
106    scene: String,
107    profile: String,
108    binding_set_id: String,
109    closure_id: String,
110    enabled_features: Vec<String>,
111    capabilities: Vec<String>,
112    bindings: Vec<crate::app::AppBindingResolution>,
113}
114
115#[derive(Debug, Clone, Serialize)]
116struct GenerationSummaryDiff {
117    id: ScalarDiff<u64>,
118    status: ScalarDiff<crate::app::GenerationStatus>,
119    scene: ScalarDiff<String>,
120    profile: ScalarDiff<String>,
121    binding_set_id: ScalarDiff<String>,
122    closure_id: ScalarDiff<String>,
123    enabled_features: CollectionDiff<String>,
124    capabilities: CollectionDiff<String>,
125    bindings: BindingCollectionDiff<crate::app::AppBindingResolution>,
126}
127
128#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
129struct LockBindingSnapshot {
130    capability: String,
131    provider: String,
132}
133
134#[derive(Debug, Clone, Serialize)]
135struct ImmutableLockDiff {
136    scene: ScalarDiff<String>,
137    profile: ScalarDiff<String>,
138    closure_id: ScalarDiff<String>,
139    packages: CollectionDiff<String>,
140    bindings: BindingCollectionDiff<LockBindingSnapshot>,
141}
142
143#[derive(Debug, Clone, Serialize)]
144struct ImmutableLockComparison {
145    available: bool,
146    from_path: Option<String>,
147    to_path: Option<String>,
148    diff: Option<ImmutableLockDiff>,
149}
150
151#[derive(Debug, Clone, Serialize)]
152struct GenerationDiffWarning {
153    warning_type: String,
154    generation: u64,
155    path: Option<String>,
156    message: String,
157}
158
159#[derive(Debug, Clone, Serialize)]
160struct GenerationDiffResponse {
161    app: String,
162    from: GenerationSummarySnapshot,
163    to: GenerationSummarySnapshot,
164    summary_diff: GenerationSummaryDiff,
165    immutable_lock: ImmutableLockComparison,
166    warnings: Vec<GenerationDiffWarning>,
167}
168
169fn resolved_app_instance_dir(app: &crate::app::ResolvedApp) -> Option<PathBuf> {
170    app.sources
171        .lock_path
172        .as_deref()
173        .and_then(|path| FsPath::new(path).parent().map(FsPath::to_path_buf))
174        .or_else(|| {
175            app.sources
176                .config_path
177                .as_deref()
178                .and_then(|path| FsPath::new(path).parent().map(FsPath::to_path_buf))
179        })
180}
181
182fn scalar_diff<T>(from: T, to: T) -> ScalarDiff<T>
183where
184    T: Serialize + PartialEq,
185{
186    let changed = from != to;
187    ScalarDiff { from, to, changed }
188}
189
190fn sorted_strings(values: &[String]) -> Vec<String> {
191    let mut values = values.to_vec();
192    values.sort();
193    values.dedup();
194    values
195}
196
197fn collection_diff(values_from: &[String], values_to: &[String]) -> CollectionDiff<String> {
198    let from = sorted_strings(values_from);
199    let to = sorted_strings(values_to);
200    let from_set = from.iter().cloned().collect::<BTreeSet<_>>();
201    let to_set = to.iter().cloned().collect::<BTreeSet<_>>();
202    let added = to_set.difference(&from_set).cloned().collect::<Vec<_>>();
203    let removed = from_set.difference(&to_set).cloned().collect::<Vec<_>>();
204    let changed = !added.is_empty() || !removed.is_empty();
205
206    CollectionDiff {
207        from,
208        to,
209        added,
210        removed,
211        changed,
212    }
213}
214
215fn normalized_bindings(
216    bindings: &[crate::app::AppBindingResolution],
217) -> Vec<crate::app::AppBindingResolution> {
218    let mut bindings = bindings.to_vec();
219    bindings.sort_by(|left, right| {
220        left.capability
221            .cmp(&right.capability)
222            .then(left.provider.cmp(&right.provider))
223            .then(left.mutable.cmp(&right.mutable))
224            .then(left.source.cmp(&right.source))
225    });
226    bindings
227}
228
229fn binding_collection_diff(
230    from_bindings: &[crate::app::AppBindingResolution],
231    to_bindings: &[crate::app::AppBindingResolution],
232) -> BindingCollectionDiff<crate::app::AppBindingResolution> {
233    let from = normalized_bindings(from_bindings);
234    let to = normalized_bindings(to_bindings);
235
236    let from_by_capability = from
237        .iter()
238        .cloned()
239        .map(|binding| (binding.capability.clone(), binding))
240        .collect::<BTreeMap<_, _>>();
241    let to_by_capability = to
242        .iter()
243        .cloned()
244        .map(|binding| (binding.capability.clone(), binding))
245        .collect::<BTreeMap<_, _>>();
246
247    let added = to_by_capability
248        .iter()
249        .filter(|(capability, _)| !from_by_capability.contains_key(*capability))
250        .map(|(_, binding)| binding.clone())
251        .collect::<Vec<_>>();
252    let removed = from_by_capability
253        .iter()
254        .filter(|(capability, _)| !to_by_capability.contains_key(*capability))
255        .map(|(_, binding)| binding.clone())
256        .collect::<Vec<_>>();
257    let changed_entries = from_by_capability
258        .iter()
259        .filter_map(|(capability, from_binding)| {
260            let to_binding = to_by_capability.get(capability)?;
261            if from_binding == to_binding {
262                None
263            } else {
264                Some(BindingChange {
265                    capability: capability.clone(),
266                    from: from_binding.clone(),
267                    to: to_binding.clone(),
268                })
269            }
270        })
271        .collect::<Vec<_>>();
272    let changed = !added.is_empty() || !removed.is_empty() || !changed_entries.is_empty();
273
274    BindingCollectionDiff {
275        from,
276        to,
277        added,
278        removed,
279        changed_entries,
280        changed,
281    }
282}
283
284fn generation_summary_snapshot(
285    generation: &crate::app::AppGeneration,
286) -> GenerationSummarySnapshot {
287    GenerationSummarySnapshot {
288        id: generation.id,
289        status: generation.status,
290        scene: generation.scene.clone(),
291        profile: generation.profile.clone(),
292        binding_set_id: generation.binding_set_id.clone(),
293        closure_id: generation.closure_id.clone(),
294        enabled_features: sorted_strings(&generation.enabled_features),
295        capabilities: sorted_strings(&generation.capabilities),
296        bindings: normalized_bindings(&generation.bindings),
297    }
298}
299
300fn generation_summary_diff(
301    from: &crate::app::AppGeneration,
302    to: &crate::app::AppGeneration,
303) -> GenerationSummaryDiff {
304    GenerationSummaryDiff {
305        id: scalar_diff(from.id, to.id),
306        status: scalar_diff(from.status, to.status),
307        scene: scalar_diff(from.scene.clone(), to.scene.clone()),
308        profile: scalar_diff(from.profile.clone(), to.profile.clone()),
309        binding_set_id: scalar_diff(from.binding_set_id.clone(), to.binding_set_id.clone()),
310        closure_id: scalar_diff(from.closure_id.clone(), to.closure_id.clone()),
311        enabled_features: collection_diff(&from.enabled_features, &to.enabled_features),
312        capabilities: collection_diff(&from.capabilities, &to.capabilities),
313        bindings: binding_collection_diff(&from.bindings, &to.bindings),
314    }
315}
316
317fn normalized_lock_bindings(bindings: &[crate::app::AppLockBinding]) -> Vec<LockBindingSnapshot> {
318    let mut bindings = bindings
319        .iter()
320        .map(|binding| LockBindingSnapshot {
321            capability: binding.capability.clone(),
322            provider: binding.provider.clone(),
323        })
324        .collect::<Vec<_>>();
325    bindings.sort_by(|left, right| {
326        left.capability
327            .cmp(&right.capability)
328            .then(left.provider.cmp(&right.provider))
329    });
330    bindings
331}
332
333fn lock_binding_collection_diff(
334    from_bindings: &[crate::app::AppLockBinding],
335    to_bindings: &[crate::app::AppLockBinding],
336) -> BindingCollectionDiff<LockBindingSnapshot> {
337    let from = normalized_lock_bindings(from_bindings);
338    let to = normalized_lock_bindings(to_bindings);
339
340    let from_by_capability = from
341        .iter()
342        .cloned()
343        .map(|binding| (binding.capability.clone(), binding))
344        .collect::<BTreeMap<_, _>>();
345    let to_by_capability = to
346        .iter()
347        .cloned()
348        .map(|binding| (binding.capability.clone(), binding))
349        .collect::<BTreeMap<_, _>>();
350
351    let added = to_by_capability
352        .iter()
353        .filter(|(capability, _)| !from_by_capability.contains_key(*capability))
354        .map(|(_, binding)| binding.clone())
355        .collect::<Vec<_>>();
356    let removed = from_by_capability
357        .iter()
358        .filter(|(capability, _)| !to_by_capability.contains_key(*capability))
359        .map(|(_, binding)| binding.clone())
360        .collect::<Vec<_>>();
361    let changed_entries = from_by_capability
362        .iter()
363        .filter_map(|(capability, from_binding)| {
364            let to_binding = to_by_capability.get(capability)?;
365            if from_binding == to_binding {
366                None
367            } else {
368                Some(BindingChange {
369                    capability: capability.clone(),
370                    from: from_binding.clone(),
371                    to: to_binding.clone(),
372                })
373            }
374        })
375        .collect::<Vec<_>>();
376    let changed = !added.is_empty() || !removed.is_empty() || !changed_entries.is_empty();
377
378    BindingCollectionDiff {
379        from,
380        to,
381        added,
382        removed,
383        changed_entries,
384        changed,
385    }
386}
387
388fn immutable_lock_diff(
389    from: &crate::app::InstanceLock,
390    to: &crate::app::InstanceLock,
391) -> ImmutableLockDiff {
392    let from_packages = from
393        .packages
394        .iter()
395        .map(crate::app::AppLockPackage::identity)
396        .collect::<Vec<_>>();
397    let to_packages = to
398        .packages
399        .iter()
400        .map(crate::app::AppLockPackage::identity)
401        .collect::<Vec<_>>();
402
403    ImmutableLockDiff {
404        scene: scalar_diff(from.scene.clone(), to.scene.clone()),
405        profile: scalar_diff(from.profile.clone(), to.profile.clone()),
406        closure_id: scalar_diff(from.closure_id.clone(), to.closure_id.clone()),
407        packages: collection_diff(&from_packages, &to_packages),
408        bindings: lock_binding_collection_diff(&from.bindings, &to.bindings),
409    }
410}
411
412fn generation_diff_not_found(
413    app_name: &str,
414    generation_id: u64,
415    role: &str,
416) -> (StatusCode, Json<serde_json::Value>) {
417    (
418        StatusCode::NOT_FOUND,
419        Json(serde_json::json!({
420            "error": format!(
421                "Generation {} not found for app '{}'",
422                generation_id, app_name
423            ),
424            "reason": "generation_not_found",
425            "app": app_name,
426            "generation": generation_id,
427            "role": role,
428        })),
429    )
430}
431
432fn load_generation_lock_for_diff(
433    generation: &crate::app::AppGeneration,
434    instance_dir: Option<&FsPath>,
435) -> Result<GenerationLockDiffData, anyhow::Error> {
436    let Some(instance_dir) = instance_dir else {
437        return Ok((
438            None,
439            None,
440            Some(GenerationDiffWarning {
441                warning_type: "instance_dir_unavailable".into(),
442                generation: generation.id,
443                path: None,
444                message: format!(
445                    "Resolved instance directory is unavailable for generation {}; immutable lock diff was skipped",
446                    generation.id
447                ),
448            }),
449        ));
450    };
451
452    if generation.lock_path.trim().is_empty() {
453        return Ok((
454            None,
455            None,
456            Some(GenerationDiffWarning {
457                warning_type: "missing_lock_path_metadata".into(),
458                generation: generation.id,
459                path: None,
460                message: format!(
461                    "Generation {} is missing immutable lock_path metadata; immutable lock diff was skipped",
462                    generation.id
463                ),
464            }),
465        ));
466    }
467
468    let lock_path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
469    let lock_path_display = lock_path.display().to_string();
470    match crate::app::load_instance_lock_from_path(&lock_path) {
471        Ok(lock) => Ok((Some(lock), Some(lock_path_display), None)),
472        Err(error) if is_not_found_error(&error) => Ok((
473            None,
474            Some(lock_path_display.clone()),
475            Some(GenerationDiffWarning {
476                warning_type: "immutable_lock_missing".into(),
477                generation: generation.id,
478                path: Some(lock_path_display),
479                message: format!(
480                    "Immutable generation lock for generation {} is missing; returning summary diff only",
481                    generation.id
482                ),
483            }),
484        )),
485        Err(error) => Err(error),
486    }
487}
488
489type GenerationLockDiffData = (
490    Option<crate::app::InstanceLock>,
491    Option<String>,
492    Option<GenerationDiffWarning>,
493);
494
495fn persist_generation_index_best_effort(
496    app_name: &str,
497    app_store: &crate::app::AppGenerationStore,
498    instance_dir: &FsPath,
499) -> (bool, Option<String>) {
500    let index_path = crate::app::generation_index_path(instance_dir);
501    let index = crate::app::AppGenerationIndex::from_store(app_store);
502    match crate::app::save_generation_index(instance_dir, &index) {
503        Ok(()) => (true, None),
504        Err(error) => {
505            let message = format!(
506                "Failed to persist repairable generation index for '{}' at '{}': {error:#}",
507                app_name,
508                index_path.display()
509            );
510            tracing::warn!("{}", message);
511            (false, Some(message))
512        }
513    }
514}
515
516fn write_generation_pointers(
517    app_name: &str,
518    instance_dir: &FsPath,
519    previous_generation_id: Option<u64>,
520    active_generation_id: u64,
521    generation_lock_written: bool,
522    failed_status: &str,
523    operation_label: &str,
524) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
525    if let Err(error) =
526        crate::app::write_previous_generation_pointer(instance_dir, previous_generation_id)
527    {
528        let message = format!(
529            "Failed to write previous generation pointer for '{}' at '{}': {error:#}",
530            app_name,
531            crate::app::previous_generation_pointer_path(instance_dir).display()
532        );
533        tracing::warn!("{}", message);
534        return Err((
535            StatusCode::INTERNAL_SERVER_ERROR,
536            Json(serde_json::json!({
537                "error": message,
538                "reason": "pointer_write_failed",
539                "status": failed_status,
540                "generation_lock_written": generation_lock_written,
541                "operation": operation_label,
542                "lock_written": true,
543                "pointer_written": false,
544                "pointer_error": {
545                    "stage": "previous",
546                    "repair_needed": false,
547                    "message": format!(
548                        "Previous generation pointer write failed before active pointer update for '{}' during {}",
549                        app_name,
550                        operation_label
551                    ),
552                    "details": format!("{error:#}"),
553                },
554                "index_written": false,
555                "index_error": serde_json::Value::Null,
556            })),
557        ));
558    }
559
560    if let Err(error) =
561        crate::app::write_active_generation_pointer(instance_dir, Some(active_generation_id))
562    {
563        let message = format!(
564            "Failed to write active generation pointer for '{}' at '{}': {error:#}",
565            app_name,
566            crate::app::active_generation_pointer_path(instance_dir).display()
567        );
568        tracing::warn!("{}", message);
569        return Err((
570            StatusCode::INTERNAL_SERVER_ERROR,
571            Json(serde_json::json!({
572                "error": message,
573                "reason": "pointer_write_failed",
574                "status": failed_status,
575                "generation_lock_written": generation_lock_written,
576                "operation": operation_label,
577                "lock_written": true,
578                "pointer_written": false,
579                "pointer_error": {
580                    "stage": "active",
581                    "repair_needed": true,
582                    "message": format!(
583                        "Active generation pointer write failed after previous pointer update for '{}' during {}; repair is required before relying on pointer files",
584                        app_name,
585                        operation_label
586                    ),
587                    "details": format!("{error:#}"),
588                },
589                "index_written": false,
590                "index_error": serde_json::Value::Null,
591            })),
592        ));
593    }
594
595    Ok(())
596}
597
598fn generation_pointer_snapshot(path: PathBuf) -> GenerationPointerSnapshot {
599    match crate::app::read_generation_pointer(&path) {
600        Ok(generation_id) => GenerationPointerSnapshot {
601            path: path.display().to_string(),
602            generation_id,
603            read_error: None,
604        },
605        Err(error) => GenerationPointerSnapshot {
606            path: path.display().to_string(),
607            generation_id: None,
608            read_error: Some(format!("{error:#}")),
609        },
610    }
611}
612
613fn generation_index_snapshot(instance_dir: &FsPath) -> GenerationIndexSnapshot {
614    let path = crate::app::generation_index_path(instance_dir);
615    match crate::app::load_generation_index_from_path(&path) {
616        Ok(index) => GenerationIndexSnapshot {
617            path: path.display().to_string(),
618            present: path.exists(),
619            index,
620            read_error: None,
621        },
622        Err(error) => GenerationIndexSnapshot {
623            path: path.display().to_string(),
624            present: path.exists(),
625            index: None,
626            read_error: Some(format!("{error:#}")),
627        },
628    }
629}
630
631fn instance_generation_diagnostics(instance_dir: PathBuf) -> InstanceGenerationDiagnostics {
632    let active_pointer =
633        generation_pointer_snapshot(crate::app::active_generation_pointer_path(&instance_dir));
634    let previous_pointer =
635        generation_pointer_snapshot(crate::app::previous_generation_pointer_path(&instance_dir));
636    let generation_index = generation_index_snapshot(&instance_dir);
637    let consistency_report =
638        generation_index.index.as_ref().and_then(|index| {
639            if active_pointer.read_error.is_none() && previous_pointer.read_error.is_none() {
640                Some(index.consistency_report(
641                    active_pointer.generation_id,
642                    previous_pointer.generation_id,
643                ))
644            } else {
645                None
646            }
647        });
648
649    InstanceGenerationDiagnostics {
650        instance_dir: instance_dir.display().to_string(),
651        active_pointer,
652        previous_pointer,
653        generation_index,
654        consistency_report,
655    }
656}
657
658async fn generation_diagnostics_snapshot(
659    state: &AppState,
660    name: &str,
661) -> Result<GenerationDiagnosticsResponse, (StatusCode, Json<serde_json::Value>)> {
662    let app = {
663        let apps = state.resolved_apps.read().await;
664        apps.get(name).cloned().ok_or_else(|| {
665            (
666                StatusCode::NOT_FOUND,
667                Json(serde_json::json!({"error": format!("App '{}' not found", name)})),
668            )
669        })?
670    };
671
672    let (store_present, store_summary) = {
673        let store = state.generation_store.read().await;
674        match store.get(name) {
675            Some(app_store) => (true, crate::app::AppGenerationIndex::from_store(app_store)),
676            None => (false, crate::app::AppGenerationIndex::default()),
677        }
678    };
679
680    Ok(GenerationDiagnosticsResponse {
681        app: name.to_string(),
682        store_present,
683        store_summary,
684        instance: resolved_app_instance_dir(&app).map(instance_generation_diagnostics),
685    })
686}
687
688fn requires_real_package_source(package_name: &str) -> bool {
689    WEFT_CLAW_REQUIRED_REAL_PACKAGES.contains(&package_name)
690}
691
692fn package_matches_required_real_source(pkg: &crate::app::PackageSource) -> bool {
693    !requires_real_package_source(&pkg.name) || pkg.current_source.starts_with("packages/official/")
694}
695
696fn generation_declares_real_package(
697    state: &AppState,
698    generation: &crate::app::AppGeneration,
699    provider: &str,
700) -> bool {
701    let Some(pkg) = state.package_index.get(provider) else {
702        return provider == "core";
703    };
704
705    if !requires_real_package_source(&pkg.name) {
706        return true;
707    }
708
709    if !package_matches_required_real_source(pkg) {
710        return false;
711    }
712
713    generation.bindings.iter().any(|binding| {
714        binding.provider == provider
715            || state
716                .package_index
717                .get(&binding.provider)
718                .map(|binding_pkg| binding_pkg.name == pkg.name)
719                .unwrap_or(false)
720    })
721}
722
723fn candidate_wasm_package_dirs(state: &AppState, provider: &str) -> Vec<std::path::PathBuf> {
724    crate::package::discover_runtime_package(&state.repo_root, &state.package_index, provider)
725        .map(|package| vec![package.dir])
726        .unwrap_or_default()
727}
728
729fn build_wasm_package_artifact(package_dir: &FsPath) -> Result<(), String> {
730    let manifest_path = package_dir.join("Cargo.toml");
731    if !manifest_path.exists() {
732        return Err(format!(
733            "No Cargo.toml found for wasm provider source at '{}'",
734            package_dir.display()
735        ));
736    }
737
738    let output = std::process::Command::new("cargo")
739        .arg("build")
740        .arg("--target")
741        .arg("wasm32-wasip1")
742        .current_dir(package_dir)
743        .output();
744
745    match output {
746        Ok(result) if result.status.success() => Ok(()),
747        Ok(result) => Err(format!(
748            "cargo build failed for '{}': {}",
749            package_dir.display(),
750            String::from_utf8_lossy(&result.stderr)
751        )),
752        Err(error) => Err(format!(
753            "Failed to spawn cargo build for '{}': {}",
754            package_dir.display(),
755            error
756        )),
757    }
758}
759
760fn locate_built_wasm(package_dir: &FsPath) -> Option<std::path::PathBuf> {
761    let target_dir = package_dir
762        .join("target")
763        .join("wasm32-wasip1")
764        .join("debug");
765    if !target_dir.exists() {
766        return None;
767    }
768    let entries = std::fs::read_dir(target_dir).ok()?;
769    let mut wasm_files: Vec<_> = entries
770        .filter_map(|entry| entry.ok())
771        .map(|entry| entry.path())
772        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("wasm"))
773        .collect();
774    wasm_files.sort();
775    wasm_files.into_iter().next()
776}
777
778async fn probe_wasm_provider_via_temp_load(
779    state: &AppState,
780    provider: &str,
781    capability: &str,
782    app_name: &str,
783) -> crate::app::ValidationResult {
784    let package_dirs = candidate_wasm_package_dirs(state, provider);
785    if package_dirs.is_empty() {
786        return crate::app::ValidationResult {
787            check: format!("probe:{}", capability),
788            passed: true,
789            message: "Live probe skipped for wasm runtime because provider directory could not be located".into(),
790        };
791    }
792
793    let handle_guard = state.wasm_handle.write().await;
794    let Some(handle) = handle_guard.as_ref().cloned() else {
795        return crate::app::ValidationResult {
796            check: format!("probe:{}", capability),
797            passed: true,
798            message: "Live probe skipped for wasm runtime because no wasm runtime is active".into(),
799        };
800    };
801
802    let already_loaded = handle.has_package(provider);
803    let mut loaded_by_probe = false;
804
805    if !already_loaded {
806        let mut loaded = false;
807        let mut build_failures = Vec::new();
808        for package_dir in package_dirs {
809            let Ok(manifest) = crate::package::config::load_manifest(&package_dir) else {
810                continue;
811            };
812            let entry = manifest
813                .resolved_entry()
814                .map(|entry| package_dir.join(entry))
815                .filter(|path| path.exists())
816                .or_else(|| match build_wasm_package_artifact(&package_dir) {
817                    Ok(()) => locate_built_wasm(&package_dir),
818                    Err(error) => {
819                        build_failures.push(error);
820                        None
821                    }
822                });
823            let Some(wasm_path) = entry else {
824                continue;
825            };
826            let load_info = crate::package::bridge::PackageLoadInfo {
827                name: provider.to_string(),
828                dir: package_dir.clone(),
829                wasm_path,
830                startup_mode: crate::package::resolve_wasm_startup_mode(&manifest),
831                permissions: manifest.permissions.clone(),
832            };
833            if handle.load_package(&load_info).is_ok() {
834                loaded = true;
835                loaded_by_probe = true;
836                break;
837            }
838        }
839
840        if !loaded {
841            return crate::app::ValidationResult {
842                check: format!("probe:{}", capability),
843                passed: build_failures.is_empty(),
844                message: if build_failures.is_empty() {
845                    "Live probe skipped for wasm runtime because provider could not be temporarily loaded".into()
846                } else {
847                    format!(
848                        "Live probe failed because wasm provider could not be built or loaded: {}",
849                        build_failures.join(" | ")
850                    )
851                },
852            };
853        }
854    }
855
856    drop(handle_guard);
857
858    let probe_payload = serde_json::json!({
859        "action": "describe",
860        "data": {},
861        "app": app_name,
862        "provider": provider,
863    });
864    let result =
865        crate::api::capabilities::execute_capability_call(state, capability, probe_payload).await;
866
867    if loaded_by_probe {
868        let handle_guard = state.wasm_handle.read().await;
869        if let Some(handle) = handle_guard.as_ref() {
870            let _ = handle.unload_package(provider);
871        }
872    }
873
874    match result {
875        Ok(_) => crate::app::ValidationResult {
876            check: format!("probe:{}", capability),
877            passed: true,
878            message: "Provider responded to live wasm probe".into(),
879        },
880        Err((status, value)) => {
881            let body = value.to_string();
882            let skipped_unknown_action = status == StatusCode::BAD_GATEWAY
883                && body.to_lowercase().contains("unknown action")
884                && body.contains("describe");
885            crate::app::ValidationResult {
886                check: format!("probe:{}", capability),
887                passed: skipped_unknown_action,
888                message: if skipped_unknown_action {
889                    "Live probe skipped because provider does not implement generic describe action"
890                        .into()
891                } else {
892                    format!("Live probe failed with {}: {}", status, value)
893                },
894            }
895        }
896    }
897}
898
899pub fn package_digest(repo_root: &FsPath, source: &str) -> String {
900    let path = repo_root.join(source);
901    let mut hasher = Sha512::new();
902
903    fn update_dir(hasher: &mut Sha512, root: &FsPath, path: &FsPath) -> Result<(), std::io::Error> {
904        let mut entries = std::fs::read_dir(path)?
905            .filter_map(|e| e.ok())
906            .map(|e| e.path())
907            .collect::<Vec<_>>();
908        entries.sort();
909        for entry in entries {
910            let name = entry.file_name().and_then(|s| s.to_str()).unwrap_or("");
911            if matches!(name, "target" | ".git" | "node_modules" | ".sisyphus") {
912                continue;
913            }
914            let relative = entry
915                .strip_prefix(root)
916                .unwrap_or(&entry)
917                .to_string_lossy()
918                .replace('\\', "/");
919            if entry.is_dir() {
920                hasher.update(relative.as_bytes());
921                update_dir(hasher, root, &entry)?;
922            } else if entry.is_file() {
923                hasher.update(relative.as_bytes());
924                hasher.update(std::fs::read(&entry)?);
925            }
926        }
927        Ok(())
928    }
929
930    if path.is_file() {
931        match std::fs::read(&path) {
932            Ok(bytes) => hasher.update(bytes),
933            Err(_) => hasher.update(format!("missing:{}", source).as_bytes()),
934        }
935    } else if path.is_dir() {
936        if update_dir(&mut hasher, &path, &path).is_err() {
937            hasher.update(format!("unreadable:{}", source).as_bytes());
938        }
939    } else {
940        hasher.update(format!("missing:{}", source).as_bytes());
941    }
942
943    format!("{:x}", hasher.finalize())
944}
945
946fn packages_for_generation(
947    state: &AppState,
948    generation: &crate::app::AppGeneration,
949) -> Vec<crate::app::AppLockPackage> {
950    generation
951        .bindings
952        .iter()
953        .filter_map(|binding| {
954            state.package_index.get(&binding.provider).map(|pkg| {
955                let digest = package_digest(&state.repo_root, &pkg.current_source);
956                crate::app::AppLockPackage {
957                    name: pkg.name.clone(),
958                    version: "current".into(),
959                    runtime: pkg.kind.clone(),
960                    sha512: digest.clone(),
961                    source: pkg.current_source.clone(),
962                    trusted: pkg.trusted,
963                    signature: pkg.signature.clone(),
964                    source_authority: pkg.source_authority.clone(),
965                    source_public_keys: pkg.source_public_keys.clone(),
966                    package_kind: pkg.package_kind.clone(),
967                    entry_kind: pkg.kind.clone(),
968                    runtime_provider: pkg.runtime_provider_name(),
969                    provides: pkg.provides.clone(),
970                    requires: pkg.requires.clone(),
971                    capabilities: pkg.provides.clone(),
972                    manifest_digest: digest.clone(),
973                    artifact_digest: digest.clone(),
974                    artifact_set_id: String::new(),
975                    store_object_id: format!("sha512-{}-{}-current", digest, pkg.name),
976                    store_path: String::new(),
977                    closure_id: String::new(),
978                    features: vec![],
979                    default_enabled_features: vec![],
980                    roles: pkg
981                        .package_kind
982                        .split(',')
983                        .map(|role| role.trim().to_string())
984                        .filter(|role| !role.is_empty())
985                        .collect(),
986                    evidence: crate::app::AppLockEvidence {
987                        digest,
988                        signature: pkg.signature.clone(),
989                        source_authority: pkg.source_authority.clone(),
990                        source_public_keys: pkg.source_public_keys.clone(),
991                    },
992                    reasons: vec![],
993                }
994            })
995        })
996        .fold(Vec::<crate::app::AppLockPackage>::new(), |mut acc, pkg| {
997            if !acc
998                .iter()
999                .any(|existing| existing.identity() == pkg.identity())
1000            {
1001                acc.push(pkg);
1002            }
1003            acc
1004        })
1005}
1006
1007fn generation_lock_path(generation_id: u64) -> String {
1008    format!("generations/{generation_id}.lock.toml")
1009}
1010
1011fn resolved_generation_lock_path(instance_dir: &FsPath, lock_path: &str) -> PathBuf {
1012    let lock_path = FsPath::new(lock_path);
1013    if lock_path.is_absolute() {
1014        lock_path.to_path_buf()
1015    } else {
1016        instance_dir.join(lock_path)
1017    }
1018}
1019
1020#[derive(Debug)]
1021enum ImmutableGenerationLockError {
1022    MissingLockPath,
1023    Serialize(String),
1024    ReadExisting { path: String, details: String },
1025    CreateParent { path: String, details: String },
1026    WriteNew { path: String, details: String },
1027    Conflict { path: String },
1028}
1029
1030fn write_immutable_generation_lock(
1031    instance_dir: &FsPath,
1032    generation: &crate::app::AppGeneration,
1033    lock_file: &crate::app::AppLockFile,
1034) -> Result<(), ImmutableGenerationLockError> {
1035    if generation.lock_path.trim().is_empty() {
1036        return Err(ImmutableGenerationLockError::MissingLockPath);
1037    }
1038
1039    let path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
1040    let path_display = path.display().to_string();
1041    let content = toml::to_string_pretty(lock_file).map_err(|error| {
1042        ImmutableGenerationLockError::Serialize(format!(
1043            "Failed to serialize immutable generation lock for generation {}: {error}",
1044            generation.id
1045        ))
1046    })?;
1047
1048    if let Some(parent) = path.parent() {
1049        std::fs::create_dir_all(parent).map_err(|error| {
1050            ImmutableGenerationLockError::CreateParent {
1051                path: parent.display().to_string(),
1052                details: error.to_string(),
1053            }
1054        })?;
1055    }
1056
1057    match std::fs::OpenOptions::new()
1058        .write(true)
1059        .create_new(true)
1060        .open(&path)
1061    {
1062        Ok(mut file) => file.write_all(content.as_bytes()).map_err(|error| {
1063            ImmutableGenerationLockError::WriteNew {
1064                path: path_display,
1065                details: error.to_string(),
1066            }
1067        }),
1068        Err(error) if error.kind() == ErrorKind::AlreadyExists => {
1069            let existing = std::fs::read_to_string(&path).map_err(|read_error| {
1070                ImmutableGenerationLockError::ReadExisting {
1071                    path: path.display().to_string(),
1072                    details: read_error.to_string(),
1073                }
1074            })?;
1075
1076            if existing == content {
1077                Ok(())
1078            } else {
1079                Err(ImmutableGenerationLockError::Conflict {
1080                    path: path.display().to_string(),
1081                })
1082            }
1083        }
1084        Err(error) => Err(ImmutableGenerationLockError::WriteNew {
1085            path: path.display().to_string(),
1086            details: error.to_string(),
1087        }),
1088    }
1089}
1090
1091fn immutable_generation_lock_failure_response(
1092    app_name: &str,
1093    generation_id: u64,
1094    error: ImmutableGenerationLockError,
1095) -> (StatusCode, Json<serde_json::Value>) {
1096    match error {
1097        ImmutableGenerationLockError::MissingLockPath => (
1098            StatusCode::INTERNAL_SERVER_ERROR,
1099            Json(serde_json::json!({
1100                "error": format!(
1101                    "Generation {} is missing lock_path metadata required to persist immutable activation lock",
1102                    generation_id
1103                ),
1104                "reason": "generation_lock_write_failed",
1105                "status": "activation_failed",
1106                "lock_written": false,
1107                "pointer_written": false,
1108                "index_written": false,
1109                "generation_lock_error": {
1110                    "type": "missing_lock_path",
1111                    "message": format!(
1112                        "Generation {} for '{}' is missing lock_path metadata",
1113                        generation_id, app_name
1114                    ),
1115                }
1116            })),
1117        ),
1118        ImmutableGenerationLockError::Serialize(details) => (
1119            StatusCode::INTERNAL_SERVER_ERROR,
1120            Json(serde_json::json!({
1121                "error": format!(
1122                    "Failed to serialize immutable generation lock for '{}' generation {}",
1123                    app_name, generation_id
1124                ),
1125                "reason": "generation_lock_write_failed",
1126                "status": "activation_failed",
1127                "lock_written": false,
1128                "pointer_written": false,
1129                "index_written": false,
1130                "generation_lock_error": {
1131                    "type": "serialize_failed",
1132                    "message": format!(
1133                        "Immutable generation lock serialization failed for '{}' generation {}",
1134                        app_name, generation_id
1135                    ),
1136                    "details": details,
1137                }
1138            })),
1139        ),
1140        ImmutableGenerationLockError::ReadExisting { path, details } => (
1141            StatusCode::INTERNAL_SERVER_ERROR,
1142            Json(serde_json::json!({
1143                "error": format!(
1144                    "Failed to read existing immutable generation lock for '{}' at '{}'",
1145                    app_name, path
1146                ),
1147                "reason": "generation_lock_write_failed",
1148                "status": "activation_failed",
1149                "lock_written": false,
1150                "pointer_written": false,
1151                "index_written": false,
1152                "generation_lock_error": {
1153                    "type": "read_existing_failed",
1154                    "path": path,
1155                    "message": format!(
1156                        "Existing immutable generation lock for '{}' could not be read before activation",
1157                        app_name
1158                    ),
1159                    "details": details,
1160                }
1161            })),
1162        ),
1163        ImmutableGenerationLockError::CreateParent { path, details } => (
1164            StatusCode::INTERNAL_SERVER_ERROR,
1165            Json(serde_json::json!({
1166                "error": format!(
1167                    "Failed to prepare immutable generation lock directory for '{}' at '{}'",
1168                    app_name, path
1169                ),
1170                "reason": "generation_lock_write_failed",
1171                "status": "activation_failed",
1172                "lock_written": false,
1173                "pointer_written": false,
1174                "index_written": false,
1175                "generation_lock_error": {
1176                    "type": "prepare_parent_failed",
1177                    "path": path,
1178                    "message": format!(
1179                        "Immutable generation lock directory setup failed for '{}'",
1180                        app_name
1181                    ),
1182                    "details": details,
1183                }
1184            })),
1185        ),
1186        ImmutableGenerationLockError::WriteNew { path, details } => (
1187            StatusCode::INTERNAL_SERVER_ERROR,
1188            Json(serde_json::json!({
1189                "error": format!(
1190                    "Failed to write immutable generation lock for '{}' at '{}'",
1191                    app_name, path
1192                ),
1193                "reason": "generation_lock_write_failed",
1194                "status": "activation_failed",
1195                "lock_written": false,
1196                "pointer_written": false,
1197                "index_written": false,
1198                "generation_lock_error": {
1199                    "type": "write_failed",
1200                    "path": path,
1201                    "message": format!(
1202                        "Immutable generation lock write failed for '{}'",
1203                        app_name
1204                    ),
1205                    "details": details,
1206                }
1207            })),
1208        ),
1209        ImmutableGenerationLockError::Conflict { path } => (
1210            StatusCode::CONFLICT,
1211            Json(serde_json::json!({
1212                "error": format!(
1213                    "Immutable generation lock for '{}' generation {} already exists with different content at '{}'",
1214                    app_name, generation_id, path
1215                ),
1216                "reason": "generation_lock_conflict",
1217                "status": "activation_failed",
1218                "lock_written": false,
1219                "pointer_written": false,
1220                "index_written": false,
1221                "generation_lock_error": {
1222                    "type": "content_conflict",
1223                    "path": path,
1224                    "message": format!(
1225                        "Immutable generation lock content for '{}' generation {} differs from the activation lock payload",
1226                        app_name, generation_id
1227                    ),
1228                }
1229            })),
1230        ),
1231    }
1232}
1233
1234fn rollback_fallback_lock(
1235    existing_lock: crate::app::AppLockFile,
1236    generation: &crate::app::AppGeneration,
1237) -> crate::app::AppLockFile {
1238    crate::app::AppLockFile {
1239        generation: generation.id as u32,
1240        status: "active".into(),
1241        profile: generation.profile.clone(),
1242        scene: existing_lock.scene.clone(),
1243        scene_digest: existing_lock.scene_digest.clone(),
1244        binding_set_id: existing_lock.binding_set_id.clone(),
1245        closure_id: existing_lock.closure_id.clone(),
1246        closure_digest: existing_lock.closure_digest.clone(),
1247        assembly: crate::app::AppLockAssembly {
1248            enabled_features: generation.enabled_features.clone(),
1249            selected_packages: existing_lock.assembly.selected_packages.clone(),
1250            scene: existing_lock.assembly.scene.clone(),
1251            scene_digest: existing_lock.assembly.scene_digest.clone(),
1252            binding_set_id: existing_lock.assembly.binding_set_id.clone(),
1253            closure_id: existing_lock.assembly.closure_id.clone(),
1254            closure_digest: existing_lock.assembly.closure_digest.clone(),
1255        },
1256        bindings: generation
1257            .bindings
1258            .iter()
1259            .map(|binding| crate::app::AppLockBinding {
1260                capability: binding.capability.clone(),
1261                provider: binding.provider.clone(),
1262                package: binding.provider.clone(),
1263                mutable: binding.mutable,
1264                package_version: String::new(),
1265                package_sha512: String::new(),
1266                binding_source: binding.source.clone(),
1267            })
1268            .collect(),
1269        binding_sources: generation
1270            .bindings
1271            .iter()
1272            .map(|binding| crate::app::AppLockBindingSource {
1273                capability: binding.capability.clone(),
1274                source: binding.source.clone(),
1275                package: binding.provider.clone(),
1276            })
1277            .collect(),
1278        notes: crate::app::AppLockNotes {
1279            message: format!("Rolled back to generation {} via API", generation.id),
1280        },
1281        ..existing_lock
1282    }
1283}
1284
1285fn is_not_found_error(error: &anyhow::Error) -> bool {
1286    error.chain().any(|cause| {
1287        cause
1288            .downcast_ref::<std::io::Error>()
1289            .is_some_and(|io_error| io_error.kind() == ErrorKind::NotFound)
1290    })
1291}
1292
1293struct RollbackRootLockResolution {
1294    lock_file: crate::app::AppLockFile,
1295    generation_lock_replayed: bool,
1296    generation_lock_warning: Option<serde_json::Value>,
1297}
1298
1299fn rollback_generation_lock_failure_response(
1300    app_name: &str,
1301    generation: &crate::app::AppGeneration,
1302    error_type: &str,
1303    message: String,
1304    path: Option<String>,
1305    details: Option<String>,
1306) -> (StatusCode, Json<serde_json::Value>) {
1307    (
1308        StatusCode::CONFLICT,
1309        Json(serde_json::json!({
1310            "error": message,
1311            "reason": "generation_lock_replay_failed",
1312            "status": "rollback_failed",
1313            "lock_written": false,
1314            "pointer_written": false,
1315            "pointer_error": serde_json::Value::Null,
1316            "index_written": false,
1317            "index_error": serde_json::Value::Null,
1318            "generation_lock_replayed": false,
1319            "generation_lock_error": {
1320                "type": error_type,
1321                "app": app_name,
1322                "generation": generation.id,
1323                "path": path,
1324                "details": details,
1325            }
1326        })),
1327    )
1328}
1329
1330fn activation_generation_lock_failure_response(
1331    app_name: &str,
1332    generation: &crate::app::AppGeneration,
1333    error_type: &str,
1334    message: String,
1335    path: Option<String>,
1336    details: Option<String>,
1337) -> (StatusCode, Json<serde_json::Value>) {
1338    (
1339        StatusCode::CONFLICT,
1340        Json(serde_json::json!({
1341            "error": message,
1342            "reason": "generation_lock_replay_failed",
1343            "status": "activation_failed",
1344            "lock_written": false,
1345            "pointer_written": false,
1346            "pointer_error": serde_json::Value::Null,
1347            "index_written": false,
1348            "index_error": serde_json::Value::Null,
1349            "generation_lock_replayed": false,
1350            "generation_lock_error": {
1351                "type": error_type,
1352                "app": app_name,
1353                "generation": generation.id,
1354                "path": path,
1355                "details": details,
1356            }
1357        })),
1358    )
1359}
1360
1361fn resolve_rollback_root_lock(
1362    app_name: &str,
1363    generation: &crate::app::AppGeneration,
1364    instance_dir: Option<&FsPath>,
1365    existing_lock: crate::app::AppLockFile,
1366) -> Result<RollbackRootLockResolution, (StatusCode, Json<serde_json::Value>)> {
1367    let fallback_lock = rollback_fallback_lock(existing_lock, generation);
1368    let Some(instance_dir) = instance_dir else {
1369        return Ok(RollbackRootLockResolution {
1370            lock_file: fallback_lock,
1371            generation_lock_replayed: false,
1372            generation_lock_warning: None,
1373        });
1374    };
1375
1376    if generation.lock_path.trim().is_empty() {
1377        return Ok(RollbackRootLockResolution {
1378            lock_file: fallback_lock,
1379            generation_lock_replayed: false,
1380            generation_lock_warning: Some(serde_json::json!({
1381                "type": "missing_lock_path_metadata",
1382                "message": format!(
1383                    "Rollback generation {} for '{}' is missing immutable lock_path metadata; using compatibility fallback root lock rebuild",
1384                    generation.id, app_name
1385                ),
1386            })),
1387        });
1388    }
1389
1390    let immutable_lock_path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
1391    let immutable_lock_path_display = immutable_lock_path.display().to_string();
1392    match crate::app::load_instance_lock_from_path(&immutable_lock_path) {
1393        Ok(mut immutable_lock) => {
1394            let expected_app = if generation.app_name.trim().is_empty() {
1395                app_name
1396            } else {
1397                generation.app_name.as_str()
1398            };
1399
1400            if immutable_lock.generation != generation.id as u32 {
1401                return Err(rollback_generation_lock_failure_response(
1402                    app_name,
1403                    generation,
1404                    "generation_mismatch",
1405                    format!(
1406                        "Immutable rollback generation lock at '{}' belongs to generation {} instead of {}",
1407                        immutable_lock_path_display, immutable_lock.generation, generation.id
1408                    ),
1409                    Some(immutable_lock_path_display),
1410                    None,
1411                ));
1412            }
1413
1414            if immutable_lock.app != expected_app {
1415                return Err(rollback_generation_lock_failure_response(
1416                    app_name,
1417                    generation,
1418                    "app_mismatch",
1419                    format!(
1420                        "Immutable rollback generation lock at '{}' belongs to app '{}' instead of '{}'",
1421                        immutable_lock_path_display, immutable_lock.app, expected_app
1422                    ),
1423                    Some(immutable_lock_path_display),
1424                    None,
1425                ));
1426            }
1427
1428            immutable_lock.status = "active".into();
1429            Ok(RollbackRootLockResolution {
1430                lock_file: immutable_lock,
1431                generation_lock_replayed: true,
1432                generation_lock_warning: None,
1433            })
1434        }
1435        Err(error) if is_not_found_error(&error) => Ok(RollbackRootLockResolution {
1436            lock_file: fallback_lock,
1437            generation_lock_replayed: false,
1438            generation_lock_warning: Some(serde_json::json!({
1439                "type": "immutable_lock_missing",
1440                "path": immutable_lock_path_display,
1441                "message": format!(
1442                    "Immutable rollback generation lock for '{}' generation {} is missing; using compatibility fallback root lock rebuild",
1443                    app_name, generation.id
1444                ),
1445            })),
1446        }),
1447        Err(error) => Err(rollback_generation_lock_failure_response(
1448            app_name,
1449            generation,
1450            "load_failed",
1451            format!(
1452                "Failed to load immutable rollback generation lock for '{}' generation {} from '{}': {error:#}",
1453                app_name, generation.id, immutable_lock_path_display
1454            ),
1455            Some(immutable_lock_path_display),
1456            Some(format!("{error:#}")),
1457        )),
1458    }
1459}
1460
1461fn resolve_activation_root_lock(
1462    app_name: &str,
1463    generation: &crate::app::AppGeneration,
1464    instance_dir: Option<&FsPath>,
1465    existing_lock: crate::app::AppLockFile,
1466) -> Result<RollbackRootLockResolution, (StatusCode, Json<serde_json::Value>)> {
1467    let fallback_lock = rollback_fallback_lock(existing_lock, generation);
1468    let Some(instance_dir) = instance_dir else {
1469        return Ok(RollbackRootLockResolution {
1470            lock_file: fallback_lock,
1471            generation_lock_replayed: false,
1472            generation_lock_warning: None,
1473        });
1474    };
1475
1476    if generation.lock_path.trim().is_empty() {
1477        return Ok(RollbackRootLockResolution {
1478            lock_file: fallback_lock,
1479            generation_lock_replayed: false,
1480            generation_lock_warning: Some(serde_json::json!({
1481                "type": "missing_lock_path_metadata",
1482                "message": format!(
1483                    "Activation target generation {} for '{}' is missing immutable lock_path metadata; using compatibility fallback root lock rebuild",
1484                    generation.id, app_name
1485                ),
1486            })),
1487        });
1488    }
1489
1490    let immutable_lock_path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
1491    let immutable_lock_path_display = immutable_lock_path.display().to_string();
1492    match crate::app::load_instance_lock_from_path(&immutable_lock_path) {
1493        Ok(mut immutable_lock) => {
1494            let expected_app = if generation.app_name.trim().is_empty() {
1495                app_name
1496            } else {
1497                generation.app_name.as_str()
1498            };
1499
1500            if immutable_lock.generation != generation.id as u32 {
1501                return Err(activation_generation_lock_failure_response(
1502                    app_name,
1503                    generation,
1504                    "generation_mismatch",
1505                    format!(
1506                        "Immutable activation generation lock at '{}' belongs to generation {} instead of {}",
1507                        immutable_lock_path_display, immutable_lock.generation, generation.id
1508                    ),
1509                    Some(immutable_lock_path_display),
1510                    None,
1511                ));
1512            }
1513
1514            if immutable_lock.app != expected_app {
1515                return Err(activation_generation_lock_failure_response(
1516                    app_name,
1517                    generation,
1518                    "app_mismatch",
1519                    format!(
1520                        "Immutable activation generation lock at '{}' belongs to app '{}' instead of '{}'",
1521                        immutable_lock_path_display, immutable_lock.app, expected_app
1522                    ),
1523                    Some(immutable_lock_path_display),
1524                    None,
1525                ));
1526            }
1527
1528            immutable_lock.status = "active".into();
1529            Ok(RollbackRootLockResolution {
1530                lock_file: immutable_lock,
1531                generation_lock_replayed: true,
1532                generation_lock_warning: None,
1533            })
1534        }
1535        Err(error) if is_not_found_error(&error) => Ok(RollbackRootLockResolution {
1536            lock_file: fallback_lock,
1537            generation_lock_replayed: false,
1538            generation_lock_warning: Some(serde_json::json!({
1539                "type": "immutable_lock_missing",
1540                "path": immutable_lock_path_display,
1541                "message": format!(
1542                    "Immutable activation generation lock for '{}' generation {} is missing; using compatibility fallback root lock rebuild",
1543                    app_name, generation.id
1544                ),
1545            })),
1546        }),
1547        Err(error) => Err(activation_generation_lock_failure_response(
1548            app_name,
1549            generation,
1550            "load_failed",
1551            format!(
1552                "Failed to load immutable activation generation lock for '{}' generation {} from '{}': {error:#}",
1553                app_name, generation.id, immutable_lock_path_display
1554            ),
1555            Some(immutable_lock_path_display),
1556            Some(format!("{error:#}")),
1557        )),
1558    }
1559}
1560
1561fn active_scene_config(
1562    app_config: &crate::app::AppConfigFile,
1563) -> Option<&crate::app::AppSceneConfig> {
1564    let active_scene = app_config.active_scene.trim();
1565    if active_scene.is_empty() {
1566        return None;
1567    }
1568
1569    app_config.scenes.get(active_scene)
1570}
1571
1572fn overlay_scene_enabled_features(
1573    enabled_features: &[String],
1574    scene: Option<&crate::app::AppSceneConfig>,
1575) -> Vec<String> {
1576    let Some(scene) = scene else {
1577        return enabled_features.to_vec();
1578    };
1579
1580    let mut merged = enabled_features.iter().cloned().collect::<BTreeSet<_>>();
1581    for feature in &scene.enabled_features {
1582        merged.insert(feature.clone());
1583    }
1584    for feature in &scene.disabled_features {
1585        merged.remove(feature);
1586    }
1587
1588    merged.into_iter().collect()
1589}
1590
1591fn generation_scene_digest(app_config: &crate::app::AppConfigFile, scene_name: &str) -> String {
1592    if scene_name.trim().is_empty() {
1593        return String::new();
1594    }
1595
1596    app_config
1597        .scenes
1598        .values()
1599        .find(|scene| scene.name == scene_name)
1600        .or_else(|| app_config.scenes.get(scene_name))
1601        .map(scene_digest)
1602        .unwrap_or_default()
1603}
1604
1605#[derive(Debug, Clone, Serialize)]
1606struct ScenePinConflictDetail {
1607    conflict_type: &'static str,
1608    scene: String,
1609    capability: Option<String>,
1610    package: Option<String>,
1611    indexes: Vec<usize>,
1612    message: String,
1613}
1614
1615fn normalized_pin_field(value: &str) -> String {
1616    value.trim().to_string()
1617}
1618
1619fn non_empty_pin_field(value: &str) -> Option<String> {
1620    let trimmed = value.trim();
1621    if trimmed.is_empty() {
1622        None
1623    } else {
1624        Some(trimmed.to_string())
1625    }
1626}
1627
1628fn scene_pin_conflict_response(
1629    detail: ScenePinConflictDetail,
1630) -> (StatusCode, Json<serde_json::Value>) {
1631    let ScenePinConflictDetail {
1632        conflict_type,
1633        scene,
1634        capability,
1635        package,
1636        indexes,
1637        message,
1638    } = detail;
1639
1640    (
1641        StatusCode::UNPROCESSABLE_ENTITY,
1642        Json(serde_json::json!({
1643            "error": message,
1644            "reason": "scene_pin_conflict",
1645            "conflict": {
1646                "type": conflict_type,
1647                "scene": scene,
1648                "capability": capability,
1649                "package": package,
1650                "indexes": indexes,
1651            }
1652        })),
1653    )
1654}
1655
1656fn scene_package_pin_unresolved_response(
1657    scene: &str,
1658    package: &str,
1659    index: usize,
1660) -> (StatusCode, Json<serde_json::Value>) {
1661    (
1662        StatusCode::UNPROCESSABLE_ENTITY,
1663        Json(serde_json::json!({
1664            "error": format!(
1665                "Scene package pin for '{}' cannot be resolved from the local package index",
1666                package
1667            ),
1668            "reason": "scene_package_pin_unresolved",
1669            "scene": scene,
1670            "package": package,
1671            "index": index,
1672        })),
1673    )
1674}
1675
1676fn validate_scene_pin_conflicts(
1677    scene_name: &str,
1678    scene: &crate::app::AppSceneConfig,
1679) -> Result<(), Box<ScenePinConflictDetail>> {
1680    let resolved_scene_name = if scene.name.trim().is_empty() {
1681        scene_name.trim().to_string()
1682    } else {
1683        scene.name.trim().to_string()
1684    };
1685
1686    let mut binding_by_capability =
1687        BTreeMap::<String, (usize, (String, String, String, String))>::new();
1688    for (index, binding_pin) in scene.binding_pins.iter().enumerate() {
1689        let capability = normalized_pin_field(&binding_pin.capability);
1690        if capability.is_empty() {
1691            continue;
1692        }
1693
1694        let identity = (
1695            normalized_pin_field(&binding_pin.package),
1696            normalized_pin_field(&binding_pin.provider),
1697            normalized_pin_field(&binding_pin.version),
1698            normalized_pin_field(&binding_pin.sha512),
1699        );
1700
1701        if let Some((existing_index, existing_identity)) = binding_by_capability.get(&capability) {
1702            if existing_identity != &identity {
1703                return Err(Box::new(ScenePinConflictDetail {
1704                    conflict_type: "duplicate_binding_pin",
1705                    scene: resolved_scene_name,
1706                    capability: Some(capability),
1707                    package: None,
1708                    indexes: vec![*existing_index, index],
1709                    message: format!(
1710                        "Scene binding pins for capability '{}' declare conflicting package/provider/version/hash identity",
1711                        binding_pin.capability.trim()
1712                    ),
1713                }));
1714            }
1715        } else {
1716            binding_by_capability.insert(capability, (index, identity));
1717        }
1718    }
1719
1720    let mut package_by_name = BTreeMap::<String, (usize, (String, String, String))>::new();
1721    for (index, package_pin) in scene.package_pins.iter().enumerate() {
1722        let package = normalized_pin_field(&package_pin.package);
1723        if package.is_empty() {
1724            continue;
1725        }
1726
1727        let identity = (
1728            normalized_pin_field(&package_pin.version),
1729            normalized_pin_field(&package_pin.sha512),
1730            normalized_pin_field(&package_pin.source),
1731        );
1732
1733        if let Some((existing_index, existing_identity)) = package_by_name.get(&package) {
1734            if existing_identity != &identity {
1735                return Err(Box::new(ScenePinConflictDetail {
1736                    conflict_type: "duplicate_package_pin",
1737                    scene: resolved_scene_name,
1738                    capability: None,
1739                    package: Some(package),
1740                    indexes: vec![*existing_index, index],
1741                    message: format!(
1742                        "Scene package pins for package '{}' declare conflicting version/hash/source identity",
1743                        package_pin.package.trim()
1744                    ),
1745                }));
1746            }
1747        } else {
1748            package_by_name.insert(package, (index, identity));
1749        }
1750    }
1751
1752    let package_pin_indexes = scene.package_pins.iter().enumerate().fold(
1753        BTreeMap::<String, Vec<usize>>::new(),
1754        |mut acc, (index, package_pin)| {
1755            let package = normalized_pin_field(&package_pin.package);
1756            if !package.is_empty() {
1757                acc.entry(package).or_default().push(index);
1758            }
1759            acc
1760        },
1761    );
1762
1763    for (binding_index, binding_pin) in scene.binding_pins.iter().enumerate() {
1764        let package = normalized_pin_field(&binding_pin.package);
1765        if package.is_empty() {
1766            continue;
1767        }
1768
1769        let Some(package_indexes) = package_pin_indexes.get(&package) else {
1770            continue;
1771        };
1772
1773        for package_index in package_indexes {
1774            let package_pin = &scene.package_pins[*package_index];
1775            let field_conflicts = [
1776                (
1777                    "version",
1778                    non_empty_pin_field(&binding_pin.version),
1779                    non_empty_pin_field(&package_pin.version),
1780                ),
1781                (
1782                    "sha512",
1783                    non_empty_pin_field(&binding_pin.sha512),
1784                    non_empty_pin_field(&package_pin.sha512),
1785                ),
1786                (
1787                    "source",
1788                    non_empty_pin_field(&binding_pin.source),
1789                    non_empty_pin_field(&package_pin.source),
1790                ),
1791            ];
1792
1793            if let Some((field_name, _, _)) = field_conflicts.into_iter().find(
1794                |(_, binding_value, package_value)| {
1795                    matches!((binding_value, package_value), (Some(left), Some(right)) if left != right)
1796                },
1797            ) {
1798                return Err(Box::new(ScenePinConflictDetail {
1799                    conflict_type: "binding_package_mismatch",
1800                    scene: resolved_scene_name.clone(),
1801                    capability: Some(binding_pin.capability.trim().to_string()),
1802                    package: Some(package.clone()),
1803                    indexes: vec![binding_index, *package_index],
1804                    message: format!(
1805                        "Scene binding pin for capability '{}' conflicts with package pin for '{}' on field '{}'",
1806                        binding_pin.capability.trim(),
1807                        package_pin.package.trim(),
1808                        field_name,
1809                    ),
1810                }));
1811            }
1812        }
1813    }
1814
1815    Ok(())
1816}
1817
1818fn resolved_scene_name(scene_name: &str, scene: &crate::app::AppSceneConfig) -> String {
1819    if scene.name.trim().is_empty() {
1820        scene_name.trim().to_string()
1821    } else {
1822        scene.name.trim().to_string()
1823    }
1824}
1825
1826fn apply_scene_binding_pins_to_candidate_bindings(
1827    scene_name: &str,
1828    scene: Option<&crate::app::AppSceneConfig>,
1829    capabilities: &[String],
1830    proposed_bindings: Vec<crate::app::AppBindingResolution>,
1831) -> Vec<crate::app::AppBindingResolution> {
1832    let Some(scene) = scene else {
1833        return proposed_bindings;
1834    };
1835
1836    let capability_set = capabilities
1837        .iter()
1838        .map(|capability| capability.trim())
1839        .filter(|capability| !capability.is_empty())
1840        .collect::<BTreeSet<_>>();
1841    let scene_name = resolved_scene_name(scene_name, scene);
1842    let mut bindings = proposed_bindings;
1843
1844    for binding_pin in &scene.binding_pins {
1845        let capability = binding_pin.capability.trim();
1846        if capability.is_empty() {
1847            continue;
1848        }
1849
1850        let pinned_provider = if binding_pin.provider.trim().is_empty() {
1851            binding_pin.package.trim()
1852        } else {
1853            binding_pin.provider.trim()
1854        };
1855        if pinned_provider.is_empty() {
1856            continue;
1857        }
1858
1859        let binding_source = format!("scene:{scene_name}");
1860        if let Some(existing_binding) = bindings
1861            .iter_mut()
1862            .find(|binding| binding.capability == capability)
1863        {
1864            existing_binding.provider = pinned_provider.to_string();
1865            existing_binding.source = binding_source;
1866            continue;
1867        }
1868
1869        if capability_set.contains(capability) {
1870            bindings.push(crate::app::AppBindingResolution {
1871                capability: capability.to_string(),
1872                provider: pinned_provider.to_string(),
1873                mutable: false,
1874                source: binding_source,
1875            });
1876        }
1877    }
1878
1879    bindings
1880}
1881
1882fn summary_lock_bindings(
1883    bindings: &[crate::app::AppBindingResolution],
1884    packages: &[crate::app::AppLockPackage],
1885) -> Vec<crate::app::AppLockBinding> {
1886    bindings
1887        .iter()
1888        .map(|binding| {
1889            let package = packages.iter().find(|pkg| {
1890                pkg.name == binding.provider || pkg.runtime_provider == binding.provider
1891            });
1892            crate::app::AppLockBinding {
1893                capability: binding.capability.clone(),
1894                provider: binding.provider.clone(),
1895                package: package
1896                    .map(|pkg| pkg.name.clone())
1897                    .unwrap_or_else(|| binding.provider.clone()),
1898                mutable: binding.mutable,
1899                package_version: package.map(|pkg| pkg.version.clone()).unwrap_or_default(),
1900                package_sha512: package.map(|pkg| pkg.sha512.clone()).unwrap_or_default(),
1901                binding_source: binding.source.clone(),
1902            }
1903        })
1904        .collect()
1905}
1906
1907fn binding_package_names(
1908    bindings: &[crate::app::AppBindingResolution],
1909    packages: &[crate::app::AppLockPackage],
1910) -> std::collections::HashMap<String, String> {
1911    bindings
1912        .iter()
1913        .map(|binding| {
1914            let package_name = packages
1915                .iter()
1916                .find(|pkg| {
1917                    pkg.name == binding.provider || pkg.runtime_provider == binding.provider
1918                })
1919                .map(|pkg| pkg.name.clone())
1920                .unwrap_or_else(|| binding.provider.clone());
1921            (binding.capability.clone(), package_name)
1922        })
1923        .collect()
1924}
1925
1926fn lock_package_from_source(
1927    state: &AppState,
1928    pkg: &crate::app::PackageSource,
1929    selected_names: &BTreeSet<String>,
1930) -> crate::app::AppLockPackage {
1931    let digest = package_digest(&state.repo_root, &pkg.current_source);
1932    crate::app::AppLockPackage {
1933        name: pkg.name.clone(),
1934        version: "current".into(),
1935        runtime: pkg.kind.clone(),
1936        sha512: digest.clone(),
1937        source: pkg.current_source.clone(),
1938        trusted: pkg.trusted,
1939        signature: pkg.signature.clone(),
1940        source_authority: pkg.source_authority.clone(),
1941        source_public_keys: pkg.source_public_keys.clone(),
1942        package_kind: pkg.package_kind.clone(),
1943        manifest_digest: digest.clone(),
1944        artifact_digest: digest.clone(),
1945        artifact_set_id: String::new(),
1946        store_object_id: format!("sha512-{}-{}-current", digest, pkg.name),
1947        store_path: String::new(),
1948        closure_id: String::new(),
1949        entry_kind: pkg.kind.clone(),
1950        runtime_provider: pkg.runtime_provider_name(),
1951        provides: pkg.provides.clone(),
1952        requires: pkg.requires.clone(),
1953        roles: pkg
1954            .package_kind
1955            .split(',')
1956            .map(|role| role.trim().to_string())
1957            .filter(|role| !role.is_empty())
1958            .collect(),
1959        capabilities: pkg.provides.clone(),
1960        features: if selected_names.contains(&pkg.name) && pkg.package_kind.contains("feature") {
1961            vec![pkg.name.clone()]
1962        } else {
1963            vec![]
1964        },
1965        default_enabled_features: vec![],
1966        evidence: crate::app::AppLockEvidence {
1967            digest,
1968            signature: pkg.signature.clone(),
1969            source_authority: pkg.source_authority.clone(),
1970            source_public_keys: pkg.source_public_keys.clone(),
1971        },
1972        reasons: vec![],
1973    }
1974}
1975
1976fn selected_summary_packages(
1977    state: &AppState,
1978    app_config: &crate::app::AppConfigFile,
1979    bindings: &[crate::app::AppBindingResolution],
1980) -> Result<Vec<crate::app::AppLockPackage>, (StatusCode, Json<serde_json::Value>)> {
1981    let mut selected_names = app_config.packages.enabled.clone();
1982    for binding in bindings {
1983        if !selected_names.contains(&binding.provider) {
1984            selected_names.push(binding.provider.clone());
1985        }
1986    }
1987
1988    let active_scene = active_scene_config(app_config);
1989    let resolved_scene = active_scene
1990        .map(|scene| resolved_scene_name(app_config.active_scene.as_str(), scene))
1991        .unwrap_or_else(|| app_config.active_scene.clone());
1992
1993    if let Some(scene) = active_scene {
1994        for (index, package_pin) in scene.package_pins.iter().enumerate() {
1995            let package = package_pin.package.trim();
1996            if package.is_empty() {
1997                continue;
1998            }
1999
2000            if state.package_index.get(package).is_none() {
2001                return Err(scene_package_pin_unresolved_response(
2002                    &resolved_scene,
2003                    package,
2004                    index,
2005                ));
2006            }
2007
2008            if !selected_names.iter().any(|name| name == package) {
2009                selected_names.push(package.to_string());
2010            }
2011        }
2012    }
2013
2014    let mut packages = packages_for_selected_names(state, &selected_names);
2015    if let Some(scene) = active_scene {
2016        for package_pin in &scene.package_pins {
2017            let package = package_pin.package.trim();
2018            if package.is_empty() {
2019                continue;
2020            }
2021
2022            if let Some(summary_package) = packages.iter_mut().find(|pkg| pkg.name == package) {
2023                let mut source_overridden = false;
2024                let mut sha512_overridden = false;
2025                if let Some(version) = non_empty_pin_field(&package_pin.version) {
2026                    summary_package.version = version;
2027                }
2028                if let Some(sha512) = non_empty_pin_field(&package_pin.sha512) {
2029                    summary_package.sha512 = sha512.clone();
2030                    summary_package.artifact_digest = sha512.clone();
2031                    summary_package.manifest_digest = sha512.clone();
2032                    summary_package.evidence.digest = sha512;
2033                    sha512_overridden = true;
2034                }
2035                if let Some(source) = non_empty_pin_field(&package_pin.source) {
2036                    summary_package.source = source;
2037                    source_overridden = true;
2038                }
2039
2040                if source_overridden && !sha512_overridden {
2041                    // Once a scene pin changes package identity to a different source, the helper
2042                    // can no longer treat the original local digest as valid summary metadata.
2043                    summary_package.sha512.clear();
2044                    summary_package.manifest_digest.clear();
2045                    summary_package.artifact_digest.clear();
2046                    summary_package.evidence.digest.clear();
2047                    summary_package.store_object_id.clear();
2048                }
2049            }
2050        }
2051    }
2052
2053    Ok(packages)
2054}
2055
2056async fn verification_packages_for_candidate(
2057    state: &AppState,
2058    app_name: &str,
2059    generation: &crate::app::AppGeneration,
2060) -> Vec<crate::app::AppLockPackage> {
2061    let app = {
2062        let apps = state.resolved_apps.read().await;
2063        apps.get(app_name).cloned()
2064    };
2065
2066    let Some(app) = app else {
2067        return packages_for_generation(state, generation);
2068    };
2069
2070    let Some(config_path) = app.sources.config_path.as_deref() else {
2071        return packages_for_generation(state, generation);
2072    };
2073
2074    let app_config = crate::app::load_instance_config_from_path(std::path::Path::new(config_path))
2075        .unwrap_or_default();
2076
2077    selected_summary_packages(state, &app_config, &generation.bindings)
2078        .unwrap_or_else(|_| packages_for_generation(state, generation))
2079}
2080
2081async fn identity_consistency_results(
2082    state: &AppState,
2083    app_name: &str,
2084    generation: &crate::app::AppGeneration,
2085) -> Vec<crate::app::ValidationResult> {
2086    let packages = verification_packages_for_candidate(state, app_name, generation).await;
2087    let lock_bindings = summary_lock_bindings(&generation.bindings, &packages);
2088    let recomputed_binding_set_id = binding_set_id_from_lock_bindings(&lock_bindings);
2089    let recomputed_closure_id = closure_id_from_lock_packages(&packages);
2090
2091    let mut results = Vec::with_capacity(2);
2092
2093    let recorded_binding_set_id = generation.binding_set_id.trim();
2094    results.push(if recorded_binding_set_id.is_empty() {
2095        crate::app::ValidationResult {
2096            check: "identity-consistency:binding_set_id".into(),
2097            passed: true,
2098            message: format!(
2099                "Warning: candidate generation summary is missing binding_set_id metadata; recomputed identity '{}' was used for verification compatibility",
2100                recomputed_binding_set_id
2101            ),
2102        }
2103    } else {
2104        let matches = recorded_binding_set_id == recomputed_binding_set_id;
2105        crate::app::ValidationResult {
2106            check: "identity-consistency:binding_set_id".into(),
2107            passed: matches,
2108            message: if matches {
2109                format!(
2110                    "Candidate binding_set_id matches the recomputed binding identity '{}' from current bindings and selected package summary",
2111                    recomputed_binding_set_id
2112                )
2113            } else {
2114                format!(
2115                    "Candidate binding_set_id '{}' does not match recomputed binding identity '{}' from current bindings and selected package summary",
2116                    recorded_binding_set_id, recomputed_binding_set_id
2117                )
2118            },
2119        }
2120    });
2121
2122    let recorded_closure_id = generation.closure_id.trim();
2123    results.push(if recorded_closure_id.is_empty() {
2124        crate::app::ValidationResult {
2125            check: "identity-consistency:closure_id".into(),
2126            passed: true,
2127            message: format!(
2128                "Warning: candidate generation summary is missing closure_id metadata; recomputed identity '{}' was used for verification compatibility",
2129                recomputed_closure_id
2130            ),
2131        }
2132    } else {
2133        let matches = recorded_closure_id == recomputed_closure_id;
2134        crate::app::ValidationResult {
2135            check: "identity-consistency:closure_id".into(),
2136            passed: matches,
2137            message: if matches {
2138                format!(
2139                    "Candidate closure_id matches the recomputed closure identity '{}' from the selected package summary",
2140                    recomputed_closure_id
2141                )
2142            } else {
2143                format!(
2144                    "Candidate closure_id '{}' does not match recomputed closure identity '{}' from the selected package summary",
2145                    recorded_closure_id, recomputed_closure_id
2146                )
2147            },
2148        }
2149    });
2150
2151    results
2152}
2153
2154async fn binding_coverage_results(
2155    state: &AppState,
2156    app_name: &str,
2157    generation: &crate::app::AppGeneration,
2158) -> Vec<crate::app::ValidationResult> {
2159    let packages = verification_packages_for_candidate(state, app_name, generation).await;
2160    let included_packages = packages
2161        .iter()
2162        .flat_map(|pkg| [pkg.name.as_str(), pkg.runtime_provider.as_str()])
2163        .filter(|value| !value.trim().is_empty())
2164        .collect::<BTreeSet<_>>();
2165    let bindings_by_capability = generation
2166        .bindings
2167        .iter()
2168        .map(|binding| (binding.capability.as_str(), binding))
2169        .collect::<BTreeMap<_, _>>();
2170    let mut results = Vec::new();
2171
2172    for capability in &generation.capabilities {
2173        let check_name = format!("binding-coverage:{capability}");
2174        let Some(binding) = bindings_by_capability.get(capability.as_str()) else {
2175            results.push(crate::app::ValidationResult {
2176                check: check_name,
2177                passed: false,
2178                message: format!(
2179                    "Required capability '{}' has no provider binding in candidate generation",
2180                    capability
2181                ),
2182            });
2183            continue;
2184        };
2185
2186        if binding.provider == "core" {
2187            results.push(crate::app::ValidationResult {
2188                check: check_name,
2189                passed: true,
2190                message: format!(
2191                    "Required capability '{}' is bound to core; selected package coverage is not required",
2192                    capability
2193                ),
2194            });
2195            continue;
2196        }
2197
2198        let included = included_packages.contains(binding.provider.as_str());
2199        results.push(crate::app::ValidationResult {
2200            check: check_name,
2201            passed: included,
2202            message: if included {
2203                format!(
2204                    "Required capability '{}' is bound to provider '{}' with an included selected package summary entry",
2205                    capability, binding.provider
2206                )
2207            } else {
2208                format!(
2209                    "Required capability '{}' is bound to provider '{}' but no included selected package summary entry matches that binding",
2210                    capability, binding.provider
2211                )
2212            },
2213        });
2214    }
2215
2216    results
2217}
2218
2219fn is_valid_sha512_hex(value: &str) -> bool {
2220    value.len() == 128 && value.chars().all(|c| c.is_ascii_hexdigit())
2221}
2222
2223fn has_usable_digest_metadata(pkg: &crate::app::AppLockPackage) -> bool {
2224    is_valid_sha512_hex(pkg.sha512.trim())
2225        || !pkg.artifact_digest.trim().is_empty()
2226        || !pkg.evidence.digest.trim().is_empty()
2227}
2228
2229async fn digest_policy_results(
2230    state: &AppState,
2231    app_name: &str,
2232    generation: &crate::app::AppGeneration,
2233) -> (Vec<crate::app::ValidationResult>, bool) {
2234    let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2235    let packages = verification_packages_for_candidate(state, app_name, generation).await;
2236    let mut dev_unsealed = false;
2237
2238    let results = packages
2239        .into_iter()
2240        .map(|pkg| {
2241            let digest_ok = has_usable_digest_metadata(&pkg);
2242            match profile {
2243                crate::app::AppProfile::Developer if !digest_ok => {
2244                    dev_unsealed = true;
2245                    crate::app::ValidationResult {
2246                        check: format!("digest-policy:{}", pkg.name),
2247                        passed: true,
2248                        message: format!(
2249                            "Warning: package '{}' is missing usable digest metadata for source '{}' under profile 'developer'; candidate remains verified as dev-unsealed",
2250                            pkg.name, pkg.source
2251                        ),
2252                    }
2253                }
2254                _ => crate::app::ValidationResult {
2255                    check: format!("digest-policy:{}", pkg.name),
2256                    passed: digest_ok,
2257                    message: if digest_ok {
2258                        format!(
2259                            "Package '{}' has usable digest metadata for source '{}' under profile '{}'",
2260                            pkg.name, pkg.source, generation.profile
2261                        )
2262                    } else {
2263                        format!(
2264                            "Package '{}' is missing usable sha512/artifact/content digest metadata for source '{}' under profile '{}'",
2265                            pkg.name, pkg.source, generation.profile
2266                        )
2267                    },
2268                },
2269            }
2270        })
2271        .collect();
2272
2273    (results, dev_unsealed)
2274}
2275
2276fn generation_is_dev_unsealed(generation: &crate::app::AppGeneration) -> bool {
2277    crate::app::AppProfile::from_str_loose(&generation.profile) == crate::app::AppProfile::Developer
2278        && generation_has_dev_unsealed_digest_policy_validation(generation)
2279}
2280
2281async fn store_policy_results(
2282    state: &AppState,
2283    app_name: &str,
2284    generation: &crate::app::AppGeneration,
2285) -> Vec<crate::app::ValidationResult> {
2286    let packages = verification_packages_for_candidate(state, app_name, generation).await;
2287    let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2288    let instance_dir = {
2289        let apps = state.resolved_apps.read().await;
2290        apps.get(app_name).and_then(resolved_app_instance_dir)
2291    };
2292
2293    crate::app::verify_local_store_packages(instance_dir.as_deref(), &packages, profile)
2294}
2295
2296fn activation_policy_error(
2297    app_name: &str,
2298    generation: &crate::app::AppGeneration,
2299    instance_dir: Option<&FsPath>,
2300) -> Option<(StatusCode, Json<serde_json::Value>)> {
2301    if generation_has_dev_unsealed_digest_policy_validation(generation)
2302        && crate::app::AppProfile::from_str_loose(&generation.profile)
2303            != crate::app::AppProfile::Developer
2304    {
2305        return Some((
2306            StatusCode::CONFLICT,
2307            Json(serde_json::json!({
2308                "error": format!(
2309                    "Generation {} uses developer-only dev-unsealed validation but profile '{}' is not allowed to activate it",
2310                    generation.id, generation.profile
2311                ),
2312                "reason": "dev_unsealed_profile_mismatch",
2313                "status": "activation_failed",
2314                "generation": generation,
2315                "lock_written": false,
2316                "pointer_written": false,
2317                "pointer_error": serde_json::Value::Null,
2318                "index_written": false,
2319                "index_error": serde_json::Value::Null,
2320            })),
2321        ));
2322    }
2323
2324    let needs_store_verification = generation
2325        .validation_results
2326        .iter()
2327        .any(crate::app::is_store_check);
2328    if needs_store_verification {
2329        let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2330        let packages = if let Some(instance_dir) = instance_dir {
2331            let immutable_lock_path =
2332                resolved_generation_lock_path(instance_dir, &generation.lock_path);
2333            crate::app::load_instance_lock_from_path(&immutable_lock_path)
2334                .map(|lock| lock.packages)
2335                .unwrap_or_default()
2336        } else {
2337            Vec::new()
2338        };
2339        let store_results =
2340            crate::app::verify_local_store_packages(instance_dir, &packages, profile);
2341        if let Some(result) = store_results.into_iter().find(|result| !result.passed) {
2342            return Some((
2343                StatusCode::CONFLICT,
2344                Json(serde_json::json!({
2345                    "error": format!(
2346                        "Generation {} failed offline store verification required for activation: {}",
2347                        generation.id, result.message
2348                    ),
2349                    "reason": "store_object_missing",
2350                    "status": "activation_failed",
2351                    "generation": generation,
2352                    "app": app_name,
2353                    "lock_written": false,
2354                    "pointer_written": false,
2355                    "pointer_error": serde_json::Value::Null,
2356                    "index_written": false,
2357                    "index_error": serde_json::Value::Null,
2358                    "store_validation": result,
2359                })),
2360            ));
2361        }
2362    }
2363
2364    None
2365}
2366
2367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2368enum StoredGenerationSlot {
2369    Active,
2370    Candidate,
2371    Rollback,
2372}
2373
2374fn stored_generation_slot(
2375    app_store: &crate::app::AppGenerationStore,
2376    generation_id: u64,
2377) -> Option<StoredGenerationSlot> {
2378    if app_store
2379        .active
2380        .as_ref()
2381        .is_some_and(|generation| generation.id == generation_id)
2382    {
2383        Some(StoredGenerationSlot::Active)
2384    } else if app_store
2385        .candidate
2386        .as_ref()
2387        .is_some_and(|generation| generation.id == generation_id)
2388    {
2389        Some(StoredGenerationSlot::Candidate)
2390    } else if app_store
2391        .rollback
2392        .as_ref()
2393        .is_some_and(|generation| generation.id == generation_id)
2394    {
2395        Some(StoredGenerationSlot::Rollback)
2396    } else {
2397        None
2398    }
2399}
2400
2401fn activation_target_error(
2402    app_name: &str,
2403    generation_id: u64,
2404    status: Option<crate::app::GenerationStatus>,
2405) -> (StatusCode, Json<serde_json::Value>) {
2406    match status {
2407        None => (
2408            StatusCode::NOT_FOUND,
2409            Json(serde_json::json!({
2410                "error": format!(
2411                    "Generation {} not found for app '{}'",
2412                    generation_id, app_name
2413                ),
2414                "reason": "generation_not_found",
2415                "status": "activation_failed",
2416                "generation": generation_id,
2417                "lock_written": false,
2418                "pointer_written": false,
2419                "index_written": false,
2420            })),
2421        ),
2422        Some(crate::app::GenerationStatus::Candidate) => (
2423            StatusCode::CONFLICT,
2424            Json(serde_json::json!({
2425                "error": format!(
2426                    "Generation {} must be verified before activation",
2427                    generation_id
2428                ),
2429                "reason": "generation_not_verified",
2430                "status": "activation_failed",
2431                "generation": generation_id,
2432                "lock_written": false,
2433                "pointer_written": false,
2434                "index_written": false,
2435            })),
2436        ),
2437        Some(crate::app::GenerationStatus::Failed) => (
2438            StatusCode::CONFLICT,
2439            Json(serde_json::json!({
2440                "error": format!(
2441                    "Generation {} failed verification and cannot be activated",
2442                    generation_id
2443                ),
2444                "reason": "generation_failed",
2445                "status": "activation_failed",
2446                "generation": generation_id,
2447                "lock_written": false,
2448                "pointer_written": false,
2449                "index_written": false,
2450            })),
2451        ),
2452        Some(crate::app::GenerationStatus::Archived) => (
2453            StatusCode::CONFLICT,
2454            Json(serde_json::json!({
2455                "error": format!(
2456                    "Generation {} is archived and cannot be activated",
2457                    generation_id
2458                ),
2459                "reason": "generation_archived",
2460                "status": "activation_failed",
2461                "generation": generation_id,
2462                "lock_written": false,
2463                "pointer_written": false,
2464                "index_written": false,
2465            })),
2466        ),
2467        Some(_) => (
2468            StatusCode::CONFLICT,
2469            Json(serde_json::json!({
2470                "error": format!(
2471                    "Generation {} cannot be activated from its current state",
2472                    generation_id
2473                ),
2474                "reason": "activation_failed",
2475                "status": "activation_failed",
2476                "generation": generation_id,
2477                "lock_written": false,
2478                "pointer_written": false,
2479                "index_written": false,
2480            })),
2481        ),
2482    }
2483}
2484
2485fn generation_has_dev_unsealed_digest_policy_validation(
2486    generation: &crate::app::AppGeneration,
2487) -> bool {
2488    generation.validation_results.iter().any(|result| {
2489        result.check == "digest-policy:dev-unsealed"
2490            && result.passed
2491            && result
2492                .message
2493                .contains("developer-only dev-unsealed digest policy exceptions")
2494    })
2495}
2496
2497fn packages_for_selected_names(
2498    state: &AppState,
2499    names: &[String],
2500) -> Vec<crate::app::AppLockPackage> {
2501    let selected = names.iter().cloned().collect::<BTreeSet<_>>();
2502    state
2503        .package_index
2504        .package_sources
2505        .iter()
2506        .filter(|pkg| selected.contains(&pkg.name) && package_matches_required_real_source(pkg))
2507        .map(|pkg| lock_package_from_source(state, pkg, &selected))
2508        .collect()
2509}
2510
2511struct ActivationTargetSelection {
2512    generation: crate::app::AppGeneration,
2513    previous_generation_id: Option<u64>,
2514}
2515
2516fn select_activation_target(
2517    app_name: &str,
2518    app_store: &mut crate::app::AppGenerationStore,
2519    target_generation_id: Option<u64>,
2520) -> Result<ActivationTargetSelection, (StatusCode, Json<serde_json::Value>)> {
2521    match target_generation_id {
2522        None => {
2523            if let Some(error) = app_store
2524                .candidate
2525                .as_ref()
2526                .and_then(|generation| activation_policy_error(app_name, generation, None))
2527            {
2528                return Err(error);
2529            }
2530
2531            let previous_generation_id = app_store.active.as_ref().map(|generation| generation.id);
2532            let generation = app_store.activate().cloned().map_err(|err| {
2533                (
2534                    StatusCode::CONFLICT,
2535                    Json(serde_json::json!({"error": err})),
2536                )
2537            })?;
2538
2539            Ok(ActivationTargetSelection {
2540                generation,
2541                previous_generation_id,
2542            })
2543        }
2544        Some(target_generation_id) => {
2545            let Some(slot) = stored_generation_slot(app_store, target_generation_id) else {
2546                return Err(activation_target_error(
2547                    app_name,
2548                    target_generation_id,
2549                    None,
2550                ));
2551            };
2552
2553            let target_snapshot = app_store
2554                .generation(target_generation_id)
2555                .cloned()
2556                .ok_or_else(|| activation_target_error(app_name, target_generation_id, None))?;
2557
2558            match target_snapshot.status {
2559                crate::app::GenerationStatus::Verified | crate::app::GenerationStatus::Rollback => {
2560                }
2561                status => {
2562                    return Err(activation_target_error(
2563                        app_name,
2564                        target_generation_id,
2565                        Some(status),
2566                    ));
2567                }
2568            }
2569
2570            if let Some(error) = activation_policy_error(app_name, &target_snapshot, None) {
2571                return Err(error);
2572            }
2573
2574            let previous_generation_id = match slot {
2575                StoredGenerationSlot::Active => {
2576                    app_store.rollback.as_ref().map(|generation| generation.id)
2577                }
2578                StoredGenerationSlot::Candidate | StoredGenerationSlot::Rollback => {
2579                    app_store.active.as_ref().map(|generation| generation.id)
2580                }
2581            };
2582
2583            let generation = app_store
2584                .switch_to_existing(target_generation_id)
2585                .cloned()
2586                .map_err(|_| {
2587                    activation_target_error(
2588                        app_name,
2589                        target_generation_id,
2590                        Some(target_snapshot.status),
2591                    )
2592                })?;
2593
2594            Ok(ActivationTargetSelection {
2595                generation,
2596                previous_generation_id,
2597            })
2598        }
2599    }
2600}
2601
2602pub fn preview_runtime_inputs(
2603    manifest: &crate::app::AppManifest,
2604    app_config: &crate::app::AppConfigFile,
2605    default_bindings: &[crate::app::AppBindingResolution],
2606) -> (
2607    Vec<String>,
2608    Vec<String>,
2609    std::collections::HashMap<String, crate::app::AppBindingOverride>,
2610) {
2611    let mut enabled_features = if app_config.features.enabled.is_empty() {
2612        manifest.features.default_enabled.clone()
2613    } else {
2614        app_config.features.enabled.clone()
2615    };
2616    let disabled_features = app_config
2617        .features
2618        .disabled
2619        .iter()
2620        .cloned()
2621        .collect::<BTreeSet<_>>();
2622    enabled_features.retain(|feature| !disabled_features.contains(feature));
2623    enabled_features.sort();
2624    enabled_features.dedup();
2625
2626    let mut selected_packages = app_config.packages.enabled.clone();
2627    for feature in &enabled_features {
2628        if let Some(binding) = manifest.feature_bindings.get(feature) {
2629            for package in &binding.packages {
2630                if !selected_packages.contains(package) {
2631                    selected_packages.push(package.clone());
2632                }
2633            }
2634        }
2635    }
2636    for binding in default_bindings {
2637        if !selected_packages.contains(&binding.provider) {
2638            selected_packages.push(binding.provider.clone());
2639        }
2640    }
2641    selected_packages.retain(|package| !app_config.packages.disabled.contains(package));
2642    selected_packages.sort();
2643    selected_packages.dedup();
2644
2645    let mut overrides = std::collections::HashMap::new();
2646    for (capability, binding) in &app_config.binding_overrides {
2647        if !binding.provider.is_empty() {
2648            overrides.insert(capability.clone(), binding.clone());
2649        }
2650    }
2651
2652    (enabled_features, selected_packages, overrides)
2653}
2654
2655pub fn preview_runtime_capabilities(
2656    manifest: &crate::app::AppManifest,
2657    enabled_features: &[String],
2658) -> Vec<String> {
2659    let mut capabilities = manifest.requires.capabilities.clone();
2660    let mut seen = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2661
2662    for feature in enabled_features {
2663        if let Some(binding) = manifest.feature_bindings.get(feature) {
2664            for capability in &binding.requires {
2665                if seen.insert(capability.clone()) {
2666                    capabilities.push(capability.clone());
2667                }
2668            }
2669        }
2670    }
2671
2672    capabilities
2673}
2674
2675pub fn preview_runtime_bindings(
2676    app: &crate::app::ResolvedApp,
2677    allowed_capabilities: &[String],
2678    package_index: &crate::app::PackageIndex,
2679    config_overrides: &std::collections::HashMap<String, crate::app::AppBindingOverride>,
2680) -> Vec<crate::app::AppBindingResolution> {
2681    let allowed = allowed_capabilities
2682        .iter()
2683        .cloned()
2684        .collect::<BTreeSet<_>>();
2685
2686    app.bindings
2687        .iter()
2688        .filter_map(|binding| {
2689            if !allowed.contains(&binding.capability) {
2690                return None;
2691            }
2692
2693            let provider = config_overrides
2694                .get(&binding.capability)
2695                .map(|override_binding| override_binding.provider.clone())
2696                .unwrap_or_else(|| binding.provider.clone());
2697
2698            if provider != "core" && package_index.get(&provider).is_none() {
2699                return None;
2700            }
2701
2702            Some(crate::app::AppBindingResolution {
2703                capability: binding.capability.clone(),
2704                provider,
2705                mutable: binding.mutable,
2706                source: if config_overrides.contains_key(&binding.capability) {
2707                    "config-override".into()
2708                } else {
2709                    binding.source.clone()
2710                },
2711            })
2712        })
2713        .collect()
2714}
2715
2716pub fn preview_runtime_bindings_from_manifest(
2717    manifest: &crate::app::AppManifest,
2718    allowed_capabilities: &[String],
2719    package_index: &crate::app::PackageIndex,
2720    config_overrides: &std::collections::HashMap<String, crate::app::AppBindingOverride>,
2721) -> Vec<crate::app::AppBindingResolution> {
2722    let allowed = allowed_capabilities
2723        .iter()
2724        .cloned()
2725        .collect::<BTreeSet<_>>();
2726    let flattened = manifest.flattened_bindings();
2727
2728    allowed_capabilities
2729        .iter()
2730        .filter_map(|capability| {
2731            let binding = flattened.get(capability)?;
2732            let provider = config_overrides
2733                .get(capability)
2734                .map(|override_binding| override_binding.provider.clone())
2735                .unwrap_or_else(|| binding.provider.clone());
2736
2737            if provider != "core" && package_index.get(&provider).is_none() {
2738                return None;
2739            }
2740
2741            Some(crate::app::AppBindingResolution {
2742                capability: capability.clone(),
2743                provider,
2744                mutable: binding.mutable,
2745                source: if config_overrides.contains_key(capability) {
2746                    "config-override".into()
2747                } else {
2748                    "declaration-default".into()
2749                },
2750            })
2751        })
2752        .filter(|binding| allowed.contains(&binding.capability))
2753        .collect()
2754}
2755
2756fn default_runtime_bindings(
2757    app: &crate::app::ResolvedApp,
2758) -> Vec<crate::app::AppBindingResolution> {
2759    app.bindings
2760        .iter()
2761        .filter(|binding| {
2762            binding.provider == "core" || app.capabilities.contains(&binding.capability)
2763        })
2764        .cloned()
2765        .collect()
2766}
2767
2768fn lock_features_for_generation(
2769    manifest: &crate::app::AppManifest,
2770    generation: &crate::app::AppGeneration,
2771) -> Vec<crate::app::AppLockFeature> {
2772    let enabled = generation.capabilities.iter().collect::<BTreeSet<_>>();
2773
2774    manifest
2775        .features
2776        .default_enabled
2777        .iter()
2778        .chain(manifest.features.optional.iter())
2779        .cloned()
2780        .collect::<BTreeSet<_>>()
2781        .into_iter()
2782        .map(|feature_name| {
2783            let binding = manifest.feature_bindings.get(&feature_name);
2784            let capabilities = binding
2785                .map(|binding| binding.requires.clone())
2786                .unwrap_or_default();
2787            let resolved = capabilities
2788                .iter()
2789                .all(|capability| enabled.contains(capability));
2790            crate::app::AppLockFeature {
2791                name: feature_name,
2792                resolved,
2793                packages: binding
2794                    .map(|binding| binding.packages.clone())
2795                    .unwrap_or_default(),
2796                capabilities,
2797            }
2798        })
2799        .collect()
2800}
2801
2802fn trust_results(
2803    state: &AppState,
2804    generation: &crate::app::AppGeneration,
2805) -> Vec<crate::app::ValidationResult> {
2806    let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2807    packages_for_generation(state, generation)
2808        .into_iter()
2809        .map(|pkg| {
2810            let allowed = match profile {
2811                crate::app::AppProfile::Safe => pkg.trusted && pkg.runtime != "native",
2812                crate::app::AppProfile::Developer => pkg.runtime != "native" || pkg.trusted,
2813                crate::app::AppProfile::Trusted => true,
2814            };
2815            crate::app::ValidationResult {
2816                check: format!("trust:{}", pkg.name),
2817                passed: allowed,
2818                message: if allowed {
2819                    format!(
2820                        "Package '{}' is allowed under profile '{}' (runtime={}, trusted={})",
2821                        pkg.name, generation.profile, pkg.runtime, pkg.trusted
2822                    )
2823                } else {
2824                    format!(
2825                        "Package '{}' is not allowed under profile '{}' (runtime={}, trusted={})",
2826                        pkg.name, generation.profile, pkg.runtime, pkg.trusted
2827                    )
2828                },
2829            }
2830        })
2831        .collect()
2832}
2833
2834fn signature_results(
2835    state: &AppState,
2836    generation: &crate::app::AppGeneration,
2837) -> Vec<crate::app::ValidationResult> {
2838    let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2839    packages_for_generation(state, generation)
2840        .into_iter()
2841        .map(|pkg| {
2842            let crypto_ok = if pkg.signature.starts_with("ed25519:") {
2843                crate::app::verify_package_signature_for_source(
2844                    &pkg.signature,
2845                    &crate::app::signature_message(
2846                        &pkg.name,
2847                        &pkg.version,
2848                        &pkg.sha512,
2849                        &pkg.source,
2850                    ),
2851                    &pkg.source_authority,
2852                    &pkg.source_public_keys,
2853                )
2854                .is_ok()
2855            } else {
2856                false
2857            };
2858            let signature_ok = match profile {
2859                crate::app::AppProfile::Safe => pkg.signature.starts_with("builtin:") || crypto_ok,
2860                crate::app::AppProfile::Developer => {
2861                    (!pkg.signature.is_empty() && pkg.signature != "unsigned") || crypto_ok
2862                }
2863                crate::app::AppProfile::Trusted => true,
2864            };
2865            crate::app::ValidationResult {
2866                check: format!("signature:{}", pkg.name),
2867                passed: signature_ok,
2868                message: if signature_ok {
2869                    format!(
2870                        "Package '{}' signature '{}' accepted under profile '{}'{}",
2871                        pkg.name,
2872                        pkg.signature,
2873                        generation.profile,
2874                        if crypto_ok {
2875                            " (cryptographically verified)"
2876                        } else {
2877                            ""
2878                        }
2879                    )
2880                } else {
2881                    format!(
2882                        "Package '{}' signature '{}' rejected under profile '{}'",
2883                        pkg.name, pkg.signature, generation.profile
2884                    )
2885                },
2886            }
2887        })
2888        .collect()
2889}
2890
2891async fn live_probe_generation(
2892    state: &AppState,
2893    generation: &crate::app::AppGeneration,
2894) -> Vec<crate::app::ValidationResult> {
2895    let mut results = Vec::new();
2896    let registry = state.capability_registry.read().await;
2897
2898    for capability in &generation.capabilities {
2899        let binding = generation
2900            .bindings
2901            .iter()
2902            .find(|binding| binding.capability == *capability);
2903        let Some(binding) = binding else {
2904            results.push(crate::app::ValidationResult {
2905                check: format!("probe:{}", capability),
2906                passed: false,
2907                message: "No provider binding found for capability during live probe".into(),
2908            });
2909            continue;
2910        };
2911
2912        let registry_provider = registry.get(capability).and_then(|entry| {
2913            entry
2914                .providers
2915                .iter()
2916                .find(|provider| provider.provider == binding.provider)
2917        });
2918        let Some(registry_provider) = registry_provider else {
2919            results.push(crate::app::ValidationResult {
2920                check: format!("probe:{}", capability),
2921                passed: false,
2922                message: format!(
2923                    "Live probe failed because registry has no runtime entry for capability '{}' bound provider '{}'",
2924                    capability, binding.provider
2925                ),
2926            });
2927            continue;
2928        };
2929
2930        if !generation_declares_real_package(state, generation, &binding.provider) {
2931            results.push(crate::app::ValidationResult {
2932                check: format!("probe:{}", capability),
2933                passed: false,
2934                message: format!(
2935                    "Live probe failed because capability '{}' is bound to '{}' without an assembled real package source",
2936                    capability, binding.provider
2937                ),
2938            });
2939            continue;
2940        }
2941
2942        let runtime = registry_provider.runtime.as_str();
2943
2944        if runtime == "native" {
2945            results.push(crate::app::ValidationResult {
2946                check: format!("probe:{}", capability),
2947                passed: true,
2948                message: format!(
2949                    "Live probe skipped for {} runtime until direct probe support is available",
2950                    runtime
2951                ),
2952            });
2953            continue;
2954        }
2955
2956        if runtime == "wasm" {
2957            results.push(
2958                probe_wasm_provider_via_temp_load(
2959                    state,
2960                    &binding.provider,
2961                    capability,
2962                    &generation.app_name,
2963                )
2964                .await,
2965            );
2966            continue;
2967        }
2968
2969        if runtime == "metadata" {
2970            results.push(crate::app::ValidationResult {
2971                check: format!("probe:{}", capability),
2972                passed: !requires_real_package_source(&binding.provider),
2973                message: if requires_real_package_source(&binding.provider) {
2974                    format!(
2975                        "Live probe failed because capability '{}' requires a real package-backed runtime, not metadata provider '{}'",
2976                        capability, binding.provider
2977                    )
2978                } else {
2979                    "Metadata provider validated structurally; live probe skipped".into()
2980                },
2981            });
2982            continue;
2983        }
2984
2985        let probe_payload = serde_json::json!({
2986            "action": "describe",
2987            "data": {},
2988            "app": generation.app_name,
2989        });
2990        let result =
2991            crate::api::capabilities::execute_capability_call(state, capability, probe_payload)
2992                .await;
2993        match result {
2994            Ok(_) => results.push(crate::app::ValidationResult {
2995                check: format!("probe:{}", capability),
2996                passed: true,
2997                message: format!("Provider responded to live {} probe", runtime),
2998            }),
2999            Err((status, value)) => {
3000                let body = value.to_string();
3001                let skipped_unknown_action = status == StatusCode::BAD_GATEWAY
3002                    && body.to_lowercase().contains("unknown action")
3003                    && body.contains("describe");
3004                results.push(crate::app::ValidationResult {
3005                    check: format!("probe:{}", capability),
3006                    passed: skipped_unknown_action,
3007                    message: if skipped_unknown_action {
3008                        "Live probe skipped because provider does not implement generic describe action"
3009                            .into()
3010                    } else {
3011                        format!("Live probe failed with {}: {}", status, value)
3012                    },
3013                });
3014            }
3015        }
3016    }
3017
3018    results
3019}
3020
3021fn integrity_results(
3022    state: &AppState,
3023    generation: &crate::app::AppGeneration,
3024) -> Vec<crate::app::ValidationResult> {
3025    packages_for_generation(state, generation)
3026        .into_iter()
3027        .map(|pkg| {
3028            let digest_ok = is_valid_sha512_hex(&pkg.sha512);
3029            crate::app::ValidationResult {
3030                check: format!("integrity:{}", pkg.name),
3031                passed: digest_ok,
3032                message: if digest_ok {
3033                    format!(
3034                        "Package '{}' has valid SHA-512 digest from {}",
3035                        pkg.name, pkg.source
3036                    )
3037                } else {
3038                    format!("Package '{}' has invalid digest", pkg.name)
3039                },
3040            }
3041        })
3042        .collect()
3043}
3044
3045pub async fn propose_generation(
3046    Path(name): Path<String>,
3047    State(state): State<AppState>,
3048    Json(body): Json<serde_json::Value>,
3049) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3050    let apps = state.resolved_apps.read().await;
3051    let app = apps.get(&name).ok_or_else(|| {
3052        (
3053            StatusCode::NOT_FOUND,
3054            Json(serde_json::json!({"error": format!("App '{}' not found", name)})),
3055        )
3056    })?;
3057
3058    let profile = state.active_profile.read().await;
3059    let manifest = crate::app::load_product_package_declaration_from_path(std::path::Path::new(
3060        &app.sources.manifest_path,
3061    ))
3062    .unwrap_or_default();
3063    let app_config = app
3064        .sources
3065        .config_path
3066        .as_deref()
3067        .map(std::path::Path::new)
3068        .map(|path| crate::app::load_instance_config_from_path(path).unwrap_or_default())
3069        .unwrap_or_default();
3070    let manifest_is_available =
3071        !app.sources.manifest_path.is_empty() && !manifest.app.name.is_empty();
3072    let manifest_bindings = manifest.flattened_bindings();
3073    let (config_enabled_features, _selected_packages, config_overrides) = if manifest_is_available {
3074        preview_runtime_inputs(&manifest, &app_config, &app.bindings)
3075    } else {
3076        (
3077            app.enabled_features.clone(),
3078            vec![],
3079            std::collections::HashMap::new(),
3080        )
3081    };
3082    let default_runtime_capabilities = if manifest_is_available {
3083        preview_runtime_capabilities(&manifest, &config_enabled_features)
3084    } else {
3085        app.capabilities.clone()
3086    };
3087    let default_runtime_bindings = if manifest_is_available {
3088        preview_runtime_bindings_from_manifest(
3089            &manifest,
3090            &default_runtime_capabilities,
3091            &state.package_index,
3092            &config_overrides,
3093        )
3094    } else {
3095        default_runtime_bindings(app)
3096    };
3097
3098    let proposed_bindings: Vec<crate::app::AppBindingResolution> = body["bindings"]
3099        .as_array()
3100        .map(|arr| {
3101            arr.iter()
3102                .filter_map(|b| {
3103                    Some(crate::app::AppBindingResolution {
3104                        capability: b["capability"].as_str()?.into(),
3105                        provider: b["provider"].as_str()?.into(),
3106                        mutable: b["mutable"].as_bool().unwrap_or(false),
3107                        source: b["source"].as_str().unwrap_or("request").into(),
3108                    })
3109                })
3110                .collect()
3111        })
3112        .unwrap_or_else(|| default_runtime_bindings.clone());
3113
3114    for binding in &proposed_bindings {
3115        if manifest_is_available {
3116            if let Some(decl) = manifest_bindings.get(&binding.capability) {
3117                if !decl.mutable && binding.provider != decl.provider {
3118                    return Err((
3119                        StatusCode::UNPROCESSABLE_ENTITY,
3120                        Json(serde_json::json!({
3121                            "error": format!(
3122                                "Binding '{}' is immutable — cannot change provider from '{}' to '{}'",
3123                                binding.capability, decl.provider, binding.provider
3124                            ),
3125                        })),
3126                    ));
3127                }
3128                if decl.mutable
3129                    && !decl.allowed.is_empty()
3130                    && !decl.allowed.contains(&binding.provider)
3131                {
3132                    return Err((
3133                        StatusCode::UNPROCESSABLE_ENTITY,
3134                        Json(serde_json::json!({
3135                            "error": format!(
3136                                "Provider '{}' is not in the allowed list for capability '{}'. Allowed: {:?}",
3137                                binding.provider, binding.capability, decl.allowed
3138                            ),
3139                        })),
3140                    ));
3141                }
3142            }
3143        }
3144    }
3145
3146    let capabilities = body["capabilities"]
3147        .as_array()
3148        .map(|arr| {
3149            arr.iter()
3150                .filter_map(|v| v.as_str().map(String::from))
3151                .collect()
3152        })
3153        .unwrap_or_else(|| default_runtime_capabilities.clone());
3154
3155    let version = body["version"].as_str().unwrap_or(&app.version).to_string();
3156    let parent_generation = {
3157        let store = state.generation_store.read().await;
3158        store
3159            .get(&name)
3160            .and_then(|entry| entry.active.as_ref())
3161            .map(|generation| generation.id)
3162    };
3163    let created_by = body["created_by"].as_str().unwrap_or("api").to_string();
3164    let active_scene = active_scene_config(&app_config);
3165    if let Some(scene_config) = active_scene {
3166        validate_scene_pin_conflicts(app_config.active_scene.as_str(), scene_config)
3167            .map_err(|detail| scene_pin_conflict_response(*detail))?;
3168    }
3169    let scene = active_scene
3170        .map(|scene| resolved_scene_name(app_config.active_scene.as_str(), scene))
3171        .unwrap_or_else(|| app_config.active_scene.clone());
3172    let effective_profile = active_scene
3173        .and_then(|scene| {
3174            if scene.profile.trim().is_empty() {
3175                None
3176            } else {
3177                Some(scene.profile.as_str())
3178            }
3179        })
3180        .unwrap_or(profile.as_str());
3181    let parent_generation = active_scene
3182        .and_then(|scene| scene.base_generation)
3183        .or(parent_generation);
3184    let enabled_features = overlay_scene_enabled_features(&config_enabled_features, active_scene);
3185
3186    let proposed_bindings = apply_scene_binding_pins_to_candidate_bindings(
3187        app_config.active_scene.as_str(),
3188        active_scene,
3189        &capabilities,
3190        proposed_bindings,
3191    );
3192
3193    let selected_packages = selected_summary_packages(&state, &app_config, &proposed_bindings)?;
3194    let lock_bindings = summary_lock_bindings(&proposed_bindings, &selected_packages);
3195    let binding_set_id = binding_set_id_from_lock_bindings(&lock_bindings);
3196    let closure_id = closure_id_from_lock_packages(&selected_packages);
3197
3198    let mut store = state.generation_store.write().await;
3199    let app_store = store.entry(name.clone()).or_default();
3200    let candidate_id = app_store.next_generation_id();
3201    let gen = app_store.propose(crate::app::generation::AppGenerationProposal {
3202        app_name: name.clone(),
3203        version: version.clone(),
3204        bindings: proposed_bindings,
3205        capabilities,
3206        enabled_features,
3207        profile: effective_profile.to_string(),
3208        metadata: AppGenerationSummaryMetadata {
3209            scene,
3210            binding_set_id,
3211            closure_id,
3212            lock_digest: String::new(),
3213            lock_path: generation_lock_path(candidate_id),
3214            parent_generation,
3215            created_by,
3216        },
3217    });
3218
3219    Ok(Json(serde_json::json!({
3220        "generation": gen,
3221        "status": "proposed",
3222    })))
3223}
3224
3225pub async fn verify_generation(
3226    Path(name): Path<String>,
3227    State(state): State<AppState>,
3228) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3229    let mut store = state.generation_store.write().await;
3230    let app_store = store.get_mut(&name).ok_or_else(|| {
3231        (
3232            StatusCode::NOT_FOUND,
3233            Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3234        )
3235    })?;
3236
3237    let registry = state.capability_registry.read().await;
3238    let store_verification_error = app_store.verify_candidate(Some(&registry)).err();
3239    let generation_snapshot = match app_store.candidate.clone() {
3240        Some(candidate) => candidate,
3241        None => {
3242            let err = store_verification_error
3243                .unwrap_or_else(|| "No candidate generation to verify".to_string());
3244            return Err((
3245                StatusCode::UNPROCESSABLE_ENTITY,
3246                Json(serde_json::json!({
3247                    "error": err,
3248                    "candidate": serde_json::Value::Null,
3249                })),
3250            ));
3251        }
3252    };
3253    let store_verification_ok = store_verification_error.is_none();
3254    drop(registry);
3255    drop(store);
3256
3257    let binding_coverage = binding_coverage_results(&state, &name, &generation_snapshot).await;
3258    let (digest_policy, dev_unsealed) =
3259        digest_policy_results(&state, &name, &generation_snapshot).await;
3260    let store_policy = store_policy_results(&state, &name, &generation_snapshot).await;
3261    let identity_consistency =
3262        identity_consistency_results(&state, &name, &generation_snapshot).await;
3263    let integrity = integrity_results(&state, &generation_snapshot);
3264    let trust = trust_results(&state, &generation_snapshot);
3265    let signatures = signature_results(&state, &generation_snapshot);
3266    let probe_results = live_probe_generation(&state, &generation_snapshot).await;
3267    let binding_coverage_ok = binding_coverage.iter().all(|r| r.passed);
3268    let all_probes_passed = probe_results.iter().all(|r| r.passed);
3269    let digest_policy_ok = digest_policy.iter().all(|r| r.passed);
3270    let store_policy_ok = store_policy.iter().all(|r| r.passed);
3271    let identity_consistency_ok = identity_consistency.iter().all(|r| r.passed);
3272    let integrity_ok = integrity.iter().all(|r| r.passed);
3273    let trust_ok = trust.iter().all(|r| r.passed);
3274    let signatures_ok = signatures.iter().all(|r| r.passed);
3275
3276    let mut store = state.generation_store.write().await;
3277    let app_store = store.get_mut(&name).ok_or_else(|| {
3278        (
3279            StatusCode::NOT_FOUND,
3280            Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3281        )
3282    })?;
3283    let candidate = app_store.candidate.as_mut().ok_or_else(|| {
3284        (
3285            StatusCode::NOT_FOUND,
3286            Json(serde_json::json!({"error": "Candidate generation disappeared during verification"})),
3287        )
3288    })?;
3289    candidate.validation_results.extend(binding_coverage);
3290    candidate.validation_results.extend(digest_policy);
3291    candidate.validation_results.extend(store_policy);
3292    candidate.validation_results.extend(identity_consistency);
3293    candidate.validation_results.extend(integrity);
3294    candidate.validation_results.extend(trust);
3295    candidate.validation_results.extend(signatures);
3296    candidate.validation_results.extend(probe_results);
3297    if dev_unsealed {
3298        candidate.validation_results.push(crate::app::ValidationResult {
3299            check: "digest-policy:dev-unsealed".into(),
3300            passed: true,
3301            message: "Warning: candidate verification succeeded with developer-only dev-unsealed digest policy exceptions".into(),
3302        });
3303    }
3304    if !store_verification_ok
3305        || !binding_coverage_ok
3306        || !all_probes_passed
3307        || !digest_policy_ok
3308        || !store_policy_ok
3309        || !identity_consistency_ok
3310        || !integrity_ok
3311        || !trust_ok
3312        || !signatures_ok
3313    {
3314        candidate.status = crate::app::GenerationStatus::Failed;
3315        let candidate_snapshot = candidate.clone();
3316        return Err((
3317            StatusCode::UNPROCESSABLE_ENTITY,
3318            Json(serde_json::json!({
3319                "error": "Verification failed",
3320                "reason": "generation_verify_failed",
3321                "candidate": candidate_snapshot,
3322            })),
3323        ));
3324    }
3325
3326    let candidate_snapshot = candidate.clone();
3327    Ok(Json(serde_json::json!({
3328        "generation": candidate_snapshot,
3329        "status": "verified",
3330        "dev_unsealed": generation_is_dev_unsealed(&candidate_snapshot),
3331    })))
3332}
3333
3334pub async fn activate_generation(
3335    Path(name): Path<String>,
3336    State(state): State<AppState>,
3337) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3338    let mut store = state.generation_store.write().await;
3339    let app_store = store.get_mut(&name).ok_or_else(|| {
3340        (
3341            StatusCode::NOT_FOUND,
3342            Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3343        )
3344    })?;
3345
3346    match select_activation_target(&name, app_store, None) {
3347        Ok(ActivationTargetSelection {
3348            generation,
3349            previous_generation_id,
3350        }) => {
3351            let active_generation_id = generation.id;
3352            let mut packages = packages_for_generation(&state, &generation);
3353            let apps = state.resolved_apps.read().await;
3354            let app = apps.get(&name).ok_or_else(|| {
3355                (
3356                    StatusCode::NOT_FOUND,
3357                    Json(serde_json::json!({"error": format!("App '{}' not found during activation", name)})),
3358                )
3359            })?;
3360            let instance_dir = resolved_app_instance_dir(app);
3361            if let Some(error) =
3362                activation_policy_error(&name, &generation, instance_dir.as_deref())
3363            {
3364                return Err(error);
3365            }
3366            let manifest_path = std::path::Path::new(&app.sources.manifest_path);
3367            let manifest = crate::app::load_product_package_declaration_from_path(manifest_path)
3368                .unwrap_or_default();
3369            let app_config = app
3370                .sources
3371                .config_path
3372                .as_deref()
3373                .map(std::path::Path::new)
3374                .map(|path| crate::app::load_instance_config_from_path(path).unwrap_or_default())
3375                .unwrap_or_default();
3376            let manifest_is_available =
3377                !app.sources.manifest_path.is_empty() && !manifest.app.name.is_empty();
3378            let declaration_path = manifest_path.to_path_buf();
3379            let manifest_rel = declaration_path
3380                .strip_prefix(&state.repo_root)
3381                .ok()
3382                .map(|path| path.to_string_lossy().replace('\\', "/"))
3383                .unwrap_or_else(|| declaration_path.display().to_string().replace('\\', "/"));
3384            let config_path = app
3385                .sources
3386                .config_path
3387                .as_deref()
3388                .map(std::path::PathBuf::from)
3389                .unwrap_or_else(|| std::path::PathBuf::from("config.toml"));
3390            let config_rel = config_path
3391                .strip_prefix(&state.repo_root)
3392                .ok()
3393                .map(|path| path.to_string_lossy().replace('\\', "/"))
3394                .unwrap_or_else(|| config_path.display().to_string().replace('\\', "/"));
3395            let scene_digest = generation_scene_digest(&app_config, &generation.scene);
3396            let features = if manifest_is_available {
3397                lock_features_for_generation(&manifest, &generation)
3398            } else {
3399                generation
3400                    .enabled_features
3401                    .iter()
3402                    .cloned()
3403                    .map(|name| crate::app::AppLockFeature {
3404                        name,
3405                        resolved: true,
3406                        packages: vec![],
3407                        capabilities: vec![],
3408                    })
3409                    .collect()
3410            };
3411            let (_, selected_packages, _) = if manifest_is_available {
3412                preview_runtime_inputs(&manifest, &app_config, &generation.bindings)
3413            } else {
3414                (
3415                    generation.enabled_features.clone(),
3416                    vec![],
3417                    std::collections::HashMap::new(),
3418                )
3419            };
3420            let selected_packages = selected_packages
3421                .into_iter()
3422                .filter(|package_name| {
3423                    state
3424                        .package_index
3425                        .get(package_name)
3426                        .map(package_matches_required_real_source)
3427                        .unwrap_or(true)
3428                })
3429                .collect::<Vec<_>>();
3430            let mut selected_only_packages =
3431                packages_for_selected_names(&state, &selected_packages);
3432            for package in selected_only_packages.drain(..) {
3433                if !packages
3434                    .iter()
3435                    .any(|existing| existing.name == package.name)
3436                {
3437                    packages.push(package);
3438                }
3439            }
3440            let feature_only_packages = packages_for_selected_names(
3441                &state,
3442                &features
3443                    .iter()
3444                    .flat_map(|feature| feature.packages.clone())
3445                    .collect::<Vec<_>>(),
3446            );
3447            for package in feature_only_packages {
3448                if !packages
3449                    .iter()
3450                    .any(|existing| existing.name == package.name)
3451                {
3452                    packages.push(package);
3453                }
3454            }
3455            let binding_package_names = binding_package_names(&generation.bindings, &packages);
3456            let lock_bindings = summary_lock_bindings(&generation.bindings, &packages);
3457            let closure_digest = closure_digest_from_lock_packages(&packages);
3458            let dev_unsealed = generation_is_dev_unsealed(&generation);
3459
3460            let lock_file = crate::app::AppLockFile {
3461                lock_version: 2,
3462                app: generation.app_name.clone(),
3463                generation: generation.id as u32,
3464                status: "active".into(),
3465                profile: generation.profile.clone(),
3466                scene: generation.scene.clone(),
3467                scene_digest: scene_digest.clone(),
3468                binding_set_id: if generation.binding_set_id.is_empty() {
3469                    binding_set_id_from_lock_bindings(&lock_bindings)
3470                } else {
3471                    generation.binding_set_id.clone()
3472                },
3473                closure_id: if generation.closure_id.is_empty() {
3474                    closure_id_from_lock_packages(&packages)
3475                } else {
3476                    generation.closure_id.clone()
3477                },
3478                closure_digest: closure_digest.clone(),
3479                store_generation_id: String::new(),
3480                trust_level: if dev_unsealed {
3481                    "dev-unsealed".into()
3482                } else {
3483                    String::new()
3484                },
3485                dev_unsealed,
3486                features,
3487                inputs: crate::app::AppLockInputSnapshot {
3488                    declaration_schema_version: manifest.schema_version,
3489                    config_schema_version: app_config.schema_version,
3490                    declaration_digest: if manifest_is_available {
3491                        package_digest(&state.repo_root, &manifest_rel)
3492                    } else {
3493                        String::new()
3494                    },
3495                    config_digest: if manifest_is_available {
3496                        package_digest(&state.repo_root, &config_rel)
3497                    } else {
3498                        String::new()
3499                    },
3500                    scene_digest: scene_digest.clone(),
3501                    package_index_digest: String::new(),
3502                    package_server_snapshot_digest: String::new(),
3503                },
3504                assembly: crate::app::AppLockAssembly {
3505                    enabled_features: generation.enabled_features.clone(),
3506                    selected_packages,
3507                    scene: generation.scene.clone(),
3508                    scene_digest,
3509                    binding_set_id: if generation.binding_set_id.is_empty() {
3510                        binding_set_id_from_lock_bindings(&lock_bindings)
3511                    } else {
3512                        generation.binding_set_id.clone()
3513                    },
3514                    closure_id: if generation.closure_id.is_empty() {
3515                        closure_id_from_lock_packages(&packages)
3516                    } else {
3517                        generation.closure_id.clone()
3518                    },
3519                    closure_digest,
3520                },
3521                packages,
3522                bindings: lock_bindings,
3523                binding_sources: generation
3524                    .bindings
3525                    .iter()
3526                    .map(|binding| crate::app::AppLockBindingSource {
3527                        capability: binding.capability.clone(),
3528                        source: binding.source.clone(),
3529                        package: binding_package_names
3530                            .get(&binding.capability)
3531                            .cloned()
3532                            .unwrap_or_else(|| binding.provider.clone()),
3533                    })
3534                    .collect(),
3535                notes: crate::app::AppLockNotes {
3536                    message: format!("Activated generation {} via API", generation.id),
3537                },
3538            };
3539
3540            let generation_lock_written = if let Some(ref instance_dir) = instance_dir {
3541                write_immutable_generation_lock(instance_dir, &generation, &lock_file).map_err(
3542                    |error| {
3543                        tracing::warn!(
3544                        "Failed to write immutable generation lock for '{}' generation {}: {:?}",
3545                        name,
3546                        generation.id,
3547                        error
3548                    );
3549                        immutable_generation_lock_failure_response(&name, generation.id, error)
3550                    },
3551                )?;
3552                true
3553            } else {
3554                false
3555            };
3556
3557            let mut lock_written = false;
3558            if let Some(ref lock_path) = app.sources.lock_path {
3559                if let Err(e) = crate::app::save_instance_lock_to_path(
3560                    std::path::Path::new(lock_path),
3561                    &lock_file,
3562                ) {
3563                    tracing::warn!("Failed to write lock file for '{}': {}", name, e);
3564                    return Err((
3565                        StatusCode::INTERNAL_SERVER_ERROR,
3566                        Json(serde_json::json!({
3567                            "error": format!("Failed to write lock file for '{}': {}", name, e),
3568                            "reason": "lock_write_failed",
3569                            "generation": generation,
3570                            "status": "activation_failed",
3571                            "lock_written": false,
3572                            "generation_lock_written": generation_lock_written,
3573                        })),
3574                    ));
3575                } else {
3576                    lock_written = true;
3577                }
3578            }
3579
3580            let (pointer_written, pointer_error, index_written, index_error) = if let Some(
3581                instance_dir,
3582            ) = instance_dir
3583            {
3584                write_generation_pointers(
3585                    &name,
3586                    &instance_dir,
3587                    previous_generation_id,
3588                    active_generation_id,
3589                    generation_lock_written,
3590                    "activation_failed",
3591                    "activation",
3592                )?;
3593
3594                let (index_written, index_error) =
3595                    persist_generation_index_best_effort(&name, app_store, &instance_dir);
3596                (true, serde_json::Value::Null, index_written, index_error)
3597            } else {
3598                (
3599                        false,
3600                        serde_json::json!({
3601                            "stage": "instance_dir",
3602                            "repair_needed": false,
3603                            "message": format!(
3604                                "No resolved instance directory available for '{}' to write activation pointers",
3605                                name
3606                            ),
3607                            "details": "resolved_app_instance_dir returned None",
3608                        }),
3609                        false,
3610                        Some(format!(
3611                            "No resolved instance directory available for '{}' to persist repairable generation index",
3612                            name
3613                        )),
3614                    )
3615            };
3616            Ok(Json(serde_json::json!({
3617                "generation": generation,
3618                "status": "activated",
3619                "generation_lock_written": generation_lock_written,
3620                "lock_written": lock_written,
3621                "pointer_written": pointer_written,
3622                "pointer_error": pointer_error,
3623                "index_written": index_written,
3624                "index_error": index_error,
3625            })))
3626        }
3627        Err(error) => Err(error),
3628    }
3629}
3630
3631pub async fn activate_existing_generation(
3632    Path((name, generation_id)): Path<(String, u64)>,
3633    State(state): State<AppState>,
3634) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3635    let mut store = state.generation_store.write().await;
3636    let app_store = store.get_mut(&name).ok_or_else(|| {
3637        (
3638            StatusCode::NOT_FOUND,
3639            Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3640        )
3641    })?;
3642
3643    match select_activation_target(&name, app_store, Some(generation_id)) {
3644        Ok(ActivationTargetSelection {
3645            generation,
3646            previous_generation_id,
3647        }) => {
3648            let active_generation_id = generation.id;
3649            let apps = state.resolved_apps.read().await;
3650            let app = apps.get(&name).ok_or_else(|| {
3651                (
3652                    StatusCode::NOT_FOUND,
3653                    Json(serde_json::json!({"error": format!("App '{}' not found during activation", name)})),
3654                )
3655            })?;
3656            let instance_dir = resolved_app_instance_dir(app);
3657            if let Some(error) =
3658                activation_policy_error(&name, &generation, instance_dir.as_deref())
3659            {
3660                return Err(error);
3661            }
3662            let mut generation_lock_replayed = false;
3663            let mut generation_lock_warning = serde_json::Value::Null;
3664            let mut lock_written = false;
3665
3666            if let Some(ref lock_path) = app.sources.lock_path {
3667                let existing_lock =
3668                    crate::app::load_instance_lock_from_path(std::path::Path::new(lock_path))
3669                        .unwrap_or_default();
3670                let rollback_root_lock = resolve_activation_root_lock(
3671                    &name,
3672                    &generation,
3673                    instance_dir.as_deref(),
3674                    existing_lock,
3675                )?;
3676                generation_lock_replayed = rollback_root_lock.generation_lock_replayed;
3677                if let Some(warning) = rollback_root_lock.generation_lock_warning {
3678                    generation_lock_warning = warning;
3679                }
3680                if let Err(e) = crate::app::save_instance_lock_to_path(
3681                    std::path::Path::new(lock_path),
3682                    &rollback_root_lock.lock_file,
3683                ) {
3684                    return Err((
3685                        StatusCode::INTERNAL_SERVER_ERROR,
3686                        Json(serde_json::json!({
3687                            "error": format!("Failed to write lock file for '{}' during activation: {}", name, e),
3688                            "reason": "lock_write_failed",
3689                            "status": "activation_failed",
3690                            "lock_written": false,
3691                            "pointer_written": false,
3692                            "pointer_error": serde_json::Value::Null,
3693                            "index_written": false,
3694                            "index_error": serde_json::Value::Null,
3695                            "generation_lock_replayed": generation_lock_replayed,
3696                            "generation_lock_warning": generation_lock_warning,
3697                        })),
3698                    ));
3699                } else {
3700                    lock_written = true;
3701                }
3702            }
3703
3704            let (pointer_written, pointer_error, index_written, index_error) = if let Some(
3705                instance_dir,
3706            ) = instance_dir
3707            {
3708                write_generation_pointers(
3709                    &name,
3710                    &instance_dir,
3711                    previous_generation_id,
3712                    active_generation_id,
3713                    lock_written,
3714                    "activation_failed",
3715                    "activation",
3716                )?;
3717
3718                let (index_written, index_error) =
3719                    persist_generation_index_best_effort(&name, app_store, &instance_dir);
3720                (true, serde_json::Value::Null, index_written, index_error)
3721            } else {
3722                (
3723                        false,
3724                        serde_json::json!({
3725                            "stage": "instance_dir",
3726                            "repair_needed": false,
3727                            "message": format!(
3728                                "No resolved instance directory available for '{}' to write activation pointers",
3729                                name
3730                            ),
3731                            "details": "resolved_app_instance_dir returned None",
3732                        }),
3733                        false,
3734                        Some(format!(
3735                            "No resolved instance directory available for '{}' to persist repairable generation index",
3736                            name
3737                        )),
3738                    )
3739            };
3740
3741            Ok(Json(serde_json::json!({
3742                "generation": generation,
3743                "status": "activated",
3744                "lock_written": lock_written,
3745                "generation_lock_replayed": generation_lock_replayed,
3746                "generation_lock_warning": generation_lock_warning,
3747                "pointer_written": pointer_written,
3748                "pointer_error": pointer_error,
3749                "index_written": index_written,
3750                "index_error": index_error,
3751            })))
3752        }
3753        Err(error) => Err(error),
3754    }
3755}
3756
3757pub async fn rollback_generation(
3758    Path(name): Path<String>,
3759    State(state): State<AppState>,
3760) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3761    let mut store = state.generation_store.write().await;
3762    let app_store = store.get_mut(&name).ok_or_else(|| {
3763        (
3764            StatusCode::NOT_FOUND,
3765            Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3766        )
3767    })?;
3768
3769    match app_store.rollback() {
3770        Ok(gen) => {
3771            let generation = gen.clone();
3772            let apps = state.resolved_apps.read().await;
3773            let app = apps.get(&name).ok_or_else(|| {
3774                (
3775                    StatusCode::NOT_FOUND,
3776                    Json(serde_json::json!({"error": format!("App '{}' not found during rollback", name)})),
3777                )
3778            })?;
3779            let instance_dir = resolved_app_instance_dir(app);
3780            let mut lock_written = false;
3781            let mut generation_lock_replayed = false;
3782            let mut generation_lock_warning = serde_json::Value::Null;
3783            if let Some(ref lock_path) = app.sources.lock_path {
3784                let existing_lock =
3785                    crate::app::load_instance_lock_from_path(std::path::Path::new(lock_path))
3786                        .unwrap_or_default();
3787                let rollback_root_lock = resolve_rollback_root_lock(
3788                    &name,
3789                    &generation,
3790                    instance_dir.as_deref(),
3791                    existing_lock,
3792                )?;
3793                generation_lock_replayed = rollback_root_lock.generation_lock_replayed;
3794                if let Some(warning) = rollback_root_lock.generation_lock_warning {
3795                    generation_lock_warning = warning;
3796                }
3797                if let Err(e) = crate::app::save_instance_lock_to_path(
3798                    std::path::Path::new(lock_path),
3799                    &rollback_root_lock.lock_file,
3800                ) {
3801                    return Err((
3802                        StatusCode::INTERNAL_SERVER_ERROR,
3803                        Json(serde_json::json!({
3804                            "error": format!("Failed to write lock file for '{}' during rollback: {}", name, e),
3805                            "reason": "lock_write_failed",
3806                            "status": "rollback_failed",
3807                            "lock_written": false,
3808                            "pointer_written": false,
3809                            "pointer_error": serde_json::Value::Null,
3810                            "index_written": false,
3811                            "index_error": serde_json::Value::Null,
3812                            "generation_lock_replayed": generation_lock_replayed,
3813                            "generation_lock_warning": generation_lock_warning,
3814                        })),
3815                    ));
3816                } else {
3817                    lock_written = true;
3818                }
3819            }
3820
3821            let active_generation_id = generation.id;
3822            let previous_generation_id = app_store.candidate.as_ref().map(|candidate| candidate.id);
3823            let (pointer_written, pointer_error, index_written, index_error) = if let Some(
3824                instance_dir,
3825            ) = instance_dir
3826            {
3827                write_generation_pointers(
3828                    &name,
3829                    &instance_dir,
3830                    previous_generation_id,
3831                    active_generation_id,
3832                    lock_written,
3833                    "rollback_failed",
3834                    "rollback",
3835                )?;
3836
3837                let (index_written, index_error) =
3838                    persist_generation_index_best_effort(&name, app_store, &instance_dir);
3839                (true, serde_json::Value::Null, index_written, index_error)
3840            } else {
3841                (
3842                        false,
3843                        serde_json::json!({
3844                            "stage": "instance_dir",
3845                            "repair_needed": false,
3846                            "message": format!(
3847                                "No resolved instance directory available for '{}' to write rollback pointers",
3848                                name
3849                            ),
3850                            "details": "resolved_app_instance_dir returned None",
3851                        }),
3852                        false,
3853                        Some(format!(
3854                            "No resolved instance directory available for '{}' to persist repairable generation index",
3855                            name
3856                        )),
3857                    )
3858            };
3859            Ok(Json(serde_json::json!({
3860                "generation": generation,
3861                "status": "rolled_back",
3862                "lock_written": lock_written,
3863                "generation_lock_replayed": generation_lock_replayed,
3864                "generation_lock_warning": generation_lock_warning,
3865                "pointer_written": pointer_written,
3866                "pointer_error": pointer_error,
3867                "index_written": index_written,
3868                "index_error": index_error,
3869            })))
3870        }
3871        Err(err) => Err((
3872            StatusCode::CONFLICT,
3873            Json(serde_json::json!({"error": err})),
3874        )),
3875    }
3876}
3877
3878pub async fn get_generation(
3879    Path(name): Path<String>,
3880    State(state): State<AppState>,
3881) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3882    let store = state.generation_store.read().await;
3883    let app_store = store.get(&name).ok_or_else(|| {
3884        (
3885            StatusCode::NOT_FOUND,
3886            Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3887        )
3888    })?;
3889
3890    Ok(Json(serde_json::json!({
3891        "active": app_store.active,
3892        "candidate": app_store.candidate,
3893        "rollback": app_store.rollback,
3894    })))
3895}
3896
3897pub async fn get_generation_diff(
3898    Path((name, from_id, to_id)): Path<(String, u64, u64)>,
3899    State(state): State<AppState>,
3900) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3901    let resolved_app = {
3902        let apps = state.resolved_apps.read().await;
3903        apps.get(&name).cloned()
3904    };
3905
3906    let (from_generation, to_generation) = {
3907        let store = state.generation_store.read().await;
3908        let app_store = store.get(&name).ok_or_else(|| {
3909            (
3910                StatusCode::NOT_FOUND,
3911                Json(
3912                    serde_json::json!({"error": format!("No generation store for app '{}'", name)}),
3913                ),
3914            )
3915        })?;
3916
3917        let from_generation = app_store
3918            .generation(from_id)
3919            .cloned()
3920            .ok_or_else(|| generation_diff_not_found(&name, from_id, "from"))?;
3921        let to_generation = app_store
3922            .generation(to_id)
3923            .cloned()
3924            .ok_or_else(|| generation_diff_not_found(&name, to_id, "to"))?;
3925        (from_generation, to_generation)
3926    };
3927
3928    let instance_dir = resolved_app.as_ref().and_then(resolved_app_instance_dir);
3929    let (from_lock, from_path, from_warning) = load_generation_lock_for_diff(&from_generation, instance_dir.as_deref())
3930        .map_err(|error| {
3931            (
3932                StatusCode::INTERNAL_SERVER_ERROR,
3933                Json(serde_json::json!({
3934                    "error": format!(
3935                        "Failed to load immutable generation lock for app '{}' generation {}: {error:#}",
3936                        name, from_generation.id
3937                    ),
3938                    "reason": "generation_lock_load_failed",
3939                    "app": name,
3940                    "generation": from_generation.id,
3941                    "role": "from",
3942                })),
3943            )
3944        })?;
3945    let (to_lock, to_path, to_warning) = load_generation_lock_for_diff(&to_generation, instance_dir.as_deref())
3946        .map_err(|error| {
3947            (
3948                StatusCode::INTERNAL_SERVER_ERROR,
3949                Json(serde_json::json!({
3950                    "error": format!(
3951                        "Failed to load immutable generation lock for app '{}' generation {}: {error:#}",
3952                        name, to_generation.id
3953                    ),
3954                    "reason": "generation_lock_load_failed",
3955                    "app": name,
3956                    "generation": to_generation.id,
3957                    "role": "to",
3958                })),
3959            )
3960        })?;
3961
3962    let mut warnings = Vec::new();
3963    if let Some(warning) = from_warning {
3964        warnings.push(warning);
3965    }
3966    if let Some(warning) = to_warning {
3967        warnings.push(warning);
3968    }
3969
3970    let immutable_lock = match (from_lock.as_ref(), to_lock.as_ref()) {
3971        (Some(from_lock), Some(to_lock)) => ImmutableLockComparison {
3972            available: true,
3973            from_path,
3974            to_path,
3975            diff: Some(immutable_lock_diff(from_lock, to_lock)),
3976        },
3977        _ => ImmutableLockComparison {
3978            available: false,
3979            from_path,
3980            to_path,
3981            diff: None,
3982        },
3983    };
3984
3985    let response = GenerationDiffResponse {
3986        app: name,
3987        from: generation_summary_snapshot(&from_generation),
3988        to: generation_summary_snapshot(&to_generation),
3989        summary_diff: generation_summary_diff(&from_generation, &to_generation),
3990        immutable_lock,
3991        warnings,
3992    };
3993
3994    Ok(Json(serde_json::json!(response)))
3995}
3996
3997pub async fn get_generation_diagnostics(
3998    Path(name): Path<String>,
3999    State(state): State<AppState>,
4000) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
4001    let diagnostics = generation_diagnostics_snapshot(&state, &name).await?;
4002    Ok(Json(serde_json::json!(diagnostics)))
4003}
4004
4005#[cfg(test)]
4006mod tests {
4007    use super::{
4008        activate_existing_generation, activate_generation, generation_diagnostics_snapshot,
4009        generation_lock_path, get_generation_diff, live_probe_generation,
4010        packages_for_selected_names, propose_generation, rollback_generation,
4011        selected_summary_packages, summary_lock_bindings,
4012    };
4013    use crate::api::build_router;
4014    use crate::api::openai_compat::AppState;
4015    use crate::app::{
4016        active_generation_pointer_path, binding_set_id_from_lock_bindings,
4017        closure_id_from_lock_packages, previous_generation_pointer_path, save_generation_index,
4018        sign_package_message, signature_message, write_active_generation_pointer,
4019        write_previous_generation_pointer, AppBindingResolution, AppGeneration, AppGenerationIndex,
4020        AppGenerationStore, AppProfile, CapabilityProviderRecord, CapabilityRegistry,
4021        CapabilityRegistryEntry, CorePolicy, GenerationStatus, GenerationStoreMap, PackageIndex,
4022        PackageSource, ResolvedApp, ResolvedAppMap, ResolvedAppSources,
4023    };
4024    use crate::config::{
4025        AppConfig, CoreConfig, FallbackConfig, KeyStrategyConfig, RegistryConfig, RoutingConfig,
4026    };
4027    use crate::defaults::{
4028        error_handler::DefaultErrorHandler, key_selectors::FailoverSelector, router::DefaultRouter,
4029    };
4030    use crate::pipeline::Pipeline;
4031    use crate::process::ProcessManager;
4032    use crate::vkeys::VirtualKeyStore;
4033    use axum::body::Body;
4034    use axum::extract::{Path, State};
4035    use axum::http::{Request, StatusCode as HttpStatusCode};
4036    use axum::Json;
4037    use ed25519_dalek::SigningKey;
4038    use std::collections::HashMap;
4039    use std::fs;
4040    use std::sync::{Arc, Mutex as StdMutex};
4041    use tempfile::tempdir;
4042    use tokio::sync::RwLock;
4043    use tower::util::ServiceExt;
4044
4045    fn generation_with_diff_fields(
4046        id: u64,
4047        status: GenerationStatus,
4048        scene: &str,
4049        profile: &str,
4050        features: Vec<&str>,
4051        capabilities: Vec<&str>,
4052        bindings: Vec<AppBindingResolution>,
4053    ) -> AppGeneration {
4054        AppGeneration {
4055            id,
4056            app_name: "weft-claw".into(),
4057            version: "0.1.0".into(),
4058            bindings,
4059            capabilities: capabilities.into_iter().map(String::from).collect(),
4060            enabled_features: features.into_iter().map(String::from).collect(),
4061            scene: scene.into(),
4062            profile: profile.into(),
4063            binding_set_id: format!("binding-set:sha256:{id}"),
4064            closure_id: format!("closure:sha256:{id}"),
4065            lock_digest: format!("sha256:lock-{id}"),
4066            lock_path: format!("generations/{id}.lock.toml"),
4067            parent_generation: id.checked_sub(1),
4068            created_by: "api".into(),
4069            status,
4070            validation_results: vec![],
4071            created_at: 1710000000 + id,
4072        }
4073    }
4074
4075    fn write_generation_lock_for_diff(
4076        instance_dir: &std::path::Path,
4077        generation_id: u64,
4078        scene: &str,
4079        profile: &str,
4080        closure_id: &str,
4081        packages: &[&str],
4082        bindings: &[(&str, &str)],
4083    ) {
4084        let package_entries = packages
4085            .iter()
4086            .map(|name| {
4087                format!(
4088                    "[[packages]]\nname = '{name}'\nversion = '0.1.0'\nruntime_provider = '{name}'\n"
4089                )
4090            })
4091            .collect::<Vec<_>>()
4092            .join("\n");
4093        let binding_entries = bindings
4094            .iter()
4095            .map(|(capability, provider)| {
4096                format!(
4097                    "[[bindings]]\ncapability = '{capability}'\nprovider = '{provider}'\npackage = '{provider}'\n"
4098                )
4099            })
4100            .collect::<Vec<_>>()
4101            .join("\n");
4102
4103        write_text_file(
4104            &instance_dir
4105                .join("generations")
4106                .join(format!("{generation_id}.lock.toml")),
4107            &format!(
4108                "lock_version = 2\napp = 'weft-claw'\ngeneration = {generation_id}\nstatus = 'verified'\nprofile = '{profile}'\nscene = '{scene}'\nclosure_id = '{closure_id}'\n\n{package_entries}\n{binding_entries}\n"
4109            ),
4110        );
4111    }
4112
4113    fn test_state(
4114        repo_root: std::path::PathBuf,
4115        capability_registry: CapabilityRegistry,
4116    ) -> AppState {
4117        AppState {
4118            config: Arc::new(RwLock::new(AppConfig {
4119                core: CoreConfig::default(),
4120                providers: vec![],
4121                routing: RoutingConfig::default(),
4122                key_strategy: KeyStrategyConfig::default(),
4123                fallback: FallbackConfig::default(),
4124                virtual_keys: vec![],
4125                services: vec![],
4126                packages: vec![],
4127                registry: RegistryConfig::default(),
4128                package_aliases: HashMap::new(),
4129                web_search: Default::default(),
4130                team: Default::default(),
4131            })),
4132            config_path: repo_root.join("config").join("config.toml"),
4133            pipeline: Arc::new(Pipeline {
4134                router: Arc::new(DefaultRouter {
4135                    default_provider: "".into(),
4136                }),
4137                key_selector: Arc::new(FailoverSelector),
4138                transforms: Arc::new(crate::defaults::transforms::TransformRegistry::with_defaults()),
4139                error_handler: Arc::new(DefaultErrorHandler { max_retries: 0 }),
4140                http_client: reqwest::Client::new(),
4141            }),
4142            process_manager: Arc::new(ProcessManager::new()),
4143            vkey_store: Arc::new(VirtualKeyStore::new()),
4144            package_manager: Arc::new(RwLock::new(crate::package::PackageManager::new())),
4145            wasm_handle: Arc::new(RwLock::new(None)),
4146            native_handle: Arc::new(RwLock::new(None)),
4147            resolved_apps: Arc::new(RwLock::new(Default::default())),
4148            capability_registry: Arc::new(RwLock::new(capability_registry)),
4149            active_profile: Arc::new(RwLock::new(AppProfile::Developer)),
4150            core_policy: Arc::new(CorePolicy::default_policy()),
4151            generation_store: Arc::new(RwLock::new(GenerationStoreMap::new())),
4152            package_index: Arc::new(PackageIndex::default()),
4153            repo_root,
4154            data_dir: std::path::PathBuf::from("data"),
4155            runtime_token: None,
4156            runtime_token_path: None,
4157            chat_providers: Arc::new(RwLock::new(vec![])),
4158            shutdown_tx: Arc::new(StdMutex::new(None)),
4159            stream_buffer: Arc::new(StdMutex::new(std::collections::HashMap::new())),
4160        }
4161    }
4162
4163    fn test_binding(capability: &str, provider: &str) -> AppBindingResolution {
4164        AppBindingResolution {
4165            capability: capability.into(),
4166            provider: provider.into(),
4167            mutable: false,
4168            source: "test".into(),
4169        }
4170    }
4171
4172    fn test_generation(
4173        capability: &str,
4174        provider: &str,
4175        profile: &str,
4176        status: GenerationStatus,
4177    ) -> AppGeneration {
4178        AppGeneration {
4179            id: 1,
4180            app_name: "weft-claw".into(),
4181            version: "0.1.0".into(),
4182            bindings: vec![test_binding(capability, provider)],
4183            capabilities: vec![capability.into()],
4184            enabled_features: vec![],
4185            scene: String::new(),
4186            profile: profile.into(),
4187            binding_set_id: String::new(),
4188            closure_id: String::new(),
4189            lock_digest: String::new(),
4190            lock_path: String::new(),
4191            parent_generation: None,
4192            created_by: String::new(),
4193            status,
4194            validation_results: vec![],
4195            created_at: 0,
4196        }
4197    }
4198
4199    fn registry_with_provider(capability: &str, provider: &str) -> CapabilityRegistry {
4200        let mut registry = CapabilityRegistry::new();
4201        registry.insert(
4202            capability.into(),
4203            CapabilityRegistryEntry {
4204                capability: capability.into(),
4205                providers: vec![CapabilityProviderRecord {
4206                    provider: provider.into(),
4207                    runtime: "metadata".into(),
4208                    priority: 0,
4209                }],
4210                bindings: vec![],
4211            },
4212        );
4213        registry
4214    }
4215
4216    fn candidate_with_verified_identities(
4217        state: &AppState,
4218        config_path: &std::path::Path,
4219        mut candidate: AppGeneration,
4220    ) -> AppGeneration {
4221        let app_config =
4222            crate::app::load_instance_config_from_path(config_path).expect("config loads");
4223        let selected_packages = selected_summary_packages(state, &app_config, &candidate.bindings)
4224            .expect("selected packages resolve");
4225        let lock_bindings = summary_lock_bindings(&candidate.bindings, &selected_packages);
4226        candidate.binding_set_id = binding_set_id_from_lock_bindings(&lock_bindings);
4227        candidate.closure_id = closure_id_from_lock_packages(&selected_packages);
4228        candidate
4229    }
4230
4231    fn test_resolved_app(name: &str, instance_dir: Option<&std::path::Path>) -> ResolvedApp {
4232        ResolvedApp {
4233            name: name.into(),
4234            version: "0.1.0".into(),
4235            capabilities: vec!["core.execution".into()],
4236            bindings: vec![test_binding("core.execution", "core")],
4237            sources: ResolvedAppSources {
4238                manifest_path: String::new(),
4239                config_path: instance_dir.map(|dir| dir.join("config.toml").display().to_string()),
4240                lock_path: instance_dir.map(|dir| dir.join("lock.toml").display().to_string()),
4241            },
4242            ..ResolvedApp::default()
4243        }
4244    }
4245
4246    fn write_text_file(path: &std::path::Path, contents: &str) {
4247        if let Some(parent) = path.parent() {
4248            fs::create_dir_all(parent).expect("parent dir created");
4249        }
4250        fs::write(path, contents).expect("file written");
4251    }
4252
4253    fn test_package_source(
4254        name: &str,
4255        package_kind: &str,
4256        provides: Vec<&str>,
4257        requires: Vec<&str>,
4258    ) -> PackageSource {
4259        PackageSource {
4260            name: name.into(),
4261            kind: if package_kind.contains("feature") {
4262                "embedded".into()
4263            } else {
4264                "wasm".into()
4265            },
4266            package_kind: package_kind.into(),
4267            runtime_provider: name.into(),
4268            current_source: format!("packages/official/{name}"),
4269            trusted: true,
4270            signature: "builtin:test".into(),
4271            source_authority: "test".into(),
4272            source_public_keys: vec![],
4273            provides: provides.into_iter().map(String::from).collect(),
4274            requires: requires.into_iter().map(String::from).collect(),
4275        }
4276    }
4277
4278    fn state_for_propose_generation(
4279        repo_root: std::path::PathBuf,
4280        app: ResolvedApp,
4281        package_sources: Vec<PackageSource>,
4282    ) -> AppState {
4283        let mut apps = ResolvedAppMap::new();
4284        apps.insert(app.name.clone(), app);
4285
4286        AppState {
4287            resolved_apps: Arc::new(RwLock::new(apps)),
4288            package_index: Arc::new(PackageIndex {
4289                version: 1,
4290                revision: "test-rev".into(),
4291                source_url: "local://packages".into(),
4292                package_sources,
4293            }),
4294            ..test_state(repo_root, CapabilityRegistry::new())
4295        }
4296    }
4297
4298    fn state_for_verify_generation(
4299        repo_root: std::path::PathBuf,
4300        app: ResolvedApp,
4301        package_sources: Vec<PackageSource>,
4302        capability_registry: CapabilityRegistry,
4303        candidate: AppGeneration,
4304    ) -> AppState {
4305        let mut stores = GenerationStoreMap::new();
4306        stores.insert(
4307            app.name.clone(),
4308            AppGenerationStore {
4309                active: None,
4310                candidate: Some(candidate),
4311                rollback: None,
4312                next_id: 2,
4313            },
4314        );
4315
4316        AppState {
4317            resolved_apps: Arc::new(RwLock::new({
4318                let mut apps = ResolvedAppMap::new();
4319                apps.insert(app.name.clone(), app);
4320                apps
4321            })),
4322            generation_store: Arc::new(RwLock::new(stores)),
4323            package_index: Arc::new(PackageIndex {
4324                version: 1,
4325                revision: "test-rev".into(),
4326                source_url: "local://packages".into(),
4327                package_sources,
4328            }),
4329            ..test_state(repo_root, capability_registry)
4330        }
4331    }
4332
4333    fn write_test_manifest_and_config(
4334        repo_root: &std::path::Path,
4335        active_scene: Option<&str>,
4336        scene_block: &str,
4337    ) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) {
4338        let package_dir = repo_root.join("packages").join("weft-claw");
4339        let instance_dir = repo_root.join(".weft").join("weft-claw");
4340        let manifest_path = package_dir.join("package.toml");
4341        let config_path = instance_dir.join("config.toml");
4342
4343        write_text_file(
4344            &manifest_path,
4345            r#"schema_version = 1
4346[identity]
4347name = 'weft-claw'
4348version = '0.1.0'
4349display_name = 'WEFT Claw'
4350description = 'test product'
4351[package]
4352kind = 'product'
4353[requires]
4354capabilities = ['agent.runtime']
4355[bindings."agent.runtime"]
4356provider = 'agent-runtime'
4357mutable = false
4358[features]
4359default_enabled = ['feature-default']
4360optional = ['feature-scene-on', 'feature-off', 'feature-extra']
4361[feature_bindings.feature-default]
4362requires = []
4363packages = ['feature-default']
4364[feature_bindings.feature-scene-on]
4365requires = []
4366packages = ['feature-scene-on']
4367[feature_bindings.feature-off]
4368requires = []
4369packages = ['feature-off']
4370[feature_bindings.feature-extra]
4371requires = []
4372packages = ['feature-extra']
4373"#,
4374        );
4375
4376        let active_scene_line = active_scene
4377            .map(|value| format!("active_scene = '{value}'\n"))
4378            .unwrap_or_default();
4379        write_text_file(
4380            &config_path,
4381            &format!(
4382                "schema_version = 1\n{active_scene_line}\n[app_runtime]\nprofile = 'developer'\n\n[features]\nenabled = ['feature-default', 'feature-off']\ndisabled = []\n\n[packages]\nenabled = ['feature-default', 'feature-off']\ndisabled = []\n\n{scene_block}"
4383            ),
4384        );
4385
4386        (package_dir, instance_dir, manifest_path)
4387    }
4388
4389    fn generation_with_id(id: u64, status: GenerationStatus) -> AppGeneration {
4390        AppGeneration {
4391            id,
4392            app_name: "weft-claw".into(),
4393            version: "0.1.0".into(),
4394            bindings: vec![test_binding("core.execution", "core")],
4395            capabilities: vec!["core.execution".into()],
4396            enabled_features: vec![],
4397            scene: "stable".into(),
4398            profile: "developer".into(),
4399            binding_set_id: format!("binding-set:sha256:{id}"),
4400            closure_id: format!("closure:sha256:{id}"),
4401            lock_digest: format!("sha256:lock-{id}"),
4402            lock_path: format!("generations/{id}.lock.toml"),
4403            parent_generation: id.checked_sub(1),
4404            created_by: "api".into(),
4405            status,
4406            validation_results: vec![],
4407            created_at: 1710000000 + id,
4408        }
4409    }
4410
4411    fn generation_with_validation_results(
4412        id: u64,
4413        status: GenerationStatus,
4414        profile: &str,
4415        validation_results: Vec<crate::app::ValidationResult>,
4416    ) -> AppGeneration {
4417        AppGeneration {
4418            profile: profile.into(),
4419            validation_results,
4420            ..generation_with_id(id, status)
4421        }
4422    }
4423
4424    #[tokio::test]
4425    async fn propose_generation_uses_active_scene_summary_metadata() {
4426        let root = tempdir().expect("temp dir");
4427        let repo_root = root.path().to_path_buf();
4428        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4429            &repo_root,
4430            Some("team"),
4431            r#"[scenes.team]
4432name = 'team-scene'
4433profile = 'safe'
4434base_generation = 41
4435enabled_features = ['feature-extra']
4436disabled_features = []
4437"#,
4438        );
4439
4440        let app = ResolvedApp {
4441            name: "weft-claw".into(),
4442            version: "0.1.0".into(),
4443            capabilities: vec!["agent.runtime".into()],
4444            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4445            sources: ResolvedAppSources {
4446                manifest_path: manifest_path.display().to_string(),
4447                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4448                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4449            },
4450            ..ResolvedApp::default()
4451        };
4452        let state = state_for_propose_generation(
4453            repo_root,
4454            app,
4455            vec![
4456                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4457                test_package_source("feature-default", "feature", vec![], vec![]),
4458                test_package_source("feature-off", "feature", vec![], vec![]),
4459                test_package_source("feature-extra", "feature", vec![], vec![]),
4460            ],
4461        );
4462
4463        let response = propose_generation(
4464            Path("weft-claw".into()),
4465            State(state.clone()),
4466            Json(serde_json::json!({})),
4467        )
4468        .await
4469        .expect("proposal succeeds");
4470
4471        let generation = &response.0["generation"];
4472        assert_eq!(generation["scene"], "team-scene");
4473        assert_eq!(generation["profile"], "safe");
4474        assert_eq!(generation["parent_generation"], 41);
4475        assert_eq!(generation["status"], "candidate");
4476    }
4477
4478    #[tokio::test]
4479    async fn propose_generation_overlays_scene_feature_selection_on_candidate() {
4480        let root = tempdir().expect("temp dir");
4481        let repo_root = root.path().to_path_buf();
4482        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4483            &repo_root,
4484            Some("team"),
4485            r#"[scenes.team]
4486name = 'team'
4487enabled_features = ['feature-scene-on']
4488disabled_features = ['feature-off']
4489
4490[[scenes.team.binding_pins]]
4491capability = 'agent.runtime'
4492provider = 'scene-provider'
4493package = 'scene-provider'
4494
4495[[scenes.team.package_pins]]
4496package = 'scene-package'
4497version = '0.1.0'
4498source = 'local-index'
4499"#,
4500        );
4501
4502        let app = ResolvedApp {
4503            name: "weft-claw".into(),
4504            version: "0.1.0".into(),
4505            capabilities: vec!["agent.runtime".into()],
4506            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4507            sources: ResolvedAppSources {
4508                manifest_path: manifest_path.display().to_string(),
4509                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4510                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4511            },
4512            ..ResolvedApp::default()
4513        };
4514        let state = state_for_propose_generation(
4515            repo_root,
4516            app,
4517            vec![
4518                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4519                test_package_source("feature-default", "feature", vec![], vec![]),
4520                test_package_source("feature-off", "feature", vec![], vec![]),
4521                test_package_source("feature-scene-on", "feature", vec![], vec![]),
4522                test_package_source("feature-extra", "feature", vec![], vec![]),
4523                test_package_source("scene-package", "feature", vec![], vec![]),
4524            ],
4525        );
4526
4527        let response = propose_generation(
4528            Path("weft-claw".into()),
4529            State(state.clone()),
4530            Json(serde_json::json!({})),
4531        )
4532        .await
4533        .expect("proposal succeeds");
4534
4535        let generation = &response.0["generation"];
4536        let enabled_features = generation["enabled_features"]
4537            .as_array()
4538            .expect("enabled feature array")
4539            .iter()
4540            .map(|value| value.as_str().expect("feature string"))
4541            .collect::<Vec<_>>();
4542        assert_eq!(
4543            enabled_features,
4544            vec!["feature-default", "feature-scene-on"]
4545        );
4546
4547        let bindings = generation["bindings"].as_array().expect("bindings array");
4548        assert_eq!(bindings.len(), 1);
4549        assert_eq!(bindings[0]["provider"], "scene-provider");
4550        assert_eq!(bindings[0]["source"], "scene:team");
4551    }
4552
4553    #[tokio::test]
4554    async fn propose_generation_scene_binding_pin_adds_binding_for_requested_capability() {
4555        let root = tempdir().expect("temp dir");
4556        let repo_root = root.path().to_path_buf();
4557        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4558            &repo_root,
4559            Some("team"),
4560            r#"[scenes.team]
4561name = 'team'
4562
4563[[scenes.team.binding_pins]]
4564capability = 'team.delegate'
4565package = 'team-runtime'
4566"#,
4567        );
4568
4569        let app = ResolvedApp {
4570            name: "weft-claw".into(),
4571            version: "0.1.0".into(),
4572            capabilities: vec!["agent.runtime".into()],
4573            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4574            sources: ResolvedAppSources {
4575                manifest_path: manifest_path.display().to_string(),
4576                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4577                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4578            },
4579            ..ResolvedApp::default()
4580        };
4581        let state = state_for_propose_generation(
4582            repo_root,
4583            app,
4584            vec![
4585                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4586                test_package_source("team-runtime", "provider", vec!["team.delegate"], vec![]),
4587                test_package_source("feature-default", "feature", vec![], vec![]),
4588                test_package_source("feature-off", "feature", vec![], vec![]),
4589            ],
4590        );
4591
4592        let response = propose_generation(
4593            Path("weft-claw".into()),
4594            State(state.clone()),
4595            Json(serde_json::json!({
4596                "capabilities": ["agent.runtime", "team.delegate"]
4597            })),
4598        )
4599        .await
4600        .expect("proposal succeeds");
4601
4602        let bindings = response.0["generation"]["bindings"]
4603            .as_array()
4604            .expect("bindings array");
4605        assert_eq!(bindings.len(), 2);
4606        assert!(bindings.iter().any(|binding| {
4607            binding["capability"] == "team.delegate"
4608                && binding["provider"] == "team-runtime"
4609                && binding["mutable"] == false
4610                && binding["source"] == "scene:team"
4611        }));
4612    }
4613
4614    #[tokio::test]
4615    async fn propose_generation_scene_binding_pin_ignores_absent_capability() {
4616        let root = tempdir().expect("temp dir");
4617        let repo_root = root.path().to_path_buf();
4618        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4619            &repo_root,
4620            Some("team"),
4621            r#"[scenes.team]
4622name = 'team'
4623
4624[[scenes.team.binding_pins]]
4625capability = 'team.delegate'
4626provider = 'team-runtime'
4627package = 'team-runtime'
4628"#,
4629        );
4630
4631        let app = ResolvedApp {
4632            name: "weft-claw".into(),
4633            version: "0.1.0".into(),
4634            capabilities: vec!["agent.runtime".into()],
4635            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4636            sources: ResolvedAppSources {
4637                manifest_path: manifest_path.display().to_string(),
4638                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4639                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4640            },
4641            ..ResolvedApp::default()
4642        };
4643        let state = state_for_propose_generation(
4644            repo_root,
4645            app,
4646            vec![
4647                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4648                test_package_source("team-runtime", "provider", vec!["team.delegate"], vec![]),
4649                test_package_source("feature-default", "feature", vec![], vec![]),
4650                test_package_source("feature-off", "feature", vec![], vec![]),
4651            ],
4652        );
4653
4654        let response = propose_generation(
4655            Path("weft-claw".into()),
4656            State(state.clone()),
4657            Json(serde_json::json!({})),
4658        )
4659        .await
4660        .expect("proposal succeeds");
4661
4662        let bindings = response.0["generation"]["bindings"]
4663            .as_array()
4664            .expect("bindings array");
4665        assert_eq!(bindings.len(), 1);
4666        assert_eq!(bindings[0]["capability"], "agent.runtime");
4667        assert_eq!(bindings[0]["provider"], "agent-runtime");
4668        assert!(!bindings
4669            .iter()
4670            .any(|binding| binding["capability"] == "team.delegate"));
4671    }
4672
4673    #[tokio::test]
4674    async fn propose_generation_keeps_legacy_behavior_without_matching_active_scene() {
4675        let root = tempdir().expect("temp dir");
4676        let repo_root = root.path().to_path_buf();
4677        let (_, instance_dir, manifest_path) =
4678            write_test_manifest_and_config(&repo_root, Some("missing-scene"), "");
4679
4680        let app = ResolvedApp {
4681            name: "weft-claw".into(),
4682            version: "0.1.0".into(),
4683            capabilities: vec!["agent.runtime".into()],
4684            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4685            sources: ResolvedAppSources {
4686                manifest_path: manifest_path.display().to_string(),
4687                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4688                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4689            },
4690            ..ResolvedApp::default()
4691        };
4692        let state = state_for_propose_generation(
4693            repo_root,
4694            app,
4695            vec![
4696                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4697                test_package_source("feature-default", "feature", vec![], vec![]),
4698                test_package_source("feature-off", "feature", vec![], vec![]),
4699            ],
4700        );
4701
4702        let response = propose_generation(
4703            Path("weft-claw".into()),
4704            State(state.clone()),
4705            Json(serde_json::json!({})),
4706        )
4707        .await
4708        .expect("proposal succeeds");
4709
4710        let generation = &response.0["generation"];
4711        assert_eq!(generation["scene"], "missing-scene");
4712        assert_eq!(generation["profile"], "developer");
4713        assert!(generation["parent_generation"].is_null());
4714        let enabled_features = generation["enabled_features"]
4715            .as_array()
4716            .expect("enabled feature array")
4717            .iter()
4718            .map(|value| value.as_str().expect("feature string"))
4719            .collect::<Vec<_>>();
4720        assert_eq!(enabled_features, vec!["feature-default", "feature-off"]);
4721    }
4722
4723    #[tokio::test]
4724    async fn propose_generation_rejects_duplicate_binding_pin_conflicts() {
4725        let root = tempdir().expect("temp dir");
4726        let repo_root = root.path().to_path_buf();
4727        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4728            &repo_root,
4729            Some("team"),
4730            r#"[scenes.team]
4731name = 'team'
4732
4733[[scenes.team.binding_pins]]
4734capability = 'agent.runtime'
4735provider = 'agent-runtime'
4736package = 'agent-runtime'
4737version = '0.1.0'
4738sha512 = 'sha512:111'
4739
4740[[scenes.team.binding_pins]]
4741capability = 'agent.runtime'
4742provider = 'agent-runtime-alt'
4743package = 'agent-runtime-alt'
4744version = '0.2.0'
4745sha512 = 'sha512:222'
4746"#,
4747        );
4748
4749        let app = ResolvedApp {
4750            name: "weft-claw".into(),
4751            version: "0.1.0".into(),
4752            capabilities: vec!["agent.runtime".into()],
4753            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4754            sources: ResolvedAppSources {
4755                manifest_path: manifest_path.display().to_string(),
4756                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4757                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4758            },
4759            ..ResolvedApp::default()
4760        };
4761        let state = state_for_propose_generation(
4762            repo_root,
4763            app,
4764            vec![test_package_source(
4765                "agent-runtime",
4766                "provider",
4767                vec!["agent.runtime"],
4768                vec![],
4769            )],
4770        );
4771
4772        let (status, body) = propose_generation(
4773            Path("weft-claw".into()),
4774            State(state.clone()),
4775            Json(serde_json::json!({})),
4776        )
4777        .await
4778        .expect_err("proposal should fail for conflicting binding pins");
4779
4780        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
4781        let payload = body.0;
4782        assert_eq!(payload["reason"], "scene_pin_conflict");
4783        assert_eq!(payload["conflict"]["type"], "duplicate_binding_pin");
4784        assert_eq!(payload["conflict"]["scene"], "team");
4785        assert_eq!(payload["conflict"]["capability"], "agent.runtime");
4786        assert_eq!(payload["conflict"]["indexes"], serde_json::json!([0, 1]));
4787
4788        let store = state.generation_store.read().await;
4789        assert!(store.get("weft-claw").is_none());
4790    }
4791
4792    #[tokio::test]
4793    async fn propose_generation_rejects_duplicate_package_pin_conflicts() {
4794        let root = tempdir().expect("temp dir");
4795        let repo_root = root.path().to_path_buf();
4796        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4797            &repo_root,
4798            Some("team"),
4799            r#"[scenes.team]
4800name = 'team'
4801
4802[[scenes.team.package_pins]]
4803package = 'agent-runtime'
4804version = '0.1.0'
4805sha512 = 'sha512:111'
4806source = 'local-index'
4807
4808[[scenes.team.package_pins]]
4809package = 'agent-runtime'
4810version = '0.2.0'
4811sha512 = 'sha512:222'
4812source = 'local-index'
4813"#,
4814        );
4815
4816        let app = ResolvedApp {
4817            name: "weft-claw".into(),
4818            version: "0.1.0".into(),
4819            capabilities: vec!["agent.runtime".into()],
4820            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4821            sources: ResolvedAppSources {
4822                manifest_path: manifest_path.display().to_string(),
4823                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4824                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4825            },
4826            ..ResolvedApp::default()
4827        };
4828        let state = state_for_propose_generation(
4829            repo_root,
4830            app,
4831            vec![test_package_source(
4832                "agent-runtime",
4833                "provider",
4834                vec!["agent.runtime"],
4835                vec![],
4836            )],
4837        );
4838
4839        let (status, body) = propose_generation(
4840            Path("weft-claw".into()),
4841            State(state.clone()),
4842            Json(serde_json::json!({})),
4843        )
4844        .await
4845        .expect_err("proposal should fail for conflicting package pins");
4846
4847        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
4848        let payload = body.0;
4849        assert_eq!(payload["reason"], "scene_pin_conflict");
4850        assert_eq!(payload["conflict"]["type"], "duplicate_package_pin");
4851        assert_eq!(payload["conflict"]["scene"], "team");
4852        assert_eq!(payload["conflict"]["package"], "agent-runtime");
4853        assert_eq!(payload["conflict"]["indexes"], serde_json::json!([0, 1]));
4854
4855        let store = state.generation_store.read().await;
4856        assert!(store.get("weft-claw").is_none());
4857    }
4858
4859    #[tokio::test]
4860    async fn propose_generation_rejects_binding_package_pin_mismatch() {
4861        let root = tempdir().expect("temp dir");
4862        let repo_root = root.path().to_path_buf();
4863        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4864            &repo_root,
4865            Some("team"),
4866            r#"[scenes.team]
4867name = 'team'
4868
4869[[scenes.team.binding_pins]]
4870capability = 'agent.runtime'
4871provider = 'agent-runtime'
4872package = 'agent-runtime'
4873version = '0.1.0'
4874sha512 = 'sha512:111'
4875source = 'local-index'
4876
4877[[scenes.team.package_pins]]
4878package = 'agent-runtime'
4879version = '0.2.0'
4880sha512 = 'sha512:111'
4881source = 'local-index'
4882"#,
4883        );
4884
4885        let app = ResolvedApp {
4886            name: "weft-claw".into(),
4887            version: "0.1.0".into(),
4888            capabilities: vec!["agent.runtime".into()],
4889            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4890            sources: ResolvedAppSources {
4891                manifest_path: manifest_path.display().to_string(),
4892                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4893                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4894            },
4895            ..ResolvedApp::default()
4896        };
4897        let state = state_for_propose_generation(
4898            repo_root,
4899            app,
4900            vec![test_package_source(
4901                "agent-runtime",
4902                "provider",
4903                vec!["agent.runtime"],
4904                vec![],
4905            )],
4906        );
4907
4908        let (status, body) = propose_generation(
4909            Path("weft-claw".into()),
4910            State(state.clone()),
4911            Json(serde_json::json!({})),
4912        )
4913        .await
4914        .expect_err("proposal should fail for binding/package mismatch");
4915
4916        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
4917        let payload = body.0;
4918        assert_eq!(payload["reason"], "scene_pin_conflict");
4919        assert_eq!(payload["conflict"]["type"], "binding_package_mismatch");
4920        assert_eq!(payload["conflict"]["scene"], "team");
4921        assert_eq!(payload["conflict"]["capability"], "agent.runtime");
4922        assert_eq!(payload["conflict"]["package"], "agent-runtime");
4923        assert_eq!(payload["conflict"]["indexes"], serde_json::json!([0, 0]));
4924
4925        let store = state.generation_store.read().await;
4926        assert!(store.get("weft-claw").is_none());
4927    }
4928
4929    #[tokio::test]
4930    async fn propose_generation_accepts_non_conflicting_scene_pins_metadata_only() {
4931        let root = tempdir().expect("temp dir");
4932        let repo_root = root.path().to_path_buf();
4933        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4934            &repo_root,
4935            Some("team"),
4936            r#"[scenes.team]
4937name = 'team'
4938profile = 'developer'
4939enabled_features = ['feature-extra']
4940
4941[[scenes.team.binding_pins]]
4942capability = 'agent.runtime'
4943provider = 'agent-runtime'
4944package = 'agent-runtime'
4945version = '0.1.0'
4946sha512 = 'sha512:111'
4947source = 'local-index'
4948
4949[[scenes.team.package_pins]]
4950package = 'agent-runtime'
4951version = '0.1.0'
4952sha512 = 'sha512:111'
4953source = 'local-index'
4954"#,
4955        );
4956
4957        let app = ResolvedApp {
4958            name: "weft-claw".into(),
4959            version: "0.1.0".into(),
4960            capabilities: vec!["agent.runtime".into()],
4961            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4962            sources: ResolvedAppSources {
4963                manifest_path: manifest_path.display().to_string(),
4964                config_path: Some(instance_dir.join("config.toml").display().to_string()),
4965                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4966            },
4967            ..ResolvedApp::default()
4968        };
4969        let state = state_for_propose_generation(
4970            repo_root,
4971            app,
4972            vec![
4973                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4974                test_package_source("feature-default", "feature", vec![], vec![]),
4975                test_package_source("feature-off", "feature", vec![], vec![]),
4976                test_package_source("feature-extra", "feature", vec![], vec![]),
4977            ],
4978        );
4979
4980        let response = propose_generation(
4981            Path("weft-claw".into()),
4982            State(state.clone()),
4983            Json(serde_json::json!({})),
4984        )
4985        .await
4986        .expect("proposal succeeds for non-conflicting scene pins");
4987
4988        let generation = &response.0["generation"];
4989        assert_eq!(response.0["status"], "proposed");
4990        assert_eq!(generation["scene"], "team");
4991        assert_eq!(generation["profile"], "developer");
4992        assert_eq!(generation["status"], "candidate");
4993        let bindings = generation["bindings"].as_array().expect("bindings array");
4994        assert_eq!(bindings.len(), 1);
4995        assert_eq!(bindings[0]["provider"], "agent-runtime");
4996        assert_eq!(bindings[0]["source"], "scene:team");
4997
4998        let store = state.generation_store.read().await;
4999        let app_store = store.get("weft-claw").expect("store entry present");
5000        assert!(app_store.candidate.is_some());
5001        assert_eq!(app_store.next_generation_id(), 2);
5002    }
5003
5004    #[tokio::test]
5005    async fn propose_generation_scene_package_pin_adds_package_to_candidate_closure_summary() {
5006        let root = tempdir().expect("temp dir");
5007        let repo_root = root.path().to_path_buf();
5008        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
5009            &repo_root,
5010            Some("team"),
5011            r#"[scenes.team]
5012name = 'team'
5013
5014[[scenes.team.package_pins]]
5015package = 'scene-package'
5016"#,
5017        );
5018
5019        let app = ResolvedApp {
5020            name: "weft-claw".into(),
5021            version: "0.1.0".into(),
5022            capabilities: vec!["agent.runtime".into()],
5023            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
5024            sources: ResolvedAppSources {
5025                manifest_path: manifest_path.display().to_string(),
5026                config_path: Some(instance_dir.join("config.toml").display().to_string()),
5027                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
5028            },
5029            ..ResolvedApp::default()
5030        };
5031        let state = state_for_propose_generation(
5032            repo_root,
5033            app,
5034            vec![
5035                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
5036                test_package_source("feature-default", "feature", vec![], vec![]),
5037                test_package_source("feature-off", "feature", vec![], vec![]),
5038                test_package_source("scene-package", "provider", vec!["scene.extra"], vec![]),
5039            ],
5040        );
5041
5042        let _ = propose_generation(
5043            Path("weft-claw".into()),
5044            State(state.clone()),
5045            Json(serde_json::json!({})),
5046        )
5047        .await
5048        .expect("proposal succeeds");
5049
5050        let candidate = {
5051            let store = state.generation_store.read().await;
5052            store
5053                .get("weft-claw")
5054                .and_then(|app_store| app_store.candidate.clone())
5055                .expect("candidate stored")
5056        };
5057
5058        let app_config =
5059            crate::app::load_instance_config_from_path(&instance_dir.join("config.toml"))
5060                .expect("config loads");
5061        let selected_packages = selected_summary_packages(&state, &app_config, &candidate.bindings)
5062            .expect("selected packages resolve");
5063
5064        assert!(selected_packages
5065            .iter()
5066            .any(|package| package.name == "scene-package"));
5067        assert_eq!(
5068            candidate.closure_id,
5069            closure_id_from_lock_packages(&selected_packages)
5070        );
5071    }
5072
5073    #[tokio::test]
5074    async fn propose_generation_scene_package_pin_copies_exact_metadata_into_summary() {
5075        let root = tempdir().expect("temp dir");
5076        let repo_root = root.path().to_path_buf();
5077        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
5078            &repo_root,
5079            Some("team"),
5080            r#"[scenes.team]
5081name = 'team'
5082
5083[[scenes.team.package_pins]]
5084package = 'scene-package'
5085version = '1.2.3'
5086sha512 = 'sha512:scene-package'
5087source = 'local-index'
5088"#,
5089        );
5090
5091        let app = ResolvedApp {
5092            name: "weft-claw".into(),
5093            version: "0.1.0".into(),
5094            capabilities: vec!["agent.runtime".into()],
5095            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
5096            sources: ResolvedAppSources {
5097                manifest_path: manifest_path.display().to_string(),
5098                config_path: Some(instance_dir.join("config.toml").display().to_string()),
5099                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
5100            },
5101            ..ResolvedApp::default()
5102        };
5103        let state = state_for_propose_generation(
5104            repo_root,
5105            app,
5106            vec![
5107                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
5108                test_package_source("feature-default", "feature", vec![], vec![]),
5109                test_package_source("feature-off", "feature", vec![], vec![]),
5110                test_package_source("scene-package", "provider", vec!["scene.extra"], vec![]),
5111            ],
5112        );
5113
5114        let response = propose_generation(
5115            Path("weft-claw".into()),
5116            State(state.clone()),
5117            Json(serde_json::json!({})),
5118        )
5119        .await
5120        .expect("proposal succeeds");
5121
5122        let candidate = {
5123            let store = state.generation_store.read().await;
5124            store
5125                .get("weft-claw")
5126                .and_then(|app_store| app_store.candidate.clone())
5127                .expect("candidate stored")
5128        };
5129
5130        let app_config =
5131            crate::app::load_instance_config_from_path(&instance_dir.join("config.toml"))
5132                .expect("config loads");
5133        let selected_packages = selected_summary_packages(&state, &app_config, &candidate.bindings)
5134            .expect("selected packages resolve");
5135        let scene_package = selected_packages
5136            .iter()
5137            .find(|package| package.name == "scene-package")
5138            .expect("scene package present");
5139
5140        assert_eq!(scene_package.version, "1.2.3");
5141        assert_eq!(scene_package.sha512, "sha512:scene-package");
5142        assert_eq!(scene_package.source, "local-index");
5143        assert_eq!(
5144            response.0["generation"]["closure_id"],
5145            candidate.closure_id.as_str()
5146        );
5147    }
5148
5149    #[tokio::test]
5150    async fn propose_generation_rejects_unresolved_scene_package_pin_without_replacing_candidate() {
5151        let root = tempdir().expect("temp dir");
5152        let repo_root = root.path().to_path_buf();
5153        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
5154            &repo_root,
5155            Some("team"),
5156            r#"[scenes.team]
5157name = 'team'
5158
5159[[scenes.team.package_pins]]
5160package = 'missing-package'
5161"#,
5162        );
5163
5164        let app = ResolvedApp {
5165            name: "weft-claw".into(),
5166            version: "0.1.0".into(),
5167            capabilities: vec!["agent.runtime".into()],
5168            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
5169            sources: ResolvedAppSources {
5170                manifest_path: manifest_path.display().to_string(),
5171                config_path: Some(instance_dir.join("config.toml").display().to_string()),
5172                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
5173            },
5174            ..ResolvedApp::default()
5175        };
5176
5177        let existing_candidate = generation_with_id(9, GenerationStatus::Candidate);
5178        let state = state_for_propose_generation_with_store(
5179            repo_root,
5180            app,
5181            vec![
5182                test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
5183                test_package_source("feature-default", "feature", vec![], vec![]),
5184                test_package_source("feature-off", "feature", vec![], vec![]),
5185            ],
5186            AppGenerationStore {
5187                active: None,
5188                candidate: Some(existing_candidate.clone()),
5189                rollback: None,
5190                next_id: 10,
5191            },
5192        );
5193
5194        let (status, body) = propose_generation(
5195            Path("weft-claw".into()),
5196            State(state.clone()),
5197            Json(serde_json::json!({})),
5198        )
5199        .await
5200        .expect_err("proposal should fail for unresolved package pin");
5201
5202        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
5203        let payload = body.0;
5204        assert_eq!(payload["reason"], "scene_package_pin_unresolved");
5205        assert_eq!(payload["scene"], "team");
5206        assert_eq!(payload["package"], "missing-package");
5207        assert_eq!(payload["index"], 0);
5208
5209        let store = state.generation_store.read().await;
5210        let candidate = store
5211            .get("weft-claw")
5212            .and_then(|app_store| app_store.candidate.as_ref())
5213            .expect("existing candidate preserved");
5214        assert_eq!(candidate.id, existing_candidate.id);
5215        assert_eq!(candidate.closure_id, existing_candidate.closure_id);
5216    }
5217
5218    fn state_with_generation_store(
5219        repo_root: std::path::PathBuf,
5220        app: ResolvedApp,
5221        app_store: AppGenerationStore,
5222    ) -> AppState {
5223        let mut apps = ResolvedAppMap::new();
5224        apps.insert(app.name.clone(), app);
5225
5226        let mut stores = GenerationStoreMap::new();
5227        stores.insert("weft-claw".into(), app_store);
5228
5229        AppState {
5230            resolved_apps: Arc::new(RwLock::new(apps)),
5231            generation_store: Arc::new(RwLock::new(stores)),
5232            ..test_state(repo_root, CapabilityRegistry::new())
5233        }
5234    }
5235
5236    fn state_for_propose_generation_with_store(
5237        repo_root: std::path::PathBuf,
5238        app: ResolvedApp,
5239        package_sources: Vec<PackageSource>,
5240        app_store: AppGenerationStore,
5241    ) -> AppState {
5242        let mut state = state_for_propose_generation(repo_root, app, package_sources);
5243        state.generation_store = Arc::new(RwLock::new({
5244            let mut stores = GenerationStoreMap::new();
5245            stores.insert("weft-claw".into(), app_store);
5246            stores
5247        }));
5248        state
5249    }
5250
5251    #[tokio::test]
5252    async fn activate_generation_persists_repairable_generation_index_after_success() {
5253        let root = tempdir().expect("temp dir");
5254        let repo_root = root.path().to_path_buf();
5255        let instance_dir = repo_root.join(".weft").join("weft-claw");
5256        fs::create_dir_all(&instance_dir).expect("instance dir created");
5257        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5258            .expect("lock file seeded");
5259
5260        let state = state_with_generation_store(
5261            repo_root,
5262            test_resolved_app("weft-claw", Some(&instance_dir)),
5263            AppGenerationStore {
5264                active: Some(generation_with_id(1, GenerationStatus::Active)),
5265                candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5266                rollback: None,
5267                next_id: 3,
5268            },
5269        );
5270
5271        let response = activate_generation(Path("weft-claw".into()), State(state))
5272            .await
5273            .expect("activation succeeds");
5274
5275        let payload = response.0;
5276        assert_eq!(payload["status"], "activated");
5277        assert_eq!(payload["generation_lock_written"], true);
5278        assert_eq!(payload["lock_written"], true);
5279        assert_eq!(payload["pointer_written"], true);
5280        assert!(payload["pointer_error"].is_null());
5281        assert_eq!(payload["index_written"], true);
5282        assert!(payload["index_error"].is_null());
5283
5284        let generation_lock_path = instance_dir.join("generations").join("2.lock.toml");
5285        assert!(generation_lock_path.exists());
5286        let generation_lock = crate::app::load_instance_lock_from_path(&generation_lock_path)
5287            .expect("generation lock loads");
5288        assert_eq!(generation_lock.generation, 2);
5289        assert_eq!(generation_lock.status, "active");
5290
5291        let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5292            .expect("root lock loads");
5293        assert_eq!(root_lock.generation, 2);
5294        assert_eq!(root_lock.status, "active");
5295
5296        assert_eq!(
5297            crate::app::read_previous_generation_pointer(&instance_dir)
5298                .expect("previous pointer read succeeds"),
5299            Some(1)
5300        );
5301        assert_eq!(
5302            crate::app::read_active_generation_pointer(&instance_dir)
5303                .expect("active pointer read succeeds"),
5304            Some(2)
5305        );
5306
5307        let index = crate::app::load_generation_index(&instance_dir)
5308            .expect("index load succeeds")
5309            .expect("index present");
5310        assert_eq!(index.active, Some(2));
5311        assert_eq!(index.previous, Some(1));
5312        assert_eq!(index.candidate, None);
5313        assert_eq!(index.next_id, 3);
5314        assert_eq!(index.generations.len(), 2);
5315    }
5316
5317    #[tokio::test]
5318    async fn activate_generation_succeeds_when_generation_index_persist_fails() {
5319        let root = tempdir().expect("temp dir");
5320        let repo_root = root.path().to_path_buf();
5321        let instance_dir = repo_root.join(".weft").join("weft-claw");
5322        fs::create_dir_all(&instance_dir).expect("instance dir created");
5323        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5324            .expect("lock file seeded");
5325        fs::write(
5326            instance_dir.join("generation-store.toml"),
5327            "schema_version = 1\nnext_id = 1\ngenerations = []\n",
5328        )
5329        .expect("existing generation index seeded");
5330        fs::create_dir_all(instance_dir.join("generation-store.toml.bak"))
5331            .expect("backup dir created to force index write failure");
5332
5333        let state = state_with_generation_store(
5334            repo_root,
5335            test_resolved_app("weft-claw", Some(&instance_dir)),
5336            AppGenerationStore {
5337                active: Some(generation_with_id(1, GenerationStatus::Active)),
5338                candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5339                rollback: None,
5340                next_id: 3,
5341            },
5342        );
5343
5344        let response = activate_generation(Path("weft-claw".into()), State(state))
5345            .await
5346            .expect("activation still succeeds");
5347
5348        let payload = response.0;
5349        assert_eq!(payload["status"], "activated");
5350        assert_eq!(payload["generation_lock_written"], true);
5351        assert_eq!(payload["lock_written"], true);
5352        assert_eq!(payload["pointer_written"], true);
5353        assert!(payload["pointer_error"].is_null());
5354        assert_eq!(payload["index_written"], false);
5355        assert!(payload["index_error"]
5356            .as_str()
5357            .expect("index error string")
5358            .contains("Failed to persist repairable generation index"));
5359    }
5360
5361    #[tokio::test]
5362    async fn activate_generation_fails_when_previous_pointer_write_fails() {
5363        let root = tempdir().expect("temp dir");
5364        let repo_root = root.path().to_path_buf();
5365        let instance_dir = repo_root.join(".weft").join("weft-claw");
5366        fs::create_dir_all(&instance_dir).expect("instance dir created");
5367        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5368            .expect("lock file seeded");
5369        fs::write(previous_generation_pointer_path(&instance_dir), "0\n")
5370            .expect("previous pointer seeded");
5371        fs::create_dir_all(instance_dir.join("previous.bak"))
5372            .expect("backup dir created to force previous pointer write failure");
5373
5374        let state = state_with_generation_store(
5375            repo_root,
5376            test_resolved_app("weft-claw", Some(&instance_dir)),
5377            AppGenerationStore {
5378                active: Some(generation_with_id(1, GenerationStatus::Active)),
5379                candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5380                rollback: None,
5381                next_id: 3,
5382            },
5383        );
5384
5385        let (status, body) = activate_generation(Path("weft-claw".into()), State(state))
5386            .await
5387            .expect_err("activation should fail when previous pointer write fails");
5388
5389        assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5390        let payload = body.0;
5391        assert_eq!(payload["status"], "activation_failed");
5392        assert_eq!(payload["reason"], "pointer_write_failed");
5393        assert_eq!(payload["generation_lock_written"], true);
5394        assert_eq!(payload["lock_written"], true);
5395        assert_eq!(payload["pointer_written"], false);
5396        assert_eq!(payload["pointer_error"]["stage"], "previous");
5397        assert_eq!(payload["pointer_error"]["repair_needed"], false);
5398        assert_eq!(payload["index_written"], false);
5399        assert!(payload["index_error"].is_null());
5400        assert!(payload["error"]
5401            .as_str()
5402            .expect("error string")
5403            .contains("Failed to write previous generation pointer"));
5404
5405        assert_eq!(
5406            crate::app::read_active_generation_pointer(&instance_dir)
5407                .expect("active pointer read succeeds"),
5408            None
5409        );
5410        assert_eq!(
5411            crate::app::read_previous_generation_pointer(&instance_dir)
5412                .expect("previous pointer read succeeds"),
5413            Some(0)
5414        );
5415        assert!(crate::app::load_generation_index(&instance_dir)
5416            .expect("index load succeeds")
5417            .is_none());
5418    }
5419
5420    #[tokio::test]
5421    async fn activate_generation_fails_when_active_pointer_write_fails_and_skips_index() {
5422        let root = tempdir().expect("temp dir");
5423        let repo_root = root.path().to_path_buf();
5424        let instance_dir = repo_root.join(".weft").join("weft-claw");
5425        fs::create_dir_all(&instance_dir).expect("instance dir created");
5426        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5427            .expect("lock file seeded");
5428        fs::write(active_generation_pointer_path(&instance_dir), "1\n")
5429            .expect("active pointer seeded");
5430        fs::create_dir_all(instance_dir.join("active.bak"))
5431            .expect("backup dir created to force active pointer write failure");
5432
5433        let state = state_with_generation_store(
5434            repo_root,
5435            test_resolved_app("weft-claw", Some(&instance_dir)),
5436            AppGenerationStore {
5437                active: Some(generation_with_id(1, GenerationStatus::Active)),
5438                candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5439                rollback: None,
5440                next_id: 3,
5441            },
5442        );
5443
5444        let (status, body) = activate_generation(Path("weft-claw".into()), State(state))
5445            .await
5446            .expect_err("activation should fail when active pointer write fails");
5447
5448        assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5449        let payload = body.0;
5450        assert_eq!(payload["status"], "activation_failed");
5451        assert_eq!(payload["reason"], "pointer_write_failed");
5452        assert_eq!(payload["generation_lock_written"], true);
5453        assert_eq!(payload["lock_written"], true);
5454        assert_eq!(payload["pointer_written"], false);
5455        assert_eq!(payload["pointer_error"]["stage"], "active");
5456        assert_eq!(payload["pointer_error"]["repair_needed"], true);
5457        assert_eq!(payload["index_written"], false);
5458        assert!(payload["index_error"].is_null());
5459        assert!(payload["pointer_error"]["message"]
5460            .as_str()
5461            .expect("pointer error message")
5462            .contains("repair is required"));
5463
5464        assert_eq!(
5465            crate::app::read_previous_generation_pointer(&instance_dir)
5466                .expect("previous pointer read succeeds"),
5467            Some(1)
5468        );
5469        assert_eq!(
5470            crate::app::read_active_generation_pointer(&instance_dir)
5471                .expect("active pointer read succeeds"),
5472            Some(1)
5473        );
5474        assert!(crate::app::load_generation_index(&instance_dir)
5475            .expect("index load succeeds")
5476            .is_none());
5477    }
5478
5479    #[tokio::test]
5480    async fn activate_generation_allows_existing_same_generation_lock() {
5481        let root = tempdir().expect("temp dir");
5482        let instance_dir = root.path().join(".weft").join("weft-claw");
5483        fs::create_dir_all(&instance_dir).expect("instance dir created");
5484        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5485            .expect("root lock seeded");
5486
5487        let first_state = state_with_generation_store(
5488            root.path().to_path_buf(),
5489            test_resolved_app("weft-claw", Some(&instance_dir)),
5490            AppGenerationStore {
5491                active: Some(generation_with_id(1, GenerationStatus::Active)),
5492                candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5493                rollback: None,
5494                next_id: 3,
5495            },
5496        );
5497
5498        let first_response = activate_generation(Path("weft-claw".into()), State(first_state))
5499            .await
5500            .expect("first activation succeeds");
5501        assert_eq!(first_response.0["generation_lock_written"], true);
5502
5503        let second_state = state_with_generation_store(
5504            root.path().to_path_buf(),
5505            test_resolved_app("weft-claw", Some(&instance_dir)),
5506            AppGenerationStore {
5507                active: Some(generation_with_id(1, GenerationStatus::Active)),
5508                candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5509                rollback: None,
5510                next_id: 3,
5511            },
5512        );
5513
5514        let second_response = activate_generation(Path("weft-claw".into()), State(second_state))
5515            .await
5516            .expect("second activation succeeds with same immutable lock");
5517        let payload = second_response.0;
5518        assert_eq!(payload["status"], "activated");
5519        assert_eq!(payload["generation_lock_written"], true);
5520        assert_eq!(payload["lock_written"], true);
5521        assert_eq!(payload["pointer_written"], true);
5522        assert_eq!(payload["index_written"], true);
5523
5524        let generation_lock = crate::app::load_instance_lock_from_path(
5525            &instance_dir.join("generations").join("2.lock.toml"),
5526        )
5527        .expect("generation lock still loads");
5528        assert_eq!(generation_lock.generation, 2);
5529
5530        let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5531            .expect("root lock loads after idempotent activation");
5532        assert_eq!(root_lock.generation, 2);
5533    }
5534
5535    #[tokio::test]
5536    async fn activate_generation_fails_when_existing_generation_lock_differs_and_skips_root_pointers_and_index(
5537    ) {
5538        let root = tempdir().expect("temp dir");
5539        let repo_root = root.path().to_path_buf();
5540        let instance_dir = repo_root.join(".weft").join("weft-claw");
5541        fs::create_dir_all(instance_dir.join("generations")).expect("generations dir created");
5542        fs::write(instance_dir.join("lock.toml"), "app = \"stale-root\"\n")
5543            .expect("root lock seeded");
5544        fs::write(
5545            instance_dir.join("generations").join("2.lock.toml"),
5546            "lock_version = 2\napp = 'weft-claw'\ngeneration = 2\nstatus = 'verified'\nprofile = 'developer'\nscene = 'conflict'\n",
5547        )
5548        .expect("conflicting generation lock seeded");
5549
5550        let state = state_with_generation_store(
5551            repo_root,
5552            test_resolved_app("weft-claw", Some(&instance_dir)),
5553            AppGenerationStore {
5554                active: Some(generation_with_id(1, GenerationStatus::Active)),
5555                candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5556                rollback: None,
5557                next_id: 3,
5558            },
5559        );
5560
5561        let (status, body) = activate_generation(Path("weft-claw".into()), State(state))
5562            .await
5563            .expect_err("activation should fail on immutable generation lock conflict");
5564
5565        assert_eq!(status, HttpStatusCode::CONFLICT);
5566        let payload = body.0;
5567        assert_eq!(payload["status"], "activation_failed");
5568        assert_eq!(payload["reason"], "generation_lock_conflict");
5569        assert_eq!(payload["lock_written"], false);
5570        assert_eq!(payload["pointer_written"], false);
5571        assert_eq!(payload["index_written"], false);
5572        assert_eq!(payload["generation_lock_error"]["type"], "content_conflict");
5573
5574        let root_lock_contents =
5575            fs::read_to_string(instance_dir.join("lock.toml")).expect("root lock still readable");
5576        assert_eq!(root_lock_contents, "app = \"stale-root\"\n");
5577        assert_eq!(
5578            crate::app::read_active_generation_pointer(&instance_dir)
5579                .expect("active pointer read succeeds"),
5580            None
5581        );
5582        assert_eq!(
5583            crate::app::read_previous_generation_pointer(&instance_dir)
5584                .expect("previous pointer read succeeds"),
5585            None
5586        );
5587        assert!(crate::app::load_generation_index(&instance_dir)
5588            .expect("index load succeeds")
5589            .is_none());
5590    }
5591
5592    #[tokio::test]
5593    async fn rollback_generation_persists_repairable_generation_index_after_success() {
5594        let root = tempdir().expect("temp dir");
5595        let repo_root = root.path().to_path_buf();
5596        let instance_dir = repo_root.join(".weft").join("weft-claw");
5597        fs::create_dir_all(&instance_dir).expect("instance dir created");
5598        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5599            .expect("lock file seeded");
5600
5601        let state = state_with_generation_store(
5602            repo_root,
5603            test_resolved_app("weft-claw", Some(&instance_dir)),
5604            AppGenerationStore {
5605                active: Some(generation_with_id(2, GenerationStatus::Active)),
5606                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5607                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5608                next_id: 4,
5609            },
5610        );
5611
5612        let response = rollback_generation(Path("weft-claw".into()), State(state))
5613            .await
5614            .expect("rollback succeeds");
5615
5616        let payload = response.0;
5617        assert_eq!(payload["status"], "rolled_back");
5618        assert_eq!(payload["lock_written"], true);
5619        assert_eq!(payload["pointer_written"], true);
5620        assert!(payload["pointer_error"].is_null());
5621        assert_eq!(payload["index_written"], true);
5622        assert!(payload["index_error"].is_null());
5623
5624        assert_eq!(
5625            crate::app::read_previous_generation_pointer(&instance_dir)
5626                .expect("previous pointer read succeeds"),
5627            Some(2)
5628        );
5629        assert_eq!(
5630            crate::app::read_active_generation_pointer(&instance_dir)
5631                .expect("active pointer read succeeds"),
5632            Some(1)
5633        );
5634
5635        let index = crate::app::load_generation_index(&instance_dir)
5636            .expect("index load succeeds")
5637            .expect("index present");
5638        assert_eq!(index.active, Some(1));
5639        assert_eq!(index.previous, None);
5640        assert_eq!(index.candidate, Some(2));
5641        assert_eq!(index.next_id, 4);
5642        assert_eq!(index.generations.len(), 2);
5643    }
5644
5645    #[tokio::test]
5646    async fn rollback_generation_replays_immutable_lock_into_root_mirror() {
5647        let root = tempdir().expect("temp dir");
5648        let repo_root = root.path().to_path_buf();
5649        let instance_dir = repo_root.join(".weft").join("weft-claw");
5650        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
5651        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5652            .expect("lock file seeded");
5653        write_text_file(
5654            &instance_dir.join("generations").join("1.lock.toml"),
5655            r#"lock_version = 2
5656app = 'weft-claw'
5657generation = 1
5658status = 'verified'
5659profile = 'developer'
5660scene = 'rollback-scene'
5661scene_digest = 'sha256:scene-1'
5662binding_set_id = 'binding-set:sha256:1'
5663closure_id = 'closure:sha256:1'
5664closure_digest = 'sha256:closure-1'
5665trust_level = 'verified'
5666
5667[assembly]
5668enabled_features = ['immutable-feature']
5669selected_packages = ['immutable-package']
5670scene = 'rollback-scene'
5671scene_digest = 'sha256:scene-1'
5672binding_set_id = 'binding-set:sha256:1'
5673closure_id = 'closure:sha256:1'
5674closure_digest = 'sha256:closure-1'
5675
5676[notes]
5677message = 'immutable lock payload'
5678"#,
5679        );
5680
5681        let state = state_with_generation_store(
5682            repo_root,
5683            test_resolved_app("weft-claw", Some(&instance_dir)),
5684            AppGenerationStore {
5685                active: Some(generation_with_id(2, GenerationStatus::Active)),
5686                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5687                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5688                next_id: 4,
5689            },
5690        );
5691
5692        let response = rollback_generation(Path("weft-claw".into()), State(state))
5693            .await
5694            .expect("rollback succeeds");
5695
5696        let payload = response.0;
5697        assert_eq!(payload["status"], "rolled_back");
5698        assert_eq!(payload["generation_lock_replayed"], true);
5699        assert!(payload["generation_lock_warning"].is_null());
5700
5701        let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5702            .expect("root lock loads");
5703        assert_eq!(root_lock.generation, 1);
5704        assert_eq!(root_lock.status, "active");
5705        assert_eq!(root_lock.scene, "rollback-scene");
5706        assert_eq!(root_lock.scene_digest, "sha256:scene-1");
5707        assert_eq!(root_lock.binding_set_id, "binding-set:sha256:1");
5708        assert_eq!(root_lock.closure_id, "closure:sha256:1");
5709        assert_eq!(root_lock.closure_digest, "sha256:closure-1");
5710        assert_eq!(
5711            root_lock.assembly.enabled_features,
5712            vec!["immutable-feature"]
5713        );
5714        assert_eq!(
5715            root_lock.assembly.selected_packages,
5716            vec!["immutable-package".to_string()]
5717        );
5718        assert_eq!(root_lock.notes.message, "immutable lock payload");
5719    }
5720
5721    #[tokio::test]
5722    async fn rollback_generation_falls_back_when_immutable_lock_is_missing() {
5723        let root = tempdir().expect("temp dir");
5724        let repo_root = root.path().to_path_buf();
5725        let instance_dir = repo_root.join(".weft").join("weft-claw");
5726        fs::create_dir_all(&instance_dir).expect("instance dir created");
5727        write_text_file(
5728            &instance_dir.join("lock.toml"),
5729            r#"lock_version = 2
5730app = 'weft-claw'
5731generation = 2
5732status = 'active'
5733profile = 'developer'
5734scene = 'existing-scene'
5735scene_digest = 'sha256:existing-scene'
5736binding_set_id = 'binding-set:sha256:existing'
5737closure_id = 'closure:sha256:existing'
5738closure_digest = 'sha256:closure-existing'
5739
5740[assembly]
5741enabled_features = ['existing-feature']
5742selected_packages = ['existing-package']
5743scene = 'existing-scene'
5744scene_digest = 'sha256:existing-scene'
5745binding_set_id = 'binding-set:sha256:existing'
5746closure_id = 'closure:sha256:existing'
5747closure_digest = 'sha256:closure-existing'
5748
5749[notes]
5750message = 'existing root lock'
5751"#,
5752        );
5753
5754        let state = state_with_generation_store(
5755            repo_root,
5756            test_resolved_app("weft-claw", Some(&instance_dir)),
5757            AppGenerationStore {
5758                active: Some(generation_with_id(2, GenerationStatus::Active)),
5759                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5760                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5761                next_id: 4,
5762            },
5763        );
5764
5765        let response = rollback_generation(Path("weft-claw".into()), State(state))
5766            .await
5767            .expect("rollback succeeds with fallback");
5768
5769        let payload = response.0;
5770        assert_eq!(payload["status"], "rolled_back");
5771        assert_eq!(payload["generation_lock_replayed"], false);
5772        assert_eq!(
5773            payload["generation_lock_warning"]["type"],
5774            "immutable_lock_missing"
5775        );
5776
5777        let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5778            .expect("root lock loads");
5779        assert_eq!(root_lock.generation, 1);
5780        assert_eq!(root_lock.status, "active");
5781        assert_eq!(root_lock.scene, "existing-scene");
5782        assert_eq!(root_lock.scene_digest, "sha256:existing-scene");
5783        assert_eq!(root_lock.binding_set_id, "binding-set:sha256:existing");
5784        assert_eq!(root_lock.closure_id, "closure:sha256:existing");
5785        assert_eq!(root_lock.closure_digest, "sha256:closure-existing");
5786        assert_eq!(root_lock.assembly.enabled_features, Vec::<String>::new());
5787        assert_eq!(
5788            root_lock.assembly.selected_packages,
5789            vec!["existing-package".to_string()]
5790        );
5791        assert_eq!(
5792            root_lock.notes.message,
5793            "Rolled back to generation 1 via API"
5794        );
5795    }
5796
5797    #[tokio::test]
5798    async fn rollback_generation_fails_before_writes_when_immutable_lock_generation_mismatches() {
5799        let root = tempdir().expect("temp dir");
5800        let repo_root = root.path().to_path_buf();
5801        let instance_dir = repo_root.join(".weft").join("weft-claw");
5802        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
5803        fs::write(instance_dir.join("lock.toml"), "app = \"stale-root\"\n")
5804            .expect("lock file seeded");
5805        write_text_file(
5806            &instance_dir.join("generations").join("1.lock.toml"),
5807            r#"lock_version = 2
5808app = 'weft-claw'
5809generation = 99
5810status = 'verified'
5811profile = 'developer'
5812scene = 'wrong-generation'
5813"#,
5814        );
5815
5816        let state = state_with_generation_store(
5817            repo_root,
5818            test_resolved_app("weft-claw", Some(&instance_dir)),
5819            AppGenerationStore {
5820                active: Some(generation_with_id(2, GenerationStatus::Active)),
5821                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5822                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5823                next_id: 4,
5824            },
5825        );
5826
5827        let (status, body) = rollback_generation(Path("weft-claw".into()), State(state))
5828            .await
5829            .expect_err("rollback should fail on mismatched immutable lock");
5830
5831        assert_eq!(status, HttpStatusCode::CONFLICT);
5832        let payload = body.0;
5833        assert_eq!(payload["reason"], "generation_lock_replay_failed");
5834        assert_eq!(payload["status"], "rollback_failed");
5835        assert_eq!(payload["generation_lock_replayed"], false);
5836        assert_eq!(
5837            payload["generation_lock_error"]["type"],
5838            "generation_mismatch"
5839        );
5840        assert_eq!(payload["lock_written"], false);
5841        assert_eq!(payload["pointer_written"], false);
5842        assert_eq!(payload["index_written"], false);
5843
5844        let root_lock_contents =
5845            fs::read_to_string(instance_dir.join("lock.toml")).expect("root lock still readable");
5846        assert_eq!(root_lock_contents, "app = \"stale-root\"\n");
5847        assert_eq!(
5848            crate::app::read_active_generation_pointer(&instance_dir)
5849                .expect("active pointer read succeeds"),
5850            None
5851        );
5852        assert_eq!(
5853            crate::app::read_previous_generation_pointer(&instance_dir)
5854                .expect("previous pointer read succeeds"),
5855            None
5856        );
5857        assert!(crate::app::load_generation_index(&instance_dir)
5858            .expect("index load succeeds")
5859            .is_none());
5860    }
5861
5862    #[tokio::test]
5863    async fn rollback_generation_fails_when_previous_pointer_write_fails() {
5864        let root = tempdir().expect("temp dir");
5865        let repo_root = root.path().to_path_buf();
5866        let instance_dir = repo_root.join(".weft").join("weft-claw");
5867        fs::create_dir_all(&instance_dir).expect("instance dir created");
5868        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5869            .expect("lock file seeded");
5870        fs::write(previous_generation_pointer_path(&instance_dir), "0\n")
5871            .expect("previous pointer seeded");
5872        fs::create_dir_all(instance_dir.join("previous.bak"))
5873            .expect("backup dir created to force previous pointer write failure");
5874
5875        let state = state_with_generation_store(
5876            repo_root,
5877            test_resolved_app("weft-claw", Some(&instance_dir)),
5878            AppGenerationStore {
5879                active: Some(generation_with_id(2, GenerationStatus::Active)),
5880                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5881                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5882                next_id: 4,
5883            },
5884        );
5885
5886        let (status, body) = rollback_generation(Path("weft-claw".into()), State(state))
5887            .await
5888            .expect_err("rollback should fail when previous pointer write fails");
5889
5890        assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5891        let payload = body.0;
5892        assert_eq!(payload["status"], "rollback_failed");
5893        assert_eq!(payload["reason"], "pointer_write_failed");
5894        assert_eq!(payload["operation"], "rollback");
5895        assert_eq!(payload["lock_written"], true);
5896        assert_eq!(payload["pointer_written"], false);
5897        assert_eq!(payload["pointer_error"]["stage"], "previous");
5898        assert_eq!(payload["pointer_error"]["repair_needed"], false);
5899        assert_eq!(payload["index_written"], false);
5900        assert!(payload["index_error"].is_null());
5901
5902        assert_eq!(
5903            crate::app::read_previous_generation_pointer(&instance_dir)
5904                .expect("previous pointer read succeeds"),
5905            Some(0)
5906        );
5907        assert_eq!(
5908            crate::app::read_active_generation_pointer(&instance_dir)
5909                .expect("active pointer read succeeds"),
5910            None
5911        );
5912        assert!(crate::app::load_generation_index(&instance_dir)
5913            .expect("index load succeeds")
5914            .is_none());
5915    }
5916
5917    #[tokio::test]
5918    async fn rollback_generation_fails_when_active_pointer_write_fails_and_skips_index() {
5919        let root = tempdir().expect("temp dir");
5920        let repo_root = root.path().to_path_buf();
5921        let instance_dir = repo_root.join(".weft").join("weft-claw");
5922        fs::create_dir_all(&instance_dir).expect("instance dir created");
5923        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5924            .expect("lock file seeded");
5925        fs::write(active_generation_pointer_path(&instance_dir), "2\n")
5926            .expect("active pointer seeded");
5927        fs::create_dir_all(instance_dir.join("active.bak"))
5928            .expect("backup dir created to force active pointer write failure");
5929
5930        let state = state_with_generation_store(
5931            repo_root,
5932            test_resolved_app("weft-claw", Some(&instance_dir)),
5933            AppGenerationStore {
5934                active: Some(generation_with_id(2, GenerationStatus::Active)),
5935                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5936                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5937                next_id: 4,
5938            },
5939        );
5940
5941        let (status, body) = rollback_generation(Path("weft-claw".into()), State(state))
5942            .await
5943            .expect_err("rollback should fail when active pointer write fails");
5944
5945        assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5946        let payload = body.0;
5947        assert_eq!(payload["status"], "rollback_failed");
5948        assert_eq!(payload["reason"], "pointer_write_failed");
5949        assert_eq!(payload["operation"], "rollback");
5950        assert_eq!(payload["lock_written"], true);
5951        assert_eq!(payload["pointer_written"], false);
5952        assert_eq!(payload["pointer_error"]["stage"], "active");
5953        assert_eq!(payload["pointer_error"]["repair_needed"], true);
5954        assert_eq!(payload["index_written"], false);
5955        assert!(payload["index_error"].is_null());
5956
5957        assert_eq!(
5958            crate::app::read_previous_generation_pointer(&instance_dir)
5959                .expect("previous pointer read succeeds"),
5960            Some(2)
5961        );
5962        assert_eq!(
5963            crate::app::read_active_generation_pointer(&instance_dir)
5964                .expect("active pointer read succeeds"),
5965            Some(2)
5966        );
5967        assert!(crate::app::load_generation_index(&instance_dir)
5968            .expect("index load succeeds")
5969            .is_none());
5970    }
5971
5972    #[tokio::test]
5973    async fn activate_existing_generation_switches_to_verified_rollback_generation_and_replays_lock(
5974    ) {
5975        let root = tempdir().expect("temp dir");
5976        let repo_root = root.path().to_path_buf();
5977        let instance_dir = repo_root.join(".weft").join("weft-claw");
5978        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
5979        write_text_file(
5980            &instance_dir.join("lock.toml"),
5981            r#"lock_version = 2
5982app = 'weft-claw'
5983generation = 2
5984status = 'active'
5985profile = 'developer'
5986scene = 'current-scene'
5987scene_digest = 'sha256:current-scene'
5988binding_set_id = 'binding-set:sha256:current'
5989closure_id = 'closure:sha256:current'
5990closure_digest = 'sha256:closure-current'
5991
5992[assembly]
5993enabled_features = ['current-feature']
5994selected_packages = ['current-package']
5995scene = 'current-scene'
5996scene_digest = 'sha256:current-scene'
5997binding_set_id = 'binding-set:sha256:current'
5998closure_id = 'closure:sha256:current'
5999closure_digest = 'sha256:closure-current'
6000
6001[notes]
6002message = 'current active root lock'
6003"#,
6004        );
6005        write_text_file(
6006            &instance_dir.join("generations").join("1.lock.toml"),
6007            r#"lock_version = 2
6008app = 'weft-claw'
6009generation = 1
6010status = 'verified'
6011profile = 'developer'
6012scene = 'rollback-scene'
6013scene_digest = 'sha256:scene-1'
6014binding_set_id = 'binding-set:sha256:1'
6015closure_id = 'closure:sha256:1'
6016closure_digest = 'sha256:closure-1'
6017trust_level = 'verified'
6018
6019[assembly]
6020enabled_features = ['immutable-feature']
6021selected_packages = ['immutable-package']
6022scene = 'rollback-scene'
6023scene_digest = 'sha256:scene-1'
6024binding_set_id = 'binding-set:sha256:1'
6025closure_id = 'closure:sha256:1'
6026closure_digest = 'sha256:closure-1'
6027
6028[notes]
6029message = 'immutable rollback payload'
6030"#,
6031        );
6032
6033        let state = state_with_generation_store(
6034            repo_root,
6035            test_resolved_app("weft-claw", Some(&instance_dir)),
6036            AppGenerationStore {
6037                active: Some(generation_with_id(2, GenerationStatus::Active)),
6038                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6039                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6040                next_id: 4,
6041            },
6042        );
6043
6044        let response = activate_existing_generation(Path(("weft-claw".into(), 1)), State(state))
6045            .await
6046            .expect("switch succeeds");
6047
6048        let payload = response.0;
6049        assert_eq!(payload["status"], "activated");
6050        assert_eq!(payload["generation"]["id"], 1);
6051        assert_eq!(payload["lock_written"], true);
6052        assert_eq!(payload["generation_lock_replayed"], true);
6053        assert!(payload["generation_lock_warning"].is_null());
6054        assert_eq!(payload["pointer_written"], true);
6055        assert_eq!(payload["index_written"], true);
6056
6057        let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
6058            .expect("root lock loads");
6059        assert_eq!(root_lock.generation, 1);
6060        assert_eq!(root_lock.status, "active");
6061        assert_eq!(root_lock.scene, "rollback-scene");
6062        assert_eq!(root_lock.notes.message, "immutable rollback payload");
6063        assert_eq!(
6064            crate::app::read_previous_generation_pointer(&instance_dir)
6065                .expect("previous pointer read succeeds"),
6066            Some(2)
6067        );
6068        assert_eq!(
6069            crate::app::read_active_generation_pointer(&instance_dir)
6070                .expect("active pointer read succeeds"),
6071            Some(1)
6072        );
6073
6074        let index = crate::app::load_generation_index(&instance_dir)
6075            .expect("index load succeeds")
6076            .expect("index present");
6077        assert_eq!(index.active, Some(1));
6078        assert_eq!(index.previous, Some(2));
6079        assert_eq!(index.candidate, Some(3));
6080    }
6081
6082    #[tokio::test]
6083    async fn activate_existing_generation_fails_when_target_is_missing() {
6084        let root = tempdir().expect("temp dir");
6085        let repo_root = root.path().to_path_buf();
6086        let instance_dir = repo_root.join(".weft").join("weft-claw");
6087        fs::create_dir_all(&instance_dir).expect("instance dir created");
6088        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
6089            .expect("lock file seeded");
6090
6091        let state = state_with_generation_store(
6092            repo_root,
6093            test_resolved_app("weft-claw", Some(&instance_dir)),
6094            AppGenerationStore {
6095                active: Some(generation_with_id(2, GenerationStatus::Active)),
6096                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6097                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6098                next_id: 4,
6099            },
6100        );
6101
6102        let (status, body) =
6103            activate_existing_generation(Path(("weft-claw".into(), 99)), State(state))
6104                .await
6105                .expect_err("missing generation should fail");
6106
6107        assert_eq!(status, HttpStatusCode::NOT_FOUND);
6108        let payload = body.0;
6109        assert_eq!(payload["reason"], "generation_not_found");
6110        assert_eq!(payload["status"], "activation_failed");
6111        assert_eq!(payload["generation"], 99);
6112        assert_eq!(payload["lock_written"], false);
6113        assert_eq!(payload["pointer_written"], false);
6114        assert_eq!(payload["index_written"], false);
6115    }
6116
6117    #[tokio::test]
6118    async fn get_generation_diff_returns_summary_diff_for_active_and_rollback() {
6119        let root = tempdir().expect("temp dir");
6120        let repo_root = root.path().to_path_buf();
6121        let instance_dir = repo_root.join(".weft").join("weft-claw");
6122        fs::create_dir_all(&instance_dir).expect("instance dir created");
6123
6124        let rollback = generation_with_diff_fields(
6125            1,
6126            GenerationStatus::Rollback,
6127            "stable",
6128            "safe",
6129            vec!["feature-a"],
6130            vec!["cap.a"],
6131            vec![test_binding("cap.a", "provider-a")],
6132        );
6133        let active = generation_with_diff_fields(
6134            2,
6135            GenerationStatus::Active,
6136            "team",
6137            "developer",
6138            vec!["feature-a", "feature-b"],
6139            vec!["cap.a", "cap.b"],
6140            vec![
6141                test_binding("cap.a", "provider-b"),
6142                test_binding("cap.b", "provider-c"),
6143            ],
6144        );
6145
6146        let response = get_generation_diff(
6147            Path(("weft-claw".into(), 2, 1)),
6148            State(state_with_generation_store(
6149                repo_root,
6150                test_resolved_app("weft-claw", Some(&instance_dir)),
6151                AppGenerationStore {
6152                    active: Some(active),
6153                    candidate: None,
6154                    rollback: Some(rollback),
6155                    next_id: 3,
6156                },
6157            )),
6158        )
6159        .await
6160        .expect("diff succeeds")
6161        .0;
6162
6163        assert_eq!(response["app"], "weft-claw");
6164        assert_eq!(response["from"]["id"], 2);
6165        assert_eq!(response["to"]["id"], 1);
6166        assert_eq!(response["summary_diff"]["status"]["from"], "active");
6167        assert_eq!(response["summary_diff"]["status"]["to"], "rollback");
6168        assert_eq!(response["summary_diff"]["status"]["changed"], true);
6169        assert_eq!(response["summary_diff"]["scene"]["from"], "team");
6170        assert_eq!(response["summary_diff"]["scene"]["to"], "stable");
6171        assert_eq!(
6172            response["summary_diff"]["enabled_features"]["removed"],
6173            serde_json::json!(["feature-b"])
6174        );
6175        assert_eq!(
6176            response["summary_diff"]["capabilities"]["removed"],
6177            serde_json::json!(["cap.b"])
6178        );
6179        assert_eq!(response["summary_diff"]["bindings"]["changed"], true);
6180        assert_eq!(
6181            response["summary_diff"]["bindings"]["removed"][0]["capability"],
6182            "cap.b"
6183        );
6184        assert_eq!(
6185            response["summary_diff"]["bindings"]["changed_entries"][0]["capability"],
6186            "cap.a"
6187        );
6188        assert_eq!(response["immutable_lock"]["available"], false);
6189        assert!(!response["warnings"]
6190            .as_array()
6191            .expect("warnings array")
6192            .is_empty());
6193    }
6194
6195    #[tokio::test]
6196    async fn get_generation_diff_returns_404_for_missing_generation() {
6197        let root = tempdir().expect("temp dir");
6198        let repo_root = root.path().to_path_buf();
6199        let instance_dir = repo_root.join(".weft").join("weft-claw");
6200        fs::create_dir_all(&instance_dir).expect("instance dir created");
6201
6202        let (status, body) = get_generation_diff(
6203            Path(("weft-claw".into(), 2, 99)),
6204            State(state_with_generation_store(
6205                repo_root,
6206                test_resolved_app("weft-claw", Some(&instance_dir)),
6207                AppGenerationStore {
6208                    active: Some(generation_with_id(2, GenerationStatus::Active)),
6209                    candidate: None,
6210                    rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6211                    next_id: 3,
6212                },
6213            )),
6214        )
6215        .await
6216        .expect_err("missing generation should fail");
6217
6218        assert_eq!(status, HttpStatusCode::NOT_FOUND);
6219        assert_eq!(body.0["reason"], "generation_not_found");
6220        assert_eq!(body.0["generation"], 99);
6221        assert_eq!(body.0["role"], "to");
6222    }
6223
6224    #[tokio::test]
6225    async fn get_generation_diff_includes_immutable_lock_package_and_binding_diff() {
6226        let root = tempdir().expect("temp dir");
6227        let repo_root = root.path().to_path_buf();
6228        let instance_dir = repo_root.join(".weft").join("weft-claw");
6229        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6230
6231        write_generation_lock_for_diff(
6232            &instance_dir,
6233            1,
6234            "stable",
6235            "safe",
6236            "closure:sha256:1",
6237            &["package-a"],
6238            &[("cap.a", "provider-a")],
6239        );
6240        write_generation_lock_for_diff(
6241            &instance_dir,
6242            2,
6243            "team",
6244            "developer",
6245            "closure:sha256:2",
6246            &["package-a", "package-b"],
6247            &[("cap.a", "provider-b"), ("cap.b", "provider-c")],
6248        );
6249
6250        let response = get_generation_diff(
6251            Path(("weft-claw".into(), 1, 2)),
6252            State(state_with_generation_store(
6253                repo_root,
6254                test_resolved_app("weft-claw", Some(&instance_dir)),
6255                AppGenerationStore {
6256                    active: Some(generation_with_id(2, GenerationStatus::Active)),
6257                    candidate: None,
6258                    rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6259                    next_id: 3,
6260                },
6261            )),
6262        )
6263        .await
6264        .expect("diff succeeds")
6265        .0;
6266
6267        assert_eq!(response["immutable_lock"]["available"], true);
6268        assert_eq!(
6269            response["immutable_lock"]["diff"]["packages"]["added"],
6270            serde_json::json!(["package-b"])
6271        );
6272        assert_eq!(
6273            response["immutable_lock"]["diff"]["bindings"]["added"][0]["capability"],
6274            "cap.b"
6275        );
6276        assert_eq!(
6277            response["immutable_lock"]["diff"]["bindings"]["changed_entries"][0]["capability"],
6278            "cap.a"
6279        );
6280        assert_eq!(response["immutable_lock"]["diff"]["scene"]["changed"], true);
6281        assert_eq!(
6282            response["immutable_lock"]["diff"]["profile"]["changed"],
6283            true
6284        );
6285        assert!(response["warnings"]
6286            .as_array()
6287            .expect("warnings array")
6288            .is_empty());
6289    }
6290
6291    #[tokio::test]
6292    async fn get_generation_diff_returns_summary_diff_with_warning_when_immutable_lock_missing() {
6293        let root = tempdir().expect("temp dir");
6294        let repo_root = root.path().to_path_buf();
6295        let instance_dir = repo_root.join(".weft").join("weft-claw");
6296        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6297
6298        write_generation_lock_for_diff(
6299            &instance_dir,
6300            1,
6301            "stable",
6302            "safe",
6303            "closure:sha256:1",
6304            &["package-a"],
6305            &[("cap.a", "provider-a")],
6306        );
6307
6308        let response = get_generation_diff(
6309            Path(("weft-claw".into(), 1, 2)),
6310            State(state_with_generation_store(
6311                repo_root,
6312                test_resolved_app("weft-claw", Some(&instance_dir)),
6313                AppGenerationStore {
6314                    active: Some(generation_with_id(2, GenerationStatus::Active)),
6315                    candidate: None,
6316                    rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6317                    next_id: 3,
6318                },
6319            )),
6320        )
6321        .await
6322        .expect("diff succeeds")
6323        .0;
6324
6325        assert_eq!(response["summary_diff"]["id"]["changed"], true);
6326        assert_eq!(response["immutable_lock"]["available"], false);
6327        assert!(response["immutable_lock"]["diff"].is_null());
6328        let warnings = response["warnings"].as_array().expect("warnings array");
6329        assert_eq!(warnings.len(), 1);
6330        assert_eq!(warnings[0]["warning_type"], "immutable_lock_missing");
6331        assert_eq!(warnings[0]["generation"], 2);
6332    }
6333
6334    #[tokio::test]
6335    async fn get_generation_diff_route_is_wired() {
6336        let root = tempdir().expect("temp dir");
6337        let repo_root = root.path().to_path_buf();
6338        let instance_dir = repo_root.join(".weft").join("weft-claw");
6339        fs::create_dir_all(&instance_dir).expect("instance dir created");
6340
6341        let router = build_router(state_with_generation_store(
6342            repo_root,
6343            test_resolved_app("weft-claw", Some(&instance_dir)),
6344            AppGenerationStore {
6345                active: Some(generation_with_id(2, GenerationStatus::Active)),
6346                candidate: None,
6347                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6348                next_id: 3,
6349            },
6350        ));
6351
6352        let response = router
6353            .oneshot(
6354                Request::builder()
6355                    .uri("/api/apps/weft-claw/generations/2/diff/1")
6356                    .method("GET")
6357                    .body(Body::empty())
6358                    .expect("request built"),
6359            )
6360            .await
6361            .expect("router response");
6362
6363        assert_eq!(response.status(), HttpStatusCode::OK);
6364        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6365            .await
6366            .expect("body bytes");
6367        let payload: serde_json::Value = serde_json::from_slice(&body).expect("json payload");
6368        assert_eq!(payload["from"]["id"], 2);
6369        assert_eq!(payload["to"]["id"], 1);
6370    }
6371
6372    #[tokio::test]
6373    async fn activate_existing_generation_fails_when_target_is_not_verified() {
6374        let root = tempdir().expect("temp dir");
6375        let repo_root = root.path().to_path_buf();
6376        let instance_dir = repo_root.join(".weft").join("weft-claw");
6377        fs::create_dir_all(&instance_dir).expect("instance dir created");
6378        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
6379            .expect("lock file seeded");
6380
6381        let state = state_with_generation_store(
6382            repo_root,
6383            test_resolved_app("weft-claw", Some(&instance_dir)),
6384            AppGenerationStore {
6385                active: Some(generation_with_id(2, GenerationStatus::Active)),
6386                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6387                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6388                next_id: 4,
6389            },
6390        );
6391
6392        let (status, body) =
6393            activate_existing_generation(Path(("weft-claw".into(), 3)), State(state))
6394                .await
6395                .expect_err("candidate generation should fail");
6396
6397        assert_eq!(status, HttpStatusCode::CONFLICT);
6398        let payload = body.0;
6399        assert_eq!(payload["reason"], "generation_not_verified");
6400        assert_eq!(payload["status"], "activation_failed");
6401        assert_eq!(payload["generation"], 3);
6402        assert_eq!(payload["lock_written"], false);
6403        assert_eq!(payload["pointer_written"], false);
6404        assert_eq!(payload["index_written"], false);
6405    }
6406
6407    #[tokio::test]
6408    async fn activate_existing_generation_fails_before_pointer_and_index_writes_when_immutable_lock_mismatches(
6409    ) {
6410        let root = tempdir().expect("temp dir");
6411        let repo_root = root.path().to_path_buf();
6412        let instance_dir = repo_root.join(".weft").join("weft-claw");
6413        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6414        fs::write(instance_dir.join("lock.toml"), "app = \"stale-root\"\n")
6415            .expect("lock file seeded");
6416        write_text_file(
6417            &instance_dir.join("generations").join("1.lock.toml"),
6418            r#"lock_version = 2
6419app = 'weft-claw'
6420generation = 99
6421status = 'verified'
6422profile = 'developer'
6423scene = 'wrong-generation'
6424"#,
6425        );
6426
6427        let state = state_with_generation_store(
6428            repo_root,
6429            test_resolved_app("weft-claw", Some(&instance_dir)),
6430            AppGenerationStore {
6431                active: Some(generation_with_id(2, GenerationStatus::Active)),
6432                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6433                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6434                next_id: 4,
6435            },
6436        );
6437
6438        let (status, body) =
6439            activate_existing_generation(Path(("weft-claw".into(), 1)), State(state))
6440                .await
6441                .expect_err("mismatched immutable lock should fail");
6442
6443        assert_eq!(status, HttpStatusCode::CONFLICT);
6444        let payload = body.0;
6445        assert_eq!(payload["reason"], "generation_lock_replay_failed");
6446        assert_eq!(payload["status"], "activation_failed");
6447        assert_eq!(payload["generation_lock_replayed"], false);
6448        assert_eq!(
6449            payload["generation_lock_error"]["type"],
6450            "generation_mismatch"
6451        );
6452        assert_eq!(payload["lock_written"], false);
6453        assert_eq!(payload["pointer_written"], false);
6454        assert_eq!(payload["index_written"], false);
6455
6456        let root_lock_contents =
6457            fs::read_to_string(instance_dir.join("lock.toml")).expect("root lock still readable");
6458        assert_eq!(root_lock_contents, "app = \"stale-root\"\n");
6459        assert_eq!(
6460            crate::app::read_active_generation_pointer(&instance_dir)
6461                .expect("active pointer read succeeds"),
6462            None
6463        );
6464        assert_eq!(
6465            crate::app::read_previous_generation_pointer(&instance_dir)
6466                .expect("previous pointer read succeeds"),
6467            None
6468        );
6469        assert!(crate::app::load_generation_index(&instance_dir)
6470            .expect("index load succeeds")
6471            .is_none());
6472    }
6473
6474    #[tokio::test]
6475    async fn activate_existing_generation_blocks_safe_profile_when_verified_store_object_is_missing(
6476    ) {
6477        let root = tempdir().expect("temp dir");
6478        let repo_root = root.path().to_path_buf();
6479        let instance_dir = repo_root.join(".weft").join("weft-claw");
6480        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6481        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
6482            .expect("lock file seeded");
6483        write_text_file(
6484            &instance_dir.join("generations").join("1.lock.toml"),
6485            r#"lock_version = 2
6486app = 'weft-claw'
6487generation = 1
6488status = 'verified'
6489profile = 'safe'
6490scene = 'safe-scene'
6491
6492[[packages]]
6493name = 'agent-runtime'
6494version = '0.1.0'
6495store_object_id = 'store:sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
6496store_path = '.weft/store/missing-agent-runtime-0.1.0'
6497"#,
6498        );
6499
6500        let state = state_with_generation_store(
6501            repo_root,
6502            test_resolved_app("weft-claw", Some(&instance_dir)),
6503            AppGenerationStore {
6504                active: Some(generation_with_id(2, GenerationStatus::Active)),
6505                candidate: None,
6506                rollback: Some(generation_with_validation_results(
6507                    1,
6508                    GenerationStatus::Rollback,
6509                    "safe",
6510                    vec![crate::app::ValidationResult {
6511                        check: "store-policy:agent-runtime".into(),
6512                        passed: true,
6513                        message: "Store metadata captured during prior verification".into(),
6514                    }],
6515                )),
6516                next_id: 3,
6517            },
6518        );
6519
6520        let (status, body) =
6521            activate_existing_generation(Path(("weft-claw".into(), 1)), State(state))
6522                .await
6523                .expect_err("missing verified store object should block safe activation");
6524
6525        assert_eq!(status, HttpStatusCode::CONFLICT);
6526        let payload = body.0;
6527        assert_eq!(payload["reason"], "store_object_missing");
6528        assert_eq!(payload["status"], "activation_failed");
6529        assert_eq!(payload["lock_written"], false);
6530        assert_eq!(payload["pointer_written"], false);
6531        assert_eq!(payload["index_written"], false);
6532        assert_eq!(
6533            payload["store_validation"]["check"],
6534            "store-policy:agent-runtime"
6535        );
6536        assert!(payload["store_validation"]["message"]
6537            .as_str()
6538            .unwrap_or_default()
6539            .contains("path is missing"));
6540    }
6541
6542    #[tokio::test]
6543    async fn activate_existing_generation_route_is_wired() {
6544        let root = tempdir().expect("temp dir");
6545        let repo_root = root.path().to_path_buf();
6546        let instance_dir = repo_root.join(".weft").join("weft-claw");
6547        fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6548        write_text_file(
6549            &instance_dir.join("lock.toml"),
6550            r#"lock_version = 2
6551app = 'weft-claw'
6552generation = 2
6553status = 'active'
6554profile = 'developer'
6555scene = 'current-scene'
6556scene_digest = 'sha256:current-scene'
6557binding_set_id = 'binding-set:sha256:current'
6558closure_id = 'closure:sha256:current'
6559closure_digest = 'sha256:closure-current'
6560
6561[assembly]
6562enabled_features = ['current-feature']
6563selected_packages = ['current-package']
6564scene = 'current-scene'
6565scene_digest = 'sha256:current-scene'
6566binding_set_id = 'binding-set:sha256:current'
6567closure_id = 'closure:sha256:current'
6568closure_digest = 'sha256:closure-current'
6569
6570[notes]
6571message = 'current active root lock'
6572"#,
6573        );
6574        write_text_file(
6575            &instance_dir.join("generations").join("1.lock.toml"),
6576            r#"lock_version = 2
6577app = 'weft-claw'
6578generation = 1
6579status = 'verified'
6580profile = 'developer'
6581scene = 'rollback-scene'
6582scene_digest = 'sha256:scene-1'
6583binding_set_id = 'binding-set:sha256:1'
6584closure_id = 'closure:sha256:1'
6585closure_digest = 'sha256:closure-1'
6586
6587[assembly]
6588enabled_features = ['immutable-feature']
6589selected_packages = ['immutable-package']
6590scene = 'rollback-scene'
6591scene_digest = 'sha256:scene-1'
6592binding_set_id = 'binding-set:sha256:1'
6593closure_id = 'closure:sha256:1'
6594closure_digest = 'sha256:closure-1'
6595
6596[notes]
6597message = 'immutable rollback payload'
6598"#,
6599        );
6600
6601        let state = state_with_generation_store(
6602            repo_root,
6603            test_resolved_app("weft-claw", Some(&instance_dir)),
6604            AppGenerationStore {
6605                active: Some(generation_with_id(2, GenerationStatus::Active)),
6606                candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6607                rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6608                next_id: 4,
6609            },
6610        );
6611        let app = build_router(state);
6612
6613        let response = app
6614            .oneshot(
6615                Request::builder()
6616                    .method("POST")
6617                    .uri("/api/apps/weft-claw/generations/1/activate")
6618                    .body(Body::empty())
6619                    .expect("request built"),
6620            )
6621            .await
6622            .expect("router response");
6623
6624        assert_eq!(response.status(), HttpStatusCode::OK);
6625    }
6626
6627    #[tokio::test]
6628    async fn generation_diagnostics_returns_store_summary_without_instance_sources() {
6629        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
6630        let mut apps = ResolvedAppMap::new();
6631        apps.insert("weft-claw".into(), test_resolved_app("weft-claw", None));
6632
6633        let state = AppState {
6634            resolved_apps: Arc::new(RwLock::new(apps)),
6635            ..test_state(repo_root, CapabilityRegistry::new())
6636        };
6637
6638        let diagnostics = generation_diagnostics_snapshot(&state, "weft-claw")
6639            .await
6640            .expect("diagnostics snapshot");
6641
6642        assert_eq!(diagnostics.app, "weft-claw");
6643        assert!(!diagnostics.store_present);
6644        assert_eq!(diagnostics.store_summary.next_id, 1);
6645        assert!(diagnostics.store_summary.generations.is_empty());
6646        assert!(diagnostics.instance.is_none());
6647    }
6648
6649    #[tokio::test]
6650    async fn generation_diagnostics_reports_pointer_mismatch_from_instance_files() {
6651        let root = tempdir().expect("temp dir");
6652        let repo_root = root.path().to_path_buf();
6653        let instance_dir = repo_root.join(".weft").join("weft-claw");
6654        std::fs::create_dir_all(&instance_dir).expect("instance dir created");
6655
6656        write_active_generation_pointer(&instance_dir, Some(20)).expect("active pointer written");
6657        write_previous_generation_pointer(&instance_dir, Some(19))
6658            .expect("previous pointer written");
6659        save_generation_index(
6660            &instance_dir,
6661            &AppGenerationIndex {
6662                schema_version: crate::app::GENERATION_INDEX_SCHEMA_VERSION,
6663                active: Some(21),
6664                previous: Some(19),
6665                candidate: None,
6666                next_id: 22,
6667                generations: vec![
6668                    AppGeneration {
6669                        id: 19,
6670                        ..test_generation(
6671                            "core.execution",
6672                            "core",
6673                            "developer",
6674                            GenerationStatus::Rollback,
6675                        )
6676                    },
6677                    AppGeneration {
6678                        id: 20,
6679                        ..test_generation(
6680                            "core.execution",
6681                            "core",
6682                            "developer",
6683                            GenerationStatus::Active,
6684                        )
6685                    },
6686                    AppGeneration {
6687                        id: 21,
6688                        ..test_generation(
6689                            "core.execution",
6690                            "core",
6691                            "developer",
6692                            GenerationStatus::Verified,
6693                        )
6694                    },
6695                ],
6696            },
6697        )
6698        .expect("index saved");
6699
6700        let mut apps = ResolvedAppMap::new();
6701        apps.insert(
6702            "weft-claw".into(),
6703            test_resolved_app("weft-claw", Some(&instance_dir)),
6704        );
6705
6706        let mut stores = GenerationStoreMap::new();
6707        stores.insert(
6708            "weft-claw".into(),
6709            AppGenerationStore {
6710                active: Some(AppGeneration {
6711                    id: 20,
6712                    ..test_generation(
6713                        "core.execution",
6714                        "core",
6715                        "developer",
6716                        GenerationStatus::Active,
6717                    )
6718                }),
6719                candidate: None,
6720                rollback: Some(AppGeneration {
6721                    id: 19,
6722                    ..test_generation(
6723                        "core.execution",
6724                        "core",
6725                        "developer",
6726                        GenerationStatus::Rollback,
6727                    )
6728                }),
6729                next_id: 21,
6730            },
6731        );
6732
6733        let state = AppState {
6734            generation_store: Arc::new(RwLock::new(stores)),
6735            resolved_apps: Arc::new(RwLock::new(apps)),
6736            ..test_state(repo_root, CapabilityRegistry::new())
6737        };
6738
6739        let diagnostics = generation_diagnostics_snapshot(&state, "weft-claw")
6740            .await
6741            .expect("diagnostics snapshot");
6742        let instance = diagnostics.instance.expect("instance diagnostics present");
6743        let report = instance
6744            .consistency_report
6745            .expect("consistency report present");
6746
6747        assert!(diagnostics.store_present);
6748        assert_eq!(diagnostics.store_summary.active, Some(20));
6749        assert_eq!(instance.active_pointer.generation_id, Some(20));
6750        assert_eq!(instance.previous_pointer.generation_id, Some(19));
6751        assert!(instance.generation_index.present);
6752        assert!(report.repair_recommended);
6753        assert!(report
6754            .diagnostics
6755            .iter()
6756            .any(|diagnostic| diagnostic.code == "pointer_mismatch"
6757                && diagnostic.pointer.as_deref() == Some("active")));
6758    }
6759
6760    #[tokio::test]
6761    async fn generation_diagnostics_endpoint_returns_read_only_instance_health() {
6762        let root = tempdir().expect("temp dir");
6763        let repo_root = root.path().to_path_buf();
6764        let instance_dir = repo_root.join(".weft").join("weft-claw");
6765        std::fs::create_dir_all(&instance_dir).expect("instance dir created");
6766
6767        write_active_generation_pointer(&instance_dir, Some(7)).expect("active pointer written");
6768        write_previous_generation_pointer(&instance_dir, Some(6))
6769            .expect("previous pointer written");
6770        save_generation_index(
6771            &instance_dir,
6772            &AppGenerationIndex {
6773                schema_version: crate::app::GENERATION_INDEX_SCHEMA_VERSION,
6774                active: Some(7),
6775                previous: Some(6),
6776                candidate: None,
6777                next_id: 8,
6778                generations: vec![
6779                    AppGeneration {
6780                        id: 6,
6781                        ..test_generation(
6782                            "core.execution",
6783                            "core",
6784                            "developer",
6785                            GenerationStatus::Rollback,
6786                        )
6787                    },
6788                    AppGeneration {
6789                        id: 7,
6790                        lock_path: "generations/7.lock.toml".into(),
6791                        scene: "stable".into(),
6792                        binding_set_id: "binding-set:sha256:7".into(),
6793                        closure_id: "closure:sha256:7".into(),
6794                        created_by: "api".into(),
6795                        lock_digest: "sha256:lock-7".into(),
6796                        ..test_generation(
6797                            "core.execution",
6798                            "core",
6799                            "developer",
6800                            GenerationStatus::Active,
6801                        )
6802                    },
6803                ],
6804            },
6805        )
6806        .expect("index saved");
6807
6808        let mut apps = ResolvedAppMap::new();
6809        apps.insert(
6810            "weft-claw".into(),
6811            test_resolved_app("weft-claw", Some(&instance_dir)),
6812        );
6813
6814        let state = AppState {
6815            resolved_apps: Arc::new(RwLock::new(apps)),
6816            ..test_state(repo_root, CapabilityRegistry::new())
6817        };
6818        let app = build_router(state);
6819
6820        let response = app
6821            .oneshot(
6822                Request::builder()
6823                    .uri("/api/apps/weft-claw/generations/diagnostics")
6824                    .body(Body::empty())
6825                    .unwrap(),
6826            )
6827            .await
6828            .expect("request handled");
6829
6830        assert_eq!(response.status(), HttpStatusCode::OK);
6831
6832        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6833            .await
6834            .expect("body bytes");
6835        let payload: serde_json::Value = serde_json::from_slice(&body).expect("json payload");
6836
6837        assert_eq!(payload["app"], "weft-claw");
6838        assert_eq!(payload["store_present"], false);
6839        assert_eq!(payload["store_summary"]["next_id"], 1);
6840        assert_eq!(payload["instance"]["active_pointer"]["generation_id"], 7);
6841        assert_eq!(payload["instance"]["previous_pointer"]["generation_id"], 6);
6842        assert_eq!(payload["instance"]["generation_index"]["present"], true);
6843        assert_eq!(
6844            payload["instance"]["consistency_report"]["is_consistent"],
6845            true
6846        );
6847        assert_eq!(
6848            payload["instance"]["generation_index"]["index"]["active"],
6849            7
6850        );
6851    }
6852
6853    #[tokio::test]
6854    async fn live_probe_reports_core_execution_success() {
6855        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
6856        let mut registry = CapabilityRegistry::new();
6857        registry.insert(
6858            "core.execution".into(),
6859            CapabilityRegistryEntry {
6860                capability: "core.execution".into(),
6861                providers: vec![CapabilityProviderRecord {
6862                    provider: "core".into(),
6863                    runtime: "core".into(),
6864                    priority: 0,
6865                }],
6866                bindings: vec![],
6867            },
6868        );
6869        let state = test_state(repo_root, registry);
6870
6871        let generation = test_generation(
6872            "core.execution",
6873            "core",
6874            "developer",
6875            GenerationStatus::Candidate,
6876        );
6877
6878        let results = live_probe_generation(&state, &generation).await;
6879        assert_eq!(results.len(), 1);
6880        assert_eq!(results[0].check, "probe:core.execution");
6881        assert!(results[0].passed);
6882        assert!(results[0]
6883            .message
6884            .contains("Provider responded to live core probe"));
6885    }
6886
6887    #[tokio::test]
6888    async fn live_probe_reports_provider_rejection_for_mismatched_digest_signature() {
6889        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6890            .join("..")
6891            .join("target")
6892            .join("test-live-probe-signature-rejection");
6893        let provider = "cap-provider";
6894        let source = "fixtures/cap-provider";
6895        let source_dir = repo_root.join(source);
6896        std::fs::create_dir_all(&source_dir).expect("source directory created");
6897        std::fs::write(source_dir.join("package.toml"), "name = 'cap-provider'\n")
6898            .expect("marker file written");
6899
6900        let signing_key = SigningKey::from_bytes(&[11; 32]);
6901        let bad_message = signature_message(provider, "current", "deadbeef", source);
6902        let signature = sign_package_message(&signing_key, &bad_message);
6903        let source_public_key = signature
6904            .split(':')
6905            .nth(1)
6906            .expect("public key segment exists")
6907            .to_string();
6908
6909        let mut registry = CapabilityRegistry::new();
6910        registry.insert(
6911            "cap.test".into(),
6912            CapabilityRegistryEntry {
6913                capability: "cap.test".into(),
6914                providers: vec![CapabilityProviderRecord {
6915                    provider: provider.into(),
6916                    runtime: "service".into(),
6917                    priority: 0,
6918                }],
6919                bindings: vec![],
6920            },
6921        );
6922
6923        let state = AppState {
6924            package_index: Arc::new(PackageIndex {
6925                version: 1,
6926                revision: "test-rev".into(),
6927                source_url: "local://packages".into(),
6928                package_sources: vec![PackageSource {
6929                    name: provider.into(),
6930                    kind: "service".into(),
6931                    package_kind: String::new(),
6932                    runtime_provider: provider.into(),
6933                    current_source: source.into(),
6934                    trusted: true,
6935                    signature,
6936                    source_authority: "test-authority".into(),
6937                    source_public_keys: vec![source_public_key],
6938                    provides: vec![],
6939                    requires: vec![],
6940                }],
6941            }),
6942            ..test_state(repo_root, registry)
6943        };
6944
6945        let generation = test_generation("cap.test", provider, "safe", GenerationStatus::Candidate);
6946
6947        *state.active_profile.write().await = AppProfile::Safe;
6948
6949        let results = live_probe_generation(&state, &generation).await;
6950        assert_eq!(results.len(), 1);
6951        assert_eq!(results[0].check, "probe:cap.test");
6952        assert!(!results[0].passed);
6953        assert!(results[0]
6954            .message
6955            .contains("Live probe failed with 403 Forbidden"));
6956        assert!(results[0]
6957            .message
6958            .contains("is not accepted under profile 'safe'"));
6959    }
6960
6961    #[tokio::test]
6962    async fn live_probe_rejects_required_real_package_when_registry_marks_it_metadata() {
6963        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
6964        let mut registry = CapabilityRegistry::new();
6965        registry.insert(
6966            "prompt.system".into(),
6967            CapabilityRegistryEntry {
6968                capability: "prompt.system".into(),
6969                providers: vec![CapabilityProviderRecord {
6970                    provider: "prompt-system".into(),
6971                    runtime: "metadata".into(),
6972                    priority: 0,
6973                }],
6974                bindings: vec![],
6975            },
6976        );
6977        let state = AppState {
6978            package_index: Arc::new(PackageIndex {
6979                version: 1,
6980                revision: "test-rev".into(),
6981                source_url: "local://packages".into(),
6982                package_sources: vec![PackageSource {
6983                    name: "prompt-system".into(),
6984                    kind: "metadata".into(),
6985                    package_kind: "foundation".into(),
6986                    runtime_provider: "prompt-system".into(),
6987                    current_source: "packages/weft-claw".into(),
6988                    trusted: true,
6989                    signature: "builtin:product-package".into(),
6990                    source_authority: "product-package-instance".into(),
6991                    source_public_keys: vec![],
6992                    provides: vec!["prompt.system".into()],
6993                    requires: vec![],
6994                }],
6995            }),
6996            ..test_state(repo_root, registry)
6997        };
6998
6999        let generation = test_generation(
7000            "prompt.system",
7001            "prompt-system",
7002            "developer",
7003            GenerationStatus::Candidate,
7004        );
7005
7006        let results = live_probe_generation(&state, &generation).await;
7007        assert_eq!(results.len(), 1);
7008        assert_eq!(results[0].check, "probe:prompt.system");
7009        assert!(!results[0].passed);
7010        assert!(results[0]
7011            .message
7012            .contains("without an assembled real package source"));
7013    }
7014
7015    #[tokio::test]
7016    async fn verify_generation_rejects_safe_candidate_when_selected_package_digest_metadata_missing(
7017    ) {
7018        let root = tempdir().expect("temp dir");
7019        let repo_root = root.path().to_path_buf();
7020        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7021            &repo_root,
7022            Some("team"),
7023            r#"[scenes.team]
7024name = 'team'
7025profile = 'safe'
7026
7027[[scenes.team.package_pins]]
7028package = 'agent-runtime'
7029source = 'remote://candidate-without-digest'
7030"#,
7031        );
7032
7033        let app = ResolvedApp {
7034            name: "weft-claw".into(),
7035            version: "0.1.0".into(),
7036            capabilities: vec!["agent.runtime".into()],
7037            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7038            sources: ResolvedAppSources {
7039                manifest_path: manifest_path.display().to_string(),
7040                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7041                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7042            },
7043            ..ResolvedApp::default()
7044        };
7045        let candidate = test_generation(
7046            "agent.runtime",
7047            "agent-runtime",
7048            "safe",
7049            GenerationStatus::Candidate,
7050        );
7051        let mut registry = CapabilityRegistry::new();
7052        registry.insert(
7053            "agent.runtime".into(),
7054            CapabilityRegistryEntry {
7055                capability: "agent.runtime".into(),
7056                providers: vec![CapabilityProviderRecord {
7057                    provider: "agent-runtime".into(),
7058                    runtime: "metadata".into(),
7059                    priority: 0,
7060                }],
7061                bindings: vec![],
7062            },
7063        );
7064        let state = state_for_verify_generation(
7065            repo_root,
7066            app,
7067            vec![test_package_source(
7068                "agent-runtime",
7069                "provider",
7070                vec!["agent.runtime"],
7071                vec![],
7072            )],
7073            registry,
7074            candidate,
7075        );
7076
7077        let (status, body) =
7078            super::verify_generation(Path("weft-claw".into()), State(state.clone()))
7079                .await
7080                .expect_err("safe verification should fail");
7081
7082        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7083        assert_eq!(body.0["reason"], "generation_verify_failed");
7084        let validations = body.0["candidate"]["validation_results"]
7085            .as_array()
7086            .expect("validation result array");
7087        assert!(validations.iter().any(|result| {
7088            result["check"] == "digest-policy:agent-runtime"
7089                && result["passed"] == false
7090                && result["message"]
7091                    .as_str()
7092                    .unwrap_or_default()
7093                    .contains("missing usable sha512/artifact/content digest metadata")
7094        }));
7095    }
7096
7097    #[tokio::test]
7098    async fn verify_generation_rejects_trusted_candidate_when_selected_package_digest_metadata_missing(
7099    ) {
7100        let root = tempdir().expect("temp dir");
7101        let repo_root = root.path().to_path_buf();
7102        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7103            &repo_root,
7104            Some("team"),
7105            r#"[scenes.team]
7106name = 'team'
7107profile = 'trusted'
7108
7109[[scenes.team.package_pins]]
7110package = 'agent-runtime'
7111source = 'remote://candidate-without-digest'
7112"#,
7113        );
7114
7115        let app = ResolvedApp {
7116            name: "weft-claw".into(),
7117            version: "0.1.0".into(),
7118            capabilities: vec!["agent.runtime".into()],
7119            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7120            sources: ResolvedAppSources {
7121                manifest_path: manifest_path.display().to_string(),
7122                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7123                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7124            },
7125            ..ResolvedApp::default()
7126        };
7127        let candidate = test_generation(
7128            "agent.runtime",
7129            "agent-runtime",
7130            "trusted",
7131            GenerationStatus::Candidate,
7132        );
7133        let mut registry = CapabilityRegistry::new();
7134        registry.insert(
7135            "agent.runtime".into(),
7136            CapabilityRegistryEntry {
7137                capability: "agent.runtime".into(),
7138                providers: vec![CapabilityProviderRecord {
7139                    provider: "agent-runtime".into(),
7140                    runtime: "metadata".into(),
7141                    priority: 0,
7142                }],
7143                bindings: vec![],
7144            },
7145        );
7146        let state = state_for_verify_generation(
7147            repo_root,
7148            app,
7149            vec![test_package_source(
7150                "agent-runtime",
7151                "provider",
7152                vec!["agent.runtime"],
7153                vec![],
7154            )],
7155            registry,
7156            candidate,
7157        );
7158
7159        let (status, body) =
7160            super::verify_generation(Path("weft-claw".into()), State(state.clone()))
7161                .await
7162                .expect_err("trusted verification should fail");
7163
7164        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7165        assert_eq!(body.0["reason"], "generation_verify_failed");
7166        let validations = body.0["candidate"]["validation_results"]
7167            .as_array()
7168            .expect("validation result array");
7169        assert!(validations.iter().any(|result| {
7170            result["check"] == "digest-policy:agent-runtime"
7171                && result["passed"] == false
7172                && result["message"]
7173                    .as_str()
7174                    .unwrap_or_default()
7175                    .contains("under profile 'trusted'")
7176        }));
7177    }
7178
7179    #[tokio::test]
7180    async fn verify_generation_allows_developer_candidate_with_dev_unsealed_warning() {
7181        let root = tempdir().expect("temp dir");
7182        let repo_root = root.path().to_path_buf();
7183        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7184            &repo_root,
7185            Some("team"),
7186            r#"[scenes.team]
7187name = 'team'
7188profile = 'developer'
7189
7190[[scenes.team.package_pins]]
7191package = 'agent-runtime'
7192source = 'remote://candidate-without-digest'
7193"#,
7194        );
7195
7196        let app = ResolvedApp {
7197            name: "weft-claw".into(),
7198            version: "0.1.0".into(),
7199            capabilities: vec!["agent.runtime".into()],
7200            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7201            sources: ResolvedAppSources {
7202                manifest_path: manifest_path.display().to_string(),
7203                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7204                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7205            },
7206            ..ResolvedApp::default()
7207        };
7208        let candidate = test_generation(
7209            "agent.runtime",
7210            "agent-runtime",
7211            "developer",
7212            GenerationStatus::Candidate,
7213        );
7214        let candidate = AppGeneration {
7215            lock_path: generation_lock_path(1),
7216            ..candidate
7217        };
7218        let mut registry = CapabilityRegistry::new();
7219        registry.insert(
7220            "agent.runtime".into(),
7221            CapabilityRegistryEntry {
7222                capability: "agent.runtime".into(),
7223                providers: vec![CapabilityProviderRecord {
7224                    provider: "agent-runtime".into(),
7225                    runtime: "metadata".into(),
7226                    priority: 0,
7227                }],
7228                bindings: vec![],
7229            },
7230        );
7231        let state = state_for_verify_generation(
7232            repo_root,
7233            app,
7234            vec![test_package_source(
7235                "agent-runtime",
7236                "provider",
7237                vec!["agent.runtime"],
7238                vec![],
7239            )],
7240            registry,
7241            candidate,
7242        );
7243
7244        let response = super::verify_generation(Path("weft-claw".into()), State(state.clone()))
7245            .await
7246            .expect("developer verification should succeed");
7247        assert_eq!(response.0["status"], "verified");
7248        assert_eq!(response.0["dev_unsealed"], true);
7249        let generation = &response.0["generation"];
7250        let validations = generation["validation_results"]
7251            .as_array()
7252            .expect("validation result array");
7253        assert!(validations.iter().any(|result| {
7254            result["check"] == "digest-policy:agent-runtime"
7255                && result["passed"] == true
7256                && result["message"]
7257                    .as_str()
7258                    .unwrap_or_default()
7259                    .contains("dev-unsealed")
7260        }));
7261        assert!(validations.iter().any(|result| {
7262            result["check"] == "digest-policy:dev-unsealed" && result["passed"] == true
7263        }));
7264
7265        let activated = activate_generation(Path("weft-claw".into()), State(state.clone()))
7266            .await
7267            .expect("activation should succeed for verified developer candidate");
7268        assert_eq!(activated.0["status"], "activated");
7269
7270        let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
7271            .expect("lock file written");
7272        assert!(lock.dev_unsealed);
7273        assert_eq!(lock.trust_level, "dev-unsealed");
7274    }
7275
7276    #[tokio::test]
7277    async fn verify_generation_warns_when_store_metadata_is_absent_for_backward_compatibility() {
7278        let root = tempdir().expect("temp dir");
7279        let repo_root = root.path().to_path_buf();
7280        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7281            &repo_root,
7282            Some("team"),
7283            "[scenes.team]\nname = 'team'\nprofile = 'safe'\n",
7284        );
7285
7286        let app = ResolvedApp {
7287            name: "weft-claw".into(),
7288            version: "0.1.0".into(),
7289            capabilities: vec!["agent.runtime".into()],
7290            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7291            sources: ResolvedAppSources {
7292                manifest_path: manifest_path.display().to_string(),
7293                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7294                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7295            },
7296            ..ResolvedApp::default()
7297        };
7298        let app_config_path = instance_dir.join("config.toml");
7299        let state = state_for_propose_generation(
7300            repo_root.clone(),
7301            app.clone(),
7302            vec![test_package_source(
7303                "agent-runtime",
7304                "provider",
7305                vec!["agent.runtime"],
7306                vec![],
7307            )],
7308        );
7309        let candidate = candidate_with_verified_identities(
7310            &state,
7311            &app_config_path,
7312            test_generation(
7313                "agent.runtime",
7314                "agent-runtime",
7315                "safe",
7316                GenerationStatus::Candidate,
7317            ),
7318        );
7319        let state = state_for_verify_generation(
7320            repo_root,
7321            app,
7322            vec![test_package_source(
7323                "agent-runtime",
7324                "provider",
7325                vec!["agent.runtime"],
7326                vec![],
7327            )],
7328            registry_with_provider("agent.runtime", "agent-runtime"),
7329            candidate,
7330        );
7331
7332        let response = super::verify_generation(Path("weft-claw".into()), State(state))
7333            .await
7334            .expect("verification should succeed when store metadata is absent");
7335        let validations = response.0["generation"]["validation_results"]
7336            .as_array()
7337            .expect("validation array");
7338
7339        assert!(validations.iter().any(|result| {
7340            result["check"] == "store-policy:agent-runtime"
7341                && result["passed"] == true
7342                && result["message"]
7343                    .as_str()
7344                    .unwrap_or_default()
7345                    .contains("backward compatibility")
7346        }));
7347    }
7348
7349    #[tokio::test]
7350    async fn verify_generation_passes_when_summary_identities_match_recomputed_values() {
7351        let root = tempdir().expect("temp dir");
7352        let repo_root = root.path().to_path_buf();
7353        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7354            &repo_root,
7355            Some("team"),
7356            "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7357        );
7358
7359        let app = ResolvedApp {
7360            name: "weft-claw".into(),
7361            version: "0.1.0".into(),
7362            capabilities: vec!["agent.runtime".into()],
7363            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7364            sources: ResolvedAppSources {
7365                manifest_path: manifest_path.display().to_string(),
7366                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7367                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7368            },
7369            ..ResolvedApp::default()
7370        };
7371        let app_config_path = instance_dir.join("config.toml");
7372        let state = state_for_propose_generation(
7373            repo_root.clone(),
7374            app.clone(),
7375            vec![test_package_source(
7376                "agent-runtime",
7377                "provider",
7378                vec!["agent.runtime"],
7379                vec![],
7380            )],
7381        );
7382        let candidate = candidate_with_verified_identities(
7383            &state,
7384            &app_config_path,
7385            test_generation(
7386                "agent.runtime",
7387                "agent-runtime",
7388                "developer",
7389                GenerationStatus::Candidate,
7390            ),
7391        );
7392        let state = state_for_verify_generation(
7393            repo_root,
7394            app,
7395            vec![test_package_source(
7396                "agent-runtime",
7397                "provider",
7398                vec!["agent.runtime"],
7399                vec![],
7400            )],
7401            registry_with_provider("agent.runtime", "agent-runtime"),
7402            candidate,
7403        );
7404
7405        let response = super::verify_generation(Path("weft-claw".into()), State(state))
7406            .await
7407            .expect("verification should succeed");
7408
7409        assert_eq!(response.0["status"], "verified");
7410        let validations = response.0["generation"]["validation_results"]
7411            .as_array()
7412            .expect("validation result array");
7413        assert!(validations.iter().any(|result| {
7414            result["check"] == "identity-consistency:binding_set_id"
7415                && result["passed"] == true
7416                && result["message"]
7417                    .as_str()
7418                    .unwrap_or_default()
7419                    .contains("matches the recomputed binding identity")
7420        }));
7421        assert!(validations.iter().any(|result| {
7422            result["check"] == "identity-consistency:closure_id"
7423                && result["passed"] == true
7424                && result["message"]
7425                    .as_str()
7426                    .unwrap_or_default()
7427                    .contains("matches the recomputed closure identity")
7428        }));
7429    }
7430
7431    #[tokio::test]
7432    async fn verify_generation_rejects_stale_binding_set_id_metadata() {
7433        let root = tempdir().expect("temp dir");
7434        let repo_root = root.path().to_path_buf();
7435        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7436            &repo_root,
7437            Some("team"),
7438            "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7439        );
7440
7441        let app = ResolvedApp {
7442            name: "weft-claw".into(),
7443            version: "0.1.0".into(),
7444            capabilities: vec!["agent.runtime".into()],
7445            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7446            sources: ResolvedAppSources {
7447                manifest_path: manifest_path.display().to_string(),
7448                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7449                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7450            },
7451            ..ResolvedApp::default()
7452        };
7453        let app_config_path = instance_dir.join("config.toml");
7454        let proposal_state = state_for_propose_generation(
7455            repo_root.clone(),
7456            app.clone(),
7457            vec![test_package_source(
7458                "agent-runtime",
7459                "provider",
7460                vec!["agent.runtime"],
7461                vec![],
7462            )],
7463        );
7464        let mut candidate = candidate_with_verified_identities(
7465            &proposal_state,
7466            &app_config_path,
7467            test_generation(
7468                "agent.runtime",
7469                "agent-runtime",
7470                "developer",
7471                GenerationStatus::Candidate,
7472            ),
7473        );
7474        candidate.binding_set_id = "binding-set:sha256:stale".into();
7475        let state = state_for_verify_generation(
7476            repo_root,
7477            app,
7478            vec![test_package_source(
7479                "agent-runtime",
7480                "provider",
7481                vec!["agent.runtime"],
7482                vec![],
7483            )],
7484            registry_with_provider("agent.runtime", "agent-runtime"),
7485            candidate,
7486        );
7487
7488        let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7489            .await
7490            .expect_err("verification should fail for stale binding_set_id");
7491
7492        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7493        assert_eq!(body.0["reason"], "generation_verify_failed");
7494        let validations = body.0["candidate"]["validation_results"]
7495            .as_array()
7496            .expect("validation result array");
7497        assert!(validations.iter().any(|result| {
7498            result["check"] == "identity-consistency:binding_set_id"
7499                && result["passed"] == false
7500                && result["message"]
7501                    .as_str()
7502                    .unwrap_or_default()
7503                    .contains("does not match recomputed binding identity")
7504        }));
7505    }
7506
7507    #[tokio::test]
7508    async fn verify_generation_rejects_stale_closure_id_metadata() {
7509        let root = tempdir().expect("temp dir");
7510        let repo_root = root.path().to_path_buf();
7511        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7512            &repo_root,
7513            Some("team"),
7514            "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7515        );
7516
7517        let app = ResolvedApp {
7518            name: "weft-claw".into(),
7519            version: "0.1.0".into(),
7520            capabilities: vec!["agent.runtime".into()],
7521            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7522            sources: ResolvedAppSources {
7523                manifest_path: manifest_path.display().to_string(),
7524                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7525                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7526            },
7527            ..ResolvedApp::default()
7528        };
7529        let app_config_path = instance_dir.join("config.toml");
7530        let proposal_state = state_for_propose_generation(
7531            repo_root.clone(),
7532            app.clone(),
7533            vec![test_package_source(
7534                "agent-runtime",
7535                "provider",
7536                vec!["agent.runtime"],
7537                vec![],
7538            )],
7539        );
7540        let mut candidate = candidate_with_verified_identities(
7541            &proposal_state,
7542            &app_config_path,
7543            test_generation(
7544                "agent.runtime",
7545                "agent-runtime",
7546                "developer",
7547                GenerationStatus::Candidate,
7548            ),
7549        );
7550        candidate.closure_id = "closure:sha256:stale".into();
7551        let state = state_for_verify_generation(
7552            repo_root,
7553            app,
7554            vec![test_package_source(
7555                "agent-runtime",
7556                "provider",
7557                vec!["agent.runtime"],
7558                vec![],
7559            )],
7560            registry_with_provider("agent.runtime", "agent-runtime"),
7561            candidate,
7562        );
7563
7564        let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7565            .await
7566            .expect_err("verification should fail for stale closure_id");
7567
7568        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7569        assert_eq!(body.0["reason"], "generation_verify_failed");
7570        let validations = body.0["candidate"]["validation_results"]
7571            .as_array()
7572            .expect("validation result array");
7573        assert!(validations.iter().any(|result| {
7574            result["check"] == "identity-consistency:closure_id"
7575                && result["passed"] == false
7576                && result["message"]
7577                    .as_str()
7578                    .unwrap_or_default()
7579                    .contains("does not match recomputed closure identity")
7580        }));
7581    }
7582
7583    #[tokio::test]
7584    async fn verify_generation_allows_empty_identity_metadata_with_warnings() {
7585        let root = tempdir().expect("temp dir");
7586        let repo_root = root.path().to_path_buf();
7587        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7588            &repo_root,
7589            Some("team"),
7590            "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7591        );
7592
7593        let app = ResolvedApp {
7594            name: "weft-claw".into(),
7595            version: "0.1.0".into(),
7596            capabilities: vec!["agent.runtime".into()],
7597            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7598            sources: ResolvedAppSources {
7599                manifest_path: manifest_path.display().to_string(),
7600                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7601                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7602            },
7603            ..ResolvedApp::default()
7604        };
7605        let candidate = test_generation(
7606            "agent.runtime",
7607            "agent-runtime",
7608            "developer",
7609            GenerationStatus::Candidate,
7610        );
7611        let state = state_for_verify_generation(
7612            repo_root,
7613            app,
7614            vec![test_package_source(
7615                "agent-runtime",
7616                "provider",
7617                vec!["agent.runtime"],
7618                vec![],
7619            )],
7620            registry_with_provider("agent.runtime", "agent-runtime"),
7621            candidate,
7622        );
7623
7624        let response = super::verify_generation(Path("weft-claw".into()), State(state))
7625            .await
7626            .expect("verification should allow empty legacy identities");
7627
7628        assert_eq!(response.0["status"], "verified");
7629        let validations = response.0["generation"]["validation_results"]
7630            .as_array()
7631            .expect("validation result array");
7632        assert!(validations.iter().any(|result| {
7633            result["check"] == "identity-consistency:binding_set_id"
7634                && result["passed"] == true
7635                && result["message"]
7636                    .as_str()
7637                    .unwrap_or_default()
7638                    .contains("missing binding_set_id metadata")
7639        }));
7640        assert!(validations.iter().any(|result| {
7641            result["check"] == "identity-consistency:closure_id"
7642                && result["passed"] == true
7643                && result["message"]
7644                    .as_str()
7645                    .unwrap_or_default()
7646                    .contains("missing closure_id metadata")
7647        }));
7648    }
7649
7650    #[tokio::test]
7651    async fn verify_generation_rejects_candidate_when_required_capability_binding_missing() {
7652        let root = tempdir().expect("temp dir");
7653        let repo_root = root.path().to_path_buf();
7654        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(&repo_root, None, "");
7655
7656        let app = ResolvedApp {
7657            name: "weft-claw".into(),
7658            version: "0.1.0".into(),
7659            capabilities: vec!["agent.runtime".into()],
7660            bindings: vec![],
7661            sources: ResolvedAppSources {
7662                manifest_path: manifest_path.display().to_string(),
7663                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7664                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7665            },
7666            ..ResolvedApp::default()
7667        };
7668        let candidate = AppGeneration {
7669            bindings: vec![],
7670            capabilities: vec!["agent.runtime".into()],
7671            profile: "developer".into(),
7672            status: GenerationStatus::Candidate,
7673            ..test_generation(
7674                "agent.runtime",
7675                "agent-runtime",
7676                "developer",
7677                GenerationStatus::Candidate,
7678            )
7679        };
7680        let mut registry = CapabilityRegistry::new();
7681        registry.insert(
7682            "agent.runtime".into(),
7683            CapabilityRegistryEntry {
7684                capability: "agent.runtime".into(),
7685                providers: vec![],
7686                bindings: vec![],
7687            },
7688        );
7689        let state = state_for_verify_generation(repo_root, app, vec![], registry, candidate);
7690
7691        let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7692            .await
7693            .expect_err("verification should fail without required binding");
7694
7695        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7696        assert_eq!(body.0["reason"], "generation_verify_failed");
7697        let validations = body.0["candidate"]["validation_results"]
7698            .as_array()
7699            .expect("validation result array");
7700        assert!(validations.iter().any(|result| {
7701            result["check"] == "binding-coverage:agent.runtime"
7702                && result["passed"] == false
7703                && result["message"]
7704                    .as_str()
7705                    .unwrap_or_default()
7706                    .contains("has no provider binding")
7707        }));
7708    }
7709
7710    #[tokio::test]
7711    async fn verify_generation_rejects_candidate_when_binding_provider_not_in_selected_package_summary(
7712    ) {
7713        let root = tempdir().expect("temp dir");
7714        let repo_root = root.path().to_path_buf();
7715        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7716            &repo_root,
7717            Some("team"),
7718            r#"[scenes.team]
7719name = 'team'
7720profile = 'developer'
7721"#,
7722        );
7723
7724        let app = ResolvedApp {
7725            name: "weft-claw".into(),
7726            version: "0.1.0".into(),
7727            capabilities: vec!["agent.runtime".into()],
7728            bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7729            sources: ResolvedAppSources {
7730                manifest_path: manifest_path.display().to_string(),
7731                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7732                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7733            },
7734            ..ResolvedApp::default()
7735        };
7736        let candidate = test_generation(
7737            "agent.runtime",
7738            "agent-runtime",
7739            "developer",
7740            GenerationStatus::Candidate,
7741        );
7742        let mut registry = CapabilityRegistry::new();
7743        registry.insert(
7744            "agent.runtime".into(),
7745            CapabilityRegistryEntry {
7746                capability: "agent.runtime".into(),
7747                providers: vec![CapabilityProviderRecord {
7748                    provider: "agent-runtime".into(),
7749                    runtime: "metadata".into(),
7750                    priority: 0,
7751                }],
7752                bindings: vec![],
7753            },
7754        );
7755        let state = state_for_verify_generation(
7756            repo_root,
7757            app,
7758            vec![PackageSource {
7759                name: "prompt-system".into(),
7760                kind: "metadata".into(),
7761                package_kind: "provider".into(),
7762                runtime_provider: "prompt-system".into(),
7763                current_source: "packages/weft-claw".into(),
7764                trusted: true,
7765                signature: "builtin:test".into(),
7766                source_authority: "test".into(),
7767                source_public_keys: vec![],
7768                provides: vec!["agent.runtime".into()],
7769                requires: vec![],
7770            }],
7771            registry,
7772            candidate,
7773        );
7774
7775        let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7776            .await
7777            .expect_err("verification should fail when binding provider is excluded");
7778
7779        assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7780        assert_eq!(body.0["reason"], "generation_verify_failed");
7781        let validations = body.0["candidate"]["validation_results"]
7782            .as_array()
7783            .expect("validation result array");
7784        assert!(validations.iter().any(|result| {
7785            result["check"] == "binding-coverage:agent.runtime"
7786                && result["passed"] == false
7787                && result["message"]
7788                    .as_str()
7789                    .unwrap_or_default()
7790                    .contains("no included selected package summary entry matches")
7791        }));
7792    }
7793
7794    #[tokio::test]
7795    async fn verify_generation_allows_core_provider_binding_for_coverage() {
7796        let root = tempdir().expect("temp dir");
7797        let repo_root = root.path().to_path_buf();
7798        let (_, instance_dir, manifest_path) = write_test_manifest_and_config(&repo_root, None, "");
7799
7800        let app = ResolvedApp {
7801            name: "weft-claw".into(),
7802            version: "0.1.0".into(),
7803            capabilities: vec!["core.execution".into()],
7804            bindings: vec![test_binding("core.execution", "core")],
7805            sources: ResolvedAppSources {
7806                manifest_path: manifest_path.display().to_string(),
7807                config_path: Some(instance_dir.join("config.toml").display().to_string()),
7808                lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7809            },
7810            ..ResolvedApp::default()
7811        };
7812        let candidate = AppGeneration {
7813            bindings: vec![test_binding("core.execution", "core")],
7814            capabilities: vec!["core.execution".into()],
7815            profile: "developer".into(),
7816            status: GenerationStatus::Candidate,
7817            ..test_generation(
7818                "core.execution",
7819                "core",
7820                "developer",
7821                GenerationStatus::Candidate,
7822            )
7823        };
7824        let mut registry = CapabilityRegistry::new();
7825        registry.insert(
7826            "core.execution".into(),
7827            CapabilityRegistryEntry {
7828                capability: "core.execution".into(),
7829                providers: vec![CapabilityProviderRecord {
7830                    provider: "core".into(),
7831                    runtime: "native".into(),
7832                    priority: 0,
7833                }],
7834                bindings: vec![],
7835            },
7836        );
7837        let state = state_for_verify_generation(repo_root, app, vec![], registry, candidate);
7838
7839        let response = super::verify_generation(Path("weft-claw".into()), State(state))
7840            .await
7841            .expect("core provider binding should pass coverage verification");
7842
7843        assert_eq!(response.0["status"], "verified");
7844        let validations = response.0["generation"]["validation_results"]
7845            .as_array()
7846            .expect("validation result array");
7847        assert!(validations.iter().any(|result| {
7848            result["check"] == "binding-coverage:core.execution"
7849                && result["passed"] == true
7850                && result["message"]
7851                    .as_str()
7852                    .unwrap_or_default()
7853                    .contains("bound to core")
7854        }));
7855    }
7856
7857    #[tokio::test]
7858    async fn activate_generation_sets_dev_unsealed_from_structured_validation_check() {
7859        let root = tempdir().expect("temp dir");
7860        let repo_root = root.path().to_path_buf();
7861        let instance_dir = repo_root.join(".weft").join("weft-claw");
7862        fs::create_dir_all(&instance_dir).expect("instance dir created");
7863        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
7864            .expect("lock file seeded");
7865
7866        let state = state_with_generation_store(
7867            repo_root,
7868            test_resolved_app("weft-claw", Some(&instance_dir)),
7869            AppGenerationStore {
7870                active: Some(generation_with_id(1, GenerationStatus::Active)),
7871                candidate: Some(generation_with_validation_results(
7872                    2,
7873                    GenerationStatus::Verified,
7874                    "developer",
7875                    vec![crate::app::ValidationResult {
7876                        check: "digest-policy:dev-unsealed".into(),
7877                        passed: true,
7878                        message: "Warning: candidate verification succeeded with developer-only dev-unsealed digest policy exceptions".into(),
7879                    }],
7880                )),
7881                rollback: None,
7882                next_id: 3,
7883            },
7884        );
7885
7886        let response = activate_generation(Path("weft-claw".into()), State(state))
7887            .await
7888            .expect("activation succeeds");
7889
7890        assert_eq!(response.0["status"], "activated");
7891        let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
7892            .expect("lock file written");
7893        assert!(lock.dev_unsealed);
7894        assert_eq!(lock.trust_level, "dev-unsealed");
7895    }
7896
7897    #[tokio::test]
7898    async fn activate_generation_rejects_failed_candidate_before_writes() {
7899        let root = tempdir().expect("temp dir");
7900        let repo_root = root.path().to_path_buf();
7901        let instance_dir = repo_root.join(".weft").join("weft-claw");
7902        fs::create_dir_all(&instance_dir).expect("instance dir created");
7903        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
7904            .expect("lock file seeded");
7905
7906        let state = state_with_generation_store(
7907            repo_root,
7908            test_resolved_app("weft-claw", Some(&instance_dir)),
7909            AppGenerationStore {
7910                active: Some(generation_with_id(1, GenerationStatus::Active)),
7911                candidate: Some(generation_with_id(2, GenerationStatus::Failed)),
7912                rollback: None,
7913                next_id: 3,
7914            },
7915        );
7916
7917        let (status, body) = activate_generation(Path("weft-claw".into()), State(state.clone()))
7918            .await
7919            .expect_err("failed candidate activation should be rejected");
7920
7921        assert_eq!(status, HttpStatusCode::CONFLICT);
7922        let payload = body.0;
7923        assert_eq!(
7924            payload["error"],
7925            "Candidate must be verified before activation"
7926        );
7927        assert!(payload["reason"].is_null());
7928        assert!(payload["status"].is_null());
7929        assert!(payload["lock_written"].is_null());
7930        assert!(payload["pointer_written"].is_null());
7931        assert!(payload["pointer_error"].is_null());
7932        assert!(payload["index_written"].is_null());
7933        assert!(payload["index_error"].is_null());
7934
7935        let store = state.generation_store.read().await;
7936        let app_store = store.get("weft-claw").expect("store present");
7937        assert_eq!(
7938            app_store.active.as_ref().map(|generation| generation.id),
7939            Some(1)
7940        );
7941        assert_eq!(
7942            app_store
7943                .candidate
7944                .as_ref()
7945                .map(|generation| generation.status),
7946            Some(GenerationStatus::Failed)
7947        );
7948        assert_eq!(
7949            crate::app::read_active_generation_pointer(&instance_dir)
7950                .expect("active pointer read succeeds"),
7951            None
7952        );
7953        assert_eq!(
7954            crate::app::read_previous_generation_pointer(&instance_dir)
7955                .expect("previous pointer read succeeds"),
7956            None
7957        );
7958        assert!(crate::app::load_generation_index(&instance_dir)
7959            .expect("index load succeeds")
7960            .is_none());
7961        let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
7962            .expect("lock remains readable");
7963        assert_eq!(lock.generation, 0);
7964        assert_eq!(lock.status, "");
7965    }
7966
7967    #[tokio::test]
7968    async fn activate_generation_rejects_dev_unsealed_marker_under_safe_profile_before_writes() {
7969        let root = tempdir().expect("temp dir");
7970        let repo_root = root.path().to_path_buf();
7971        let instance_dir = repo_root.join(".weft").join("weft-claw");
7972        fs::create_dir_all(&instance_dir).expect("instance dir created");
7973        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
7974            .expect("lock file seeded");
7975
7976        let state = state_with_generation_store(
7977            repo_root,
7978            test_resolved_app("weft-claw", Some(&instance_dir)),
7979            AppGenerationStore {
7980                active: Some(generation_with_id(1, GenerationStatus::Active)),
7981                candidate: Some(generation_with_validation_results(
7982                    2,
7983                    GenerationStatus::Verified,
7984                    "safe",
7985                    vec![crate::app::ValidationResult {
7986                        check: "digest-policy:dev-unsealed".into(),
7987                        passed: true,
7988                        message: "Warning: candidate verification succeeded with developer-only dev-unsealed digest policy exceptions".into(),
7989                    }],
7990                )),
7991                rollback: None,
7992                next_id: 3,
7993            },
7994        );
7995
7996        let (status, body) = activate_generation(Path("weft-claw".into()), State(state.clone()))
7997            .await
7998            .expect_err("safe-profile dev-unsealed activation should be rejected");
7999
8000        assert_eq!(status, HttpStatusCode::CONFLICT);
8001        let payload = body.0;
8002        assert_eq!(payload["reason"], "dev_unsealed_profile_mismatch");
8003        assert_eq!(payload["status"], "activation_failed");
8004        assert_eq!(payload["lock_written"], false);
8005        assert_eq!(payload["pointer_written"], false);
8006        assert!(payload["pointer_error"].is_null());
8007        assert_eq!(payload["index_written"], false);
8008        assert!(payload["index_error"].is_null());
8009        assert!(payload["error"]
8010            .as_str()
8011            .expect("error string")
8012            .contains("developer-only dev-unsealed validation"));
8013
8014        let store = state.generation_store.read().await;
8015        let app_store = store.get("weft-claw").expect("store present");
8016        assert_eq!(
8017            app_store.active.as_ref().map(|generation| generation.id),
8018            Some(1)
8019        );
8020        assert_eq!(
8021            app_store.candidate.as_ref().map(|generation| generation.id),
8022            Some(2)
8023        );
8024        assert_eq!(
8025            crate::app::read_active_generation_pointer(&instance_dir)
8026                .expect("active pointer read succeeds"),
8027            None
8028        );
8029        assert_eq!(
8030            crate::app::read_previous_generation_pointer(&instance_dir)
8031                .expect("previous pointer read succeeds"),
8032            None
8033        );
8034        assert!(crate::app::load_generation_index(&instance_dir)
8035            .expect("index load succeeds")
8036            .is_none());
8037        let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
8038            .expect("lock remains readable");
8039        assert_eq!(lock.generation, 0);
8040        assert_eq!(lock.status, "");
8041    }
8042
8043    #[tokio::test]
8044    async fn activate_generation_does_not_set_dev_unsealed_without_structured_validation_check() {
8045        let root = tempdir().expect("temp dir");
8046        let repo_root = root.path().to_path_buf();
8047        let instance_dir = repo_root.join(".weft").join("weft-claw");
8048        fs::create_dir_all(&instance_dir).expect("instance dir created");
8049        fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
8050            .expect("lock file seeded");
8051
8052        let state = state_with_generation_store(
8053            repo_root,
8054            test_resolved_app("weft-claw", Some(&instance_dir)),
8055            AppGenerationStore {
8056                active: Some(generation_with_id(1, GenerationStatus::Active)),
8057                candidate: Some(generation_with_validation_results(
8058                    2,
8059                    GenerationStatus::Verified,
8060                    "developer",
8061                    vec![crate::app::ValidationResult {
8062                        check: "digest-policy:agent-runtime".into(),
8063                        passed: true,
8064                        message: "Warning: package 'agent-runtime' is missing usable digest metadata for source 'packages/official/agent-runtime' under profile 'developer'; candidate remains verified as dev-unsealed".into(),
8065                    }],
8066                )),
8067                rollback: None,
8068                next_id: 3,
8069            },
8070        );
8071
8072        let response = activate_generation(Path("weft-claw".into()), State(state))
8073            .await
8074            .expect("activation succeeds");
8075
8076        assert_eq!(response.0["status"], "activated");
8077        let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
8078            .expect("lock file written");
8079        assert!(!lock.dev_unsealed);
8080        assert_eq!(lock.trust_level, "");
8081    }
8082
8083    #[test]
8084    fn selected_packages_skip_metadata_only_weft_claw_required_sources() {
8085        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
8086        let state = AppState {
8087            package_index: Arc::new(PackageIndex {
8088                version: 1,
8089                revision: "test-rev".into(),
8090                source_url: "local://packages".into(),
8091                package_sources: vec![
8092                    PackageSource {
8093                        name: "prompt-system".into(),
8094                        kind: "metadata".into(),
8095                        package_kind: "foundation".into(),
8096                        runtime_provider: "agent-core".into(),
8097                        current_source: "packages/weft-claw".into(),
8098                        trusted: true,
8099                        signature: "builtin:product-package".into(),
8100                        source_authority: String::new(),
8101                        source_public_keys: vec![],
8102                        provides: vec!["prompt.system".into()],
8103                        requires: vec![],
8104                    },
8105                    PackageSource {
8106                        name: "agent-runtime".into(),
8107                        kind: "embedded".into(),
8108                        package_kind: "feature".into(),
8109                        runtime_provider: "agent-runtime".into(),
8110                        current_source: "packages/official/agent-core".into(),
8111                        trusted: true,
8112                        signature: "builtin:official".into(),
8113                        source_authority: "official".into(),
8114                        source_public_keys: vec![],
8115                        provides: vec![
8116                            "agent.runtime".into(),
8117                            "prompt.system".into(),
8118                            "memory.store".into(),
8119                        ],
8120                        requires: vec![
8121                            "agent-runtime".into(),
8122                            "prompt-system".into(),
8123                            "memory-store".into(),
8124                        ],
8125                    },
8126                    PackageSource {
8127                        name: "memory-store".into(),
8128                        kind: "wasm".into(),
8129                        package_kind: "provider".into(),
8130                        runtime_provider: "memory-store".into(),
8131                        current_source: "packages/official/memory".into(),
8132                        trusted: true,
8133                        signature: "builtin:official".into(),
8134                        source_authority: "official".into(),
8135                        source_public_keys: vec![],
8136                        provides: vec!["memory.store".into()],
8137                        requires: vec![],
8138                    },
8139                ],
8140            }),
8141            ..test_state(repo_root, CapabilityRegistry::new())
8142        };
8143
8144        let packages = packages_for_selected_names(
8145            &state,
8146            &[
8147                "prompt-system".to_string(),
8148                "agent-runtime".to_string(),
8149                "memory-store".to_string(),
8150            ],
8151        );
8152
8153        assert!(packages.iter().all(|pkg| pkg.name != "prompt-system"));
8154        assert!(packages.iter().any(|pkg| pkg.name == "agent-runtime"));
8155        assert!(packages.iter().any(|pkg| pkg.name == "memory-store"));
8156    }
8157}