memstead_base/pipeline.rs
1//! Pipeline primitives — **Medium · Facet · Projection**.
2//!
3//! The declarative shape the workspace store persists and the pipeline loader
4//! exposes. The obligation itself (source→mem, with its `operations` block) is
5//! the versioned [`crate::binding::BindingV1`] record; a binding occupies the
6//! projections tier and carries the schedule the retired `Ingest` primitive
7//! once held. `Medium` / `Facet` / `Projection` are the joinable declarative
8//! pieces a binding (or the legacy loader) references.
9//!
10//! - [`Medium`] — *territory*: a passive, named, typed reference to a body of
11//! information (no selection logic, no engagement metadata, no preparation).
12//! - [`Facet`] — *engagement*: how a projection reads/writes a medium —
13//! a selection (allow/deny patterns), an engagement contract, and an
14//! optional deterministic preparation step.
15//! - [`Projection`] — *obligation*: maps source facets (+ optional reference
16//! mems) to a destination mem. The one place agent reasoning lives.
17//!
18//! These are operator-edited configs. The loader's job is load + validate +
19//! expose read-only; nothing here fetches, transforms, or schedules.
20//!
21//! `Ingest` / `IngestMode` (the flat schedule record, including the deleted
22//! `refinement` mode) are gone — collapsed into the binding's `operations`
23//! block. The legacy four-primitive store is parsed by the migrate-local
24//! [`crate::pipeline_store::LegacyIngest`] shape, not this module.
25
26use serde::{Deserialize, Serialize};
27
28/// What kind of surface a [`Medium`] references. The string forms match the
29/// `type` field of the legacy `scopes/<mem>/<name>.json` records, so
30/// the migration shim maps them without translation. `pdf` (and other
31/// non-text mediums) join this enum with their follow-up plans.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum MediumType {
35 /// A source tree of code.
36 Codebase,
37 /// A directory of files (non-code).
38 Filesystem,
39 /// Another mem's graph (reachable as the reserved id `graph` for "home").
40 Graph,
41 /// A git history.
42 Git,
43 /// Web sources.
44 Web,
45}
46
47/// A **Medium** — a passive, named, typed reference to a body of information
48/// the mem acknowledges as part of its territory. Nothing more: no
49/// selection, no engagement metadata, no preparation step.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Medium {
52 /// Stable name — facets and projections reference a medium by this.
53 pub name: String,
54 /// What kind of surface this is.
55 #[serde(rename = "type")]
56 pub medium_type: MediumType,
57 /// Where the body of information lives — a path, URL, or mem id,
58 /// interpreted per [`Self::medium_type`]. Opaque to this layer.
59 pub pointer: String,
60 /// An optional declared change-detection strategy for sources reading
61 /// this medium — `none` / `git` / `mtime` / `auto`. Unset (the common
62 /// case) means `auto`: the ingest resolver probes for a git work tree
63 /// over [`Self::pointer`] and picks `git` or `mtime`. A graph-typed
64 /// medium ignores this and always uses the graph snapshot signal.
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub change_detection: Option<String>,
67}
68
69/// Whether a [`PatternEntry`] admits or excludes the matched paths.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum PatternMode {
73 /// Paths matching this pattern are in reach.
74 Allow,
75 /// Paths matching this pattern are excluded.
76 Deny,
77}
78
79/// One allow/deny glob in a [`Facet`]'s selection over its medium. Mirrors the
80/// `{ path, mode }` entries of the legacy scope `tree`.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct PatternEntry {
83 /// Glob pattern, interpreted relative to the referenced medium's pointer.
84 pub path: String,
85 /// Whether the pattern admits or excludes.
86 pub mode: PatternMode,
87}
88
89/// A **Facet** — a named way a projection engages with a [`Medium`]: the
90/// subset in reach, the engagement contract, and an optional preparation step.
91///
92/// The facet record is deliberately heterogeneous (a *source* facet typically
93/// carries `scope` + `preparation`; a *destination* facet carries engagement
94/// discipline) — forcing a uniform shape would smuggle complexity elsewhere.
95/// The single-type-with-optional-fields modelling is chosen for machinery
96/// simplicity (concept-doc Open Question 2); the `engagement` contract stays a
97/// free-form JSON value because its shape is medium-type- and side-specific
98/// (verbs, tools, terminology, discipline) and is not load-bearing for the
99/// loader's structural validation.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct Facet {
102 /// Stable name — projections reference a facet by this.
103 pub name: String,
104 /// The [`Medium`] (by name) this facet is a perspective on. A facet
105 /// always references exactly one medium.
106 pub medium: String,
107 /// Allow/deny selection over the referenced medium. A facet with **no
108 /// allow patterns is *unscoped*** — a typed refusal at run time (no
109 /// strategy diffs or enumerates the whole medium; the brief reports it as
110 /// unmonitored), not "whole medium". A facet that truly wants everything
111 /// writes `**/*`.
112 #[serde(default)]
113 pub scope: Vec<PatternEntry>,
114 /// Engagement contract — verbs, tools, terminology, discipline. Free-form
115 /// because the shape differs by medium type and by source/destination
116 /// side; the engine does not interpret it.
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub engagement: Option<serde_json::Value>,
119 /// Optional deterministic preparation step (string identifier, e.g.
120 /// `pdf-to-markdown`). Unset for every text medium today. A facet that
121 /// names a preparation the engine has no implementation for is accepted at
122 /// rest but reported unsupported at run time — no silent skip, no crash.
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub preparation: Option<String>,
125}
126
127/// A **Projection** — the obligation that connects source facets (and optional
128/// read-only reference mems) to a single destination mem. The only place
129/// agent reasoning lives; it carries no scope, preparation, or medium metadata
130/// of its own (all of that lives in the facets it references).
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct Projection {
133 /// What the projection is trying to accomplish — prose for the agent.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub intent: Option<String>,
136 /// Source facets (by name) the projection consumes.
137 #[serde(default)]
138 pub source_facets: Vec<String>,
139 /// Read-only reference mems that supply cross-mem context.
140 #[serde(default)]
141 pub reference_mems: Vec<String>,
142 /// The mem this projection writes into.
143 pub destination_mem: String,
144 /// Free-form projection rules (e.g. a one-shot lens `routing` string).
145 /// Opaque to the engine — consumed only by the one-shot brief renderer.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub rules: Option<serde_json::Value>,
148}
149
150/// What sets a binding's operation running — the `trigger` of a
151/// [`crate::binding::BuildOperation`] / `SyncOperation` / `VerifyOperation`.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "kebab-case")]
154pub enum IngestTrigger {
155 /// Repeated runs (the ingest skill loops it).
156 Loop,
157 /// Operator-initiated.
158 Manual,
159 /// Fired by an external event.
160 OnEvent,
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 /// A medium round-trips and its `type` serialises to the lowercase form
168 /// the legacy scope JSON used.
169 #[test]
170 fn medium_round_trips_with_lowercase_type() {
171 let m = Medium {
172 name: "source-tree".to_string(),
173 medium_type: MediumType::Codebase,
174 pointer: "../macos".to_string(),
175 change_detection: None,
176 };
177 let json = serde_json::to_string(&m).unwrap();
178 assert!(
179 !json.contains("change_detection"),
180 "unset change_detection is omitted on the wire: {json}"
181 );
182 assert!(json.contains(r#""type":"codebase""#), "got {json}");
183 let back: Medium = serde_json::from_str(&json).unwrap();
184 assert_eq!(back, m);
185 }
186
187 /// A medium declaring a `change_detection` strategy round-trips with the
188 /// value present; the field is the optional slot the ingest resolver
189 /// reads to pick a source's change-detection strategy.
190 #[test]
191 fn medium_change_detection_round_trips_when_set() {
192 let m = Medium {
193 name: "manuals".to_string(),
194 medium_type: MediumType::Filesystem,
195 pointer: "../docs".to_string(),
196 change_detection: Some("mtime".to_string()),
197 };
198 let json = serde_json::to_string(&m).unwrap();
199 assert!(json.contains(r#""change_detection":"mtime""#), "got {json}");
200 let back: Medium = serde_json::from_str(&json).unwrap();
201 assert_eq!(back.change_detection.as_deref(), Some("mtime"));
202 assert_eq!(back, m);
203 }
204
205 /// A source facet with allow/deny scope and no preparation round-trips,
206 /// and the unset `preparation`/`engagement` keys are omitted on the wire.
207 #[test]
208 fn facet_round_trips_and_omits_unset_optional_fields() {
209 let f = Facet {
210 name: "source-files".to_string(),
211 medium: "source-tree".to_string(),
212 scope: vec![
213 PatternEntry {
214 path: "../macos/**/*.swift".to_string(),
215 mode: PatternMode::Allow,
216 },
217 PatternEntry {
218 path: "../macos/specs/**".to_string(),
219 mode: PatternMode::Deny,
220 },
221 ],
222 engagement: None,
223 preparation: None,
224 };
225 let json = serde_json::to_string(&f).unwrap();
226 assert!(
227 !json.contains("preparation"),
228 "unset preparation omitted: {json}"
229 );
230 assert!(
231 !json.contains("engagement"),
232 "unset engagement omitted: {json}"
233 );
234 assert!(json.contains(r#""mode":"deny""#), "got {json}");
235 let back: Facet = serde_json::from_str(&json).unwrap();
236 assert_eq!(back, f);
237 assert_eq!(back.preparation, None);
238 }
239
240 /// A facet declaring a preparation identifier round-trips with the value
241 /// present — the slot is reserved even though no implementation exists.
242 #[test]
243 fn facet_preparation_slot_round_trips_when_set() {
244 let f = Facet {
245 name: "manual-pages".to_string(),
246 medium: "manuals".to_string(),
247 scope: Vec::new(),
248 engagement: Some(serde_json::json!({ "readVerb": "Read PDF" })),
249 preparation: Some("pdf-to-markdown".to_string()),
250 };
251 let json = serde_json::to_string(&f).unwrap();
252 let back: Facet = serde_json::from_str(&json).unwrap();
253 assert_eq!(back.preparation.as_deref(), Some("pdf-to-markdown"));
254 assert_eq!(back, f);
255 }
256
257 /// A projection maps source facets + reference mems to one destination.
258 #[test]
259 fn projection_round_trips() {
260 let p = Projection {
261 intent: Some("Swift macOS app source.".to_string()),
262 source_facets: vec!["source-files".to_string()],
263 reference_mems: vec!["engine".to_string()],
264 destination_mem: "macos".to_string(),
265 rules: None,
266 };
267 let json = serde_json::to_string(&p).unwrap();
268 let back: Projection = serde_json::from_str(&json).unwrap();
269 assert_eq!(back, p);
270 assert_eq!(back.destination_mem, "macos");
271 }
272
273 /// `IngestTrigger`'s kebab-case variants serialise as the doc names
274 /// (`on-event`) — the wire forms a binding's operation `trigger` uses.
275 #[test]
276 fn ingest_trigger_uses_kebab_wire_forms() {
277 let on_event = serde_json::to_string(&IngestTrigger::OnEvent).unwrap();
278 assert_eq!(on_event, r#""on-event""#);
279 let loop_ = serde_json::to_string(&IngestTrigger::Loop).unwrap();
280 assert_eq!(loop_, r#""loop""#);
281 }
282}