Skip to main content

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(&sections.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}