memstead_base/engine/gates.rs
1//! The gates brief — the engine-rendered standing of every declared
2//! `transition_requires_checks` gate (graph-plans plan 03, the yes
3//! branch of the gated-transition spike).
4//!
5//! For each mounted mem whose schema declares the constraint on at
6//! least one type, the brief lists every non-stub entity of a gated
7//! type with its gated field's current value, whether it stands at the
8//! gated value ("closed") or before it ("open"), and — for open
9//! entities — the related-set coverage the transition would require:
10//! how many related entities the declared edges reach and which of
11//! them lack a fresh confirming check record. The related-set
12//! enumeration is [`crate::ops::health::transition_gate_standing`],
13//! the same code the write-time refusal runs, so the brief can never
14//! disagree with the gate.
15//!
16//! Open entities are listed in dependency order: a topological sort
17//! over the edges BETWEEN gated entities whose rel-type the schema
18//! declares acyclic (in the planning schema that is plan → plan
19//! REQUIRES; any schema's acyclic ordering vocabulary works the same),
20//! prerequisites first, ties by id. The brief reports state, never
21//! policy: which open entity a consumer acts on next — and which it
22//! must refuse (a human-gate marker, a parked status) — stays the
23//! consumer's judgement over its schema's vocabulary.
24//!
25//! The renderer is the shared engine entry point
26//! ([`Engine::render_gates_brief`]) per the brief-family precedent
27//! (due, ingest, sync): CLI verb, byte-identical everywhere,
28//! deliberately no MCP tool.
29
30use std::collections::HashMap;
31
32use memstead_schema::ConstraintDef;
33
34use super::Engine;
35use crate::entity::MetadataValue;
36
37impl Engine {
38 /// Render the gates brief as markdown. `mem_filter` restricts to
39 /// one mem; the default walks every mounted mem whose schema
40 /// declares the constraint. Deterministic given the store and the
41 /// check ledger.
42 pub fn render_gates_brief(&self, mem_filter: Option<&str>) -> String {
43 let checks = self.check_state_provider();
44 let mut sections: Vec<String> = Vec::new();
45 let mut declaring_mems: Vec<String> = Vec::new();
46
47 let mut mems: Vec<&str> = self
48 .mounts
49 .iter()
50 .map(|m| m.mount.mem.as_str())
51 .filter(|m| mem_filter.is_none_or(|f| f == *m))
52 .collect();
53 mems.sort_unstable();
54
55 for mem in mems {
56 let Some(schema) = self.schemas.get(mem) else {
57 continue;
58 };
59 // The gated types of this schema, with their declarations.
60 let mut gated: Vec<(&str, &ConstraintDef)> = Vec::new();
61 for td in schema.types.values() {
62 for c in &td.constraints {
63 if matches!(c, ConstraintDef::TransitionRequiresChecks { .. }) {
64 gated.push((td.name.as_str(), c));
65 }
66 }
67 }
68 if gated.is_empty() {
69 continue;
70 }
71 gated.sort_by_key(|(name, _)| *name);
72 declaring_mems.push(mem.to_string());
73
74 let mut lines: Vec<String> = Vec::new();
75 lines.push(format!("## {mem}"));
76 lines.push(String::new());
77
78 for (type_name, c) in &gated {
79 let ConstraintDef::TransitionRequiresChecks {
80 field,
81 to_value,
82 relationships,
83 direction,
84 ..
85 } = c
86 else {
87 continue;
88 };
89 lines.push(format!(
90 "Gate: `{type_name}` — `{field}: {to_value}` requires a fresh confirming \
91 check record on every entity related via [{}] ({}).",
92 relationships.join(", "),
93 match direction {
94 memstead_schema::PropagationDirection::Incoming => "incoming",
95 memstead_schema::PropagationDirection::Outgoing => "outgoing",
96 },
97 ));
98 lines.push(String::new());
99
100 // Every non-stub entity of the gated type.
101 let entities: Vec<_> = self
102 .store
103 .all_entities()
104 .filter(|e| e.mem == mem && !e.stub && e.entity_type == *type_name)
105 .collect();
106 let mut closed: Vec<String> = Vec::new();
107 struct OpenRow {
108 id: String,
109 value: String,
110 total: usize,
111 unchecked: Vec<crate::ops::health::UncheckedRelated>,
112 }
113 let mut open: Vec<OpenRow> = Vec::new();
114 for e in &entities {
115 let value = match e.metadata.get(field.as_str()) {
116 Some(MetadataValue::String(s)) => s.clone(),
117 Some(v) => v.to_frontmatter_string(),
118 None => String::new(),
119 };
120 if value == *to_value {
121 closed.push(e.id.0.clone());
122 } else {
123 let (total, unchecked) = crate::ops::health::transition_gate_standing(
124 &self.store,
125 e,
126 relationships,
127 *direction,
128 None,
129 Some(&checks),
130 );
131 open.push(OpenRow {
132 id: e.id.0.clone(),
133 value,
134 total,
135 unchecked,
136 });
137 }
138 }
139 closed.sort();
140
141 // Dependency order over acyclic edges between gated
142 // entities: an edge source depends on its target, so
143 // targets (prerequisites) list first.
144 let acyclic: Vec<&str> = schema
145 .manifest
146 .relationships
147 .definitions
148 .iter()
149 .filter(|r| r.acyclic)
150 .map(|r| r.name.as_str())
151 .collect();
152 let open_ids: Vec<String> = open.iter().map(|r| r.id.clone()).collect();
153 let mut prereqs: HashMap<String, Vec<String>> = HashMap::new();
154 for e in &entities {
155 if !open_ids.contains(&e.id.0) {
156 continue;
157 }
158 for rel in &e.relationships {
159 if acyclic.contains(&rel.rel_type.as_str())
160 && open_ids.contains(&rel.target.0)
161 && rel.target.0 != e.id.0
162 {
163 prereqs
164 .entry(e.id.0.clone())
165 .or_default()
166 .push(rel.target.0.clone());
167 }
168 }
169 }
170 let mut ordered: Vec<&OpenRow> = Vec::new();
171 let mut placed: Vec<&str> = Vec::new();
172 let mut remaining: Vec<&OpenRow> = open.iter().collect();
173 remaining.sort_by(|a, b| a.id.cmp(&b.id));
174 while !remaining.is_empty() {
175 let idx = remaining.iter().position(|r| {
176 prereqs
177 .get(&r.id)
178 .is_none_or(|p| p.iter().all(|d| placed.contains(&d.as_str())))
179 });
180 // A cycle among open entities cannot arise (the
181 // edges are engine-enforced acyclic); the fallback
182 // keeps the loop total anyway.
183 let idx = idx.unwrap_or(0);
184 let row = remaining.remove(idx);
185 placed.push(row.id.as_str());
186 ordered.push(row);
187 }
188
189 lines.push(format!(
190 "Closed (at `{to_value}`): {}",
191 if closed.is_empty() {
192 "none".to_string()
193 } else {
194 closed
195 .iter()
196 .map(|id| format!("`{id}`"))
197 .collect::<Vec<_>>()
198 .join(", ")
199 }
200 ));
201 lines.push(String::new());
202 if ordered.is_empty() {
203 lines.push("Open: none — every gated entity stands closed.".to_string());
204 } else {
205 lines.push("Open, in dependency order (prerequisites first):".to_string());
206 for row in &ordered {
207 let coverage = if row.total == 0 {
208 "no related entities — the gate is vacuously satisfiable".to_string()
209 } else if row.unchecked.is_empty() {
210 format!(
211 "{}/{} related confirmed — gate satisfiable",
212 row.total, row.total
213 )
214 } else {
215 format!(
216 "{}/{} related confirmed — unconfirmed: {}",
217 row.total - row.unchecked.len(),
218 row.total,
219 row.unchecked
220 .iter()
221 .map(|u| format!("`{}` ({})", u.id, u.state))
222 .collect::<Vec<_>>()
223 .join(", ")
224 )
225 };
226 lines.push(format!(
227 "- `{}` — {field}: {} — {coverage}",
228 row.id,
229 if row.value.is_empty() {
230 "(unset)"
231 } else {
232 row.value.as_str()
233 },
234 ));
235 }
236 }
237 lines.push(String::new());
238 }
239 sections.push(lines.join("\n"));
240 }
241
242 let mut out = String::from("# Gates brief\n\n");
243 if declaring_mems.is_empty() {
244 out.push_str(match mem_filter {
245 Some(f) => {
246 sections.push(format!(
247 "No `transition_requires_checks` gate declared by the schema of `{f}` \
248 (or the mem is not mounted)."
249 ));
250 ""
251 }
252 None => "No mounted mem's schema declares a `transition_requires_checks` gate.",
253 });
254 }
255 out.push_str(§ions.join("\n"));
256 if !out.ends_with('\n') {
257 out.push('\n');
258 }
259 out
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 /// A workspace with no gate-declaring schema renders the honest
266 /// empty brief — never an empty string, never an invented section.
267 /// The substantive standing logic is covered by
268 /// `ops::health::tests::transition_requires_checks_gates_on_derived_state`
269 /// (shared enumeration), and the rendered shape by the live
270 /// dogfood workspace.
271 #[test]
272 fn brief_names_the_no_gates_case() {
273 let tmp = tempfile::TempDir::new().unwrap();
274 let dir = tmp.path().join("plain");
275 std::fs::create_dir_all(&dir).unwrap();
276 let engine = crate::Engine::from_mounts(vec![(
277 crate::engine::test_helpers::folder_mount("plain", dir.clone()),
278 Box::new(crate::storage::FilesystemMemWriter::new(dir))
279 as Box<dyn crate::backend::MemBackend>,
280 )])
281 .unwrap();
282 let brief = engine.render_gates_brief(None);
283 assert!(
284 brief.contains("No mounted mem's schema declares"),
285 "{brief}"
286 );
287 let filtered = engine.render_gates_brief(Some("plain"));
288 assert!(
289 filtered.contains("No `transition_requires_checks` gate declared"),
290 "{filtered}"
291 );
292 }
293}