Skip to main content

memstead_git_branch/
repair.rs

1//! Below-boot repair surface — the verbs a boot-failure message names
2//! must run on exactly the workspace whose boot they repair.
3//!
4//! During the 2026-08-06/07 plenum outage both named remedies
5//! (`memstead schema install`, `memstead mem set-schema`) booted the
6//! full workspace unconditionally, so they failed on the very boot they
7//! were supposed to fix. This module supplies their below-boot forms:
8//! they load the workspace *description* (mount roster) and touch only
9//! configuration and schema storage — no backend-wide instantiation, no
10//! schema-pin resolution over every mem, no entity load. "Below boot"
11//! means below *workspace load*, never outside the engine's write
12//! discipline: pin writes go through the same
13//! [`memstead_base::engine::lifecycle::bump_backend_schema_pin`] the
14//! booted path uses, target refs resolve through the same
15//! [`SchemaResolver`] over the same catalogue construction, and package
16//! validation is the same [`Engine::validate_schema_package`] gate —
17//! one implementation per check, so the booted and below-boot paths
18//! cannot fork into two validation regimes.
19//!
20//! What below-boot `set-schema` deliberately does NOT do: the booted
21//! path's conformance gate over loaded entities (migration semantics).
22//! Entities are unreadable before boot — that is the point. The pin is
23//! switched directly; the next (now green) boot's health surfaces any
24//! conformance findings.
25
26use std::path::Path;
27use std::sync::Arc;
28
29use memstead_base::engine::error::SchemaSourceDiagnostic;
30use memstead_base::engine::lifecycle::bump_backend_schema_pin;
31use memstead_base::engine::{SchemaResolver, load_workspace_schemas};
32use memstead_base::workspace_store::WorkspaceStoreAdapter;
33use memstead_base::{BootError, Engine, EngineError, FileWorkspaceStore};
34
35/// Outcome of [`set_mem_schema_below_boot`].
36#[derive(Debug, serde::Serialize)]
37pub struct BelowBootSetSchema {
38    pub mem: String,
39    /// The new settled pin, `<name>@<version>`.
40    pub schema_pin: String,
41    /// Whether the mem's backend config carried the pin (config-absent
42    /// mems keep `Mount.schema` in `mounts.json` as their settled pin).
43    pub config_updated: bool,
44    /// Always `false` on this path — recorded explicitly so consumers
45    /// (and the operator) see that the booted path's conformance gate
46    /// did not run; the next boot's health carries any findings.
47    pub conformance_checked: bool,
48}
49
50/// The schema-resolution catalogue a below-boot repair consults —
51/// the same construction the boot path performs in
52/// `engine_from_workspace_root`: workspace-authored schemas (the fixed
53/// `.memstead/schemas/` dir via the shared [`load_workspace_schemas`]
54/// walker, plus the `__MEMSTEAD:schemas/` ref) layered over the
55/// built-ins. Ref-read failures degrade to built-ins with a warning,
56/// mirroring the boot path's best-effort overlay.
57fn below_boot_schema_catalogue(
58    workspace_root: &Path,
59) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
60    let fixed_dir = workspace_root.join(".memstead").join("schemas");
61    let mut catalogue = load_workspace_schemas(Some(fixed_dir.as_path()))?;
62    use memstead_base::schema_source::SchemaSource as _;
63    match crate::mem_repo_schemas::GitBranchSchemaSource::for_workspace(workspace_root)
64        .read_schemas()
65    {
66        Ok(schemas) => catalogue.extend(schemas),
67        Err(e) => {
68            tracing::warn!(
69                "below-boot repair: could not read schemas from `__MEMSTEAD:schemas/` at {}: {e}; \
70                 resolving against the folder dir and built-ins only",
71                workspace_root.display()
72            );
73        }
74    }
75    catalogue.extend(
76        memstead_schema::builtins::load_builtin_schemas()
77            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?,
78    );
79    Ok(catalogue)
80}
81
82/// Repin a mem's schema without booting the workspace — the below-boot
83/// form of `memstead mem set-schema`, for workspaces whose boot fails
84/// (typically on the unresolvable pin this call repairs).
85///
86/// The target ref must resolve in the shared catalogue; an unresolvable
87/// target refuses with the same `SCHEMA_NOT_FOUND` trail the booted
88/// path produces — repair never force-writes a pin that resolves
89/// nowhere. A corrupt workspace store refuses typed through
90/// [`BootError::Store`].
91pub fn set_mem_schema_below_boot(
92    workspace_root: &Path,
93    mem: &str,
94    target: &memstead_schema::SchemaRef,
95) -> Result<BelowBootSetSchema, BootError> {
96    let mut workspace = crate::workspace_store::load_workspace_description(workspace_root)?;
97    let mount_idx = workspace
98        .mounts
99        .iter()
100        .position(|m| m.mem == mem)
101        .ok_or_else(|| BootError::Engine(EngineError::UnknownMem(mem.to_string())))?;
102
103    // Shared target-ref validation: same resolver, same catalogue
104    // construction, same refusal shape as the booted path.
105    let catalogue = below_boot_schema_catalogue(workspace_root).map_err(BootError::Engine)?;
106    SchemaResolver::new(&catalogue)
107        .resolve(target)
108        .map_err(|_sources| {
109            BootError::Engine(
110                EngineError::SchemaNotFound {
111                    mem: mem.to_string(),
112                    pin: target.as_display(),
113                    sources: SchemaSourceDiagnostic::for_failed_pin(
114                        &target.name,
115                        &target.version,
116                        &catalogue,
117                    ),
118                    install_hint: None,
119                }
120                .with_schema_install_probe(Some(workspace_root)),
121            )
122        })?;
123
124    // Authoritative home first: the mem's backend config, through the
125    // same value-level bump the booted path uses. Only this one mount's
126    // backend is instantiated — no workspace-wide boot.
127    let backend = crate::storage::instantiate_full_backend(&workspace.mounts[mount_idx])
128        .map_err(|e| BootError::Engine(EngineError::Mem(e.to_string())))?;
129    let config_updated = bump_backend_schema_pin(backend.as_ref(), target)
130        .map_err(BootError::Engine)?
131        .is_some();
132
133    // Keep the mounts.json assertion in sync and clear any in-flight
134    // migration target (the repair settles the pin). Standalone
135    // workspaces (bare folder mem, no `.memstead/workspace.toml`
136    // marker) have no mount state to persist — the config bump above
137    // is their whole repair.
138    workspace.mounts[mount_idx].schema = Some(target.clone());
139    workspace.mounts[mount_idx].migration_target = None;
140    if matches!(
141        memstead_base::detect_layout(workspace_root),
142        memstead_base::Layout::New
143    ) {
144        FileWorkspaceStore::new().save_state(workspace_root, &workspace)?;
145    }
146
147    Ok(BelowBootSetSchema {
148        mem: mem.to_string(),
149        schema_pin: target.as_display(),
150        config_updated,
151        conformance_checked: false,
152    })
153}
154
155/// Install a schema package onto the workspace's `__MEMSTEAD:schemas/`
156/// ref without booting the workspace — the below-boot form of
157/// `memstead schema install` for mem-repo workspaces. Runs the same
158/// [`Engine::validate_schema_package`] gate as the booted path, then
159/// writes through the same ref writer. Returns the resulting
160/// `__MEMSTEAD` tip commit sha.
161///
162/// The gitdir resolves like the booted path prefers it: a git-branch
163/// mount's declared gitdir from the workspace description, falling
164/// back to `<root>/mem-repo/.git` when no git-branch mount is
165/// declared. A genuinely corrupt workspace store refuses typed
166/// (`BootError::Store`) — repair operates below boot, not below the
167/// workspace's own description.
168pub fn install_schema_below_boot(
169    workspace_root: &Path,
170    name: &str,
171    version: &str,
172    files: &[(String, Vec<u8>)],
173) -> Result<String, BootError> {
174    Engine::validate_schema_package(name, version, files).map_err(BootError::Engine)?;
175    let gitdir = crate::workspace_store::load_workspace_description(workspace_root)?
176        .mounts
177        .iter()
178        .find_map(|m| match &m.storage {
179            memstead_base::MountStorage::GitBranch { gitdir, .. } => Some(gitdir.clone()),
180            _ => None,
181        })
182        .unwrap_or_else(|| workspace_root.join("mem-repo").join(".git"));
183    if !gitdir.exists() {
184        return Err(BootError::Engine(EngineError::Mem(format!(
185            "schema install requires a mem-repo workspace — no git-branch mount and no \
186             mem-repo gitdir at {}",
187            gitdir.display()
188        ))));
189    }
190    let files = memstead_schema::loader::with_format_marker(files.to_vec());
191    let outcome =
192        crate::storage_memstead::write_schema_to_memstead_ref(&gitdir, name, version, &files)
193            .map_err(|e| {
194                BootError::Engine(EngineError::Mem(format!(
195                    "schema install onto `__MEMSTEAD:schemas/{name}@{version}` failed: {e}"
196                )))
197            })?;
198    Ok(outcome.commit_sha)
199}