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}