engate_types/lib.rs
1//! engate-types — typed phase markers + traits for the engate attach
2//! primitive. The actual attach machinery lives in `engate-attach`;
3//! this crate is the small dependency-free contract so consumers can
4//! depend on shapes without pulling in `statig` / `typed-builder`.
5//!
6//! # Why
7//!
8//! The bug class engate kills: a producer (PTY, WS, MQ, network)
9//! emits data BEFORE a consumer subscribes; subscribe returns only
10//! NEW data; the consumer's local model stays empty even though the
11//! producer's state is full. Today's hand-wired attach paths in the
12//! pleme-io fleet (mado↔tear, kenshi↔testpod, hiroba clients,
13//! ayatsuri↔mado, namimado↔CDP) all suffer this class.
14//!
15//! The fix: model attach as a 4-state typestate
16//! (`Spawned → Subscribed → Synced → Live`) where the consumer can
17//! only render from a `Live` value, `Live` can only be reached via
18//! `Synced.start_live()`, `Synced` can only be reached via
19//! `Subscribed.replay(history)`, and `history` is a `#[must_use]`
20//! linear-ish handle that cannot be dropped without being consumed.
21//! Reaching the render path WITHOUT history is unrepresentable in
22//! the type system.
23//!
24//! See `pleme-io/CLAUDE.md` task tracker (#123-#129) for the full
25//! M0-M6 roadmap.
26
27use serde::{Deserialize, Serialize};
28
29// ── Phase typestate markers ──────────────────────────────────────────
30
31/// Sealed marker trait for the four attach phases. Implementations
32/// live in this crate only — downstream code cannot add new phases.
33mod sealed {
34 pub trait Sealed {}
35}
36
37/// Phase of an attach lifecycle. Marker only — carries no data.
38pub trait Phase: sealed::Sealed + Send + Sync + 'static {
39 /// Human-readable phase name, used in tracing + debug output.
40 fn name() -> &'static str;
41}
42
43/// Initial phase: producer has been identified (e.g. a pane id, a
44/// channel name) but no connection has been opened yet.
45pub struct Spawned;
46impl sealed::Sealed for Spawned {}
47impl Phase for Spawned {
48 fn name() -> &'static str {
49 "Spawned"
50 }
51}
52
53/// The consumer has registered with the producer's live emission
54/// stream. No history has been replayed yet — calling render here
55/// would show an empty model even if the producer's state is full.
56pub struct Subscribed;
57impl sealed::Sealed for Subscribed {}
58impl Phase for Subscribed {
59 fn name() -> &'static str {
60 "Subscribed"
61 }
62}
63
64/// Historical snapshot has been replayed into the consumer. The
65/// consumer's local model now matches the producer's state as of
66/// the snapshot. Live stream may have items queued but not yet
67/// forwarded.
68pub struct Synced;
69impl sealed::Sealed for Synced {}
70impl Phase for Synced {
71 fn name() -> &'static str {
72 "Synced"
73 }
74}
75
76/// Live items from the producer are flowing into the consumer. This
77/// is the only phase from which `render()` is reachable. Construction
78/// gated on `Synced.start_live()` — reaching `Live` without going
79/// through `Synced` is impossible in the type system.
80pub struct Live;
81impl sealed::Sealed for Live {}
82impl Phase for Live {
83 fn name() -> &'static str {
84 "Live"
85 }
86}
87
88// ── Snapshot trait ──────────────────────────────────────────────────
89
90/// Producer-side snapshot of current state. Whatever the consumer
91/// needs to bootstrap its local model to match the producer at attach
92/// time. For tear: a `PaneSnapshot` (grid + cursor + flags). For a
93/// WebSocket: the last N messages. For a Kubernetes log stream: the
94/// tail of stdout up to attach time.
95///
96/// Implementors keep the trait minimal so engate stays generic;
97/// transport-specific serialization is the implementor's concern.
98pub trait Snapshot: Send + Sync + 'static {
99 /// Approximate byte size of the snapshot. Used for tracing /
100 /// metrics ("how much history did we replay on attach?").
101 fn size_bytes(&self) -> usize {
102 0
103 }
104}
105
106// Implement Snapshot for common shapes so simple cases are zero-LoC.
107impl Snapshot for Vec<u8> {
108 fn size_bytes(&self) -> usize {
109 self.len()
110 }
111}
112
113impl Snapshot for String {
114 fn size_bytes(&self) -> usize {
115 self.len()
116 }
117}
118
119// ── Errors ──────────────────────────────────────────────────────────
120
121/// Errors that can occur during an attach lifecycle.
122///
123/// Distinct from per-consumer errors (producer disconnect, malformed
124/// frame, etc.) which surface through the consumer's `consume` impl
125/// and are not engate's concern.
126#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
127pub enum AttachError {
128 /// The producer's snapshot operation failed. Engate cannot reach
129 /// `Synced` without a snapshot.
130 #[error("snapshot failed: {0}")]
131 SnapshotFailed(String),
132
133 /// Subscribing to the live stream failed.
134 #[error("subscribe failed: {0}")]
135 SubscribeFailed(String),
136
137 /// The producer reports the requested entity does not exist.
138 #[error("no such entity: {0}")]
139 NoSuchEntity(String),
140
141 /// Generic transport / I/O error during attach.
142 #[error("transport: {0}")]
143 Transport(String),
144}
145
146// ── EngateSpec — author surface ─────────────────────────────────────
147
148/// Declarative spec for one engate attach point. Authored either by
149/// hand in Rust (`EngateSpec { ... }`) or via tatara-lisp's
150/// `(defengate ...)` form (M5.1 — `#[derive(TataraDomain)]` lands
151/// when tatara-lisp's macro story is mature enough to consume this
152/// without a manual `register()` boilerplate dance).
153///
154/// The spec is purely descriptive — it does NOT instantiate the
155/// engate machinery (that's compile-time generics in `engate-attach`).
156/// It serves as:
157///
158/// 1. A registry every operator-facing tool can enumerate (mado
159/// publishes its engate specs, kenshi publishes its, etc.).
160/// 2. The contract substrate's `mkEngateFlake` Nix builder reads to
161/// generate per-consumer Nix outputs (module trio, attestation
162/// fixture verifier, test invocations).
163/// 3. The shape the `(defengate ...)` Lisp form serializes to.
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165pub struct EngateSpec {
166 /// Stable identifier for this engate point — used as the JobId
167 /// subject when wrapped by engate-shigoto.
168 pub name: String,
169
170 /// Producer crate + path. e.g. `("tear-client", "tear_client::PaneProducer")`.
171 pub producer: TypePath,
172
173 /// Consumer crate + path. e.g. `("mado", "mado::terminal::Terminal")`.
174 pub consumer: TypePath,
175
176 /// Whether history replay is required. `true` (the default) =
177 /// the typestate enforces snapshot-before-render; `false` = the
178 /// consumer opts out (rare — only for genuinely stateless
179 /// producers like a beep channel).
180 #[serde(default = "default_true_bool")]
181 pub history_required: bool,
182
183 /// Optional attestation fixture path. When present, substrate
184 /// emits a CI job that runs the engate and asserts the recorded
185 /// fixture matches.
186 #[serde(default)]
187 pub attestation_fixture: Option<String>,
188}
189
190fn default_true_bool() -> bool {
191 true
192}
193
194/// A crate + dotted type path. Two strings instead of one because
195/// the Nix side needs the crate name for the closure graph and the
196/// type path for the Rust import.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct TypePath {
199 pub crate_name: String,
200 pub path: String,
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn phase_names_round_trip() {
209 assert_eq!(Spawned::name(), "Spawned");
210 assert_eq!(Subscribed::name(), "Subscribed");
211 assert_eq!(Synced::name(), "Synced");
212 assert_eq!(Live::name(), "Live");
213 }
214
215 #[test]
216 fn vec_u8_snapshot_size() {
217 let v: Vec<u8> = vec![1, 2, 3, 4, 5];
218 assert_eq!(<Vec<u8> as Snapshot>::size_bytes(&v), 5);
219 }
220
221 #[test]
222 fn attach_error_display() {
223 let e = AttachError::SnapshotFailed("disk full".into());
224 assert_eq!(e.to_string(), "snapshot failed: disk full");
225 }
226
227 #[test]
228 fn engate_spec_round_trips_through_serde() {
229 let s = EngateSpec {
230 name: "mado-tear-pane".into(),
231 producer: TypePath {
232 crate_name: "tear-client".into(),
233 path: "tear_client::PaneProducer".into(),
234 },
235 consumer: TypePath {
236 crate_name: "mado".into(),
237 path: "mado::terminal::Terminal".into(),
238 },
239 history_required: true,
240 attestation_fixture: Some("fixtures/mado-tear.engate.json".into()),
241 };
242 let yaml = serde_json::to_string(&s).unwrap();
243 let back: EngateSpec = serde_json::from_str(&yaml).unwrap();
244 assert_eq!(s, back);
245 }
246
247 // ── Expanded coverage ─────────────────────────────────────────
248
249 #[test]
250 fn engate_spec_history_required_defaults_to_true() {
251 // The field has #[serde(default = "default_true_bool")] so an
252 // input missing the key should deserialize to true. Operators
253 // who omit the field get safe-by-default semantics.
254 let json = r#"{
255 "name": "x",
256 "producer": { "crate_name": "p-crate", "path": "p::Type" },
257 "consumer": { "crate_name": "c-crate", "path": "c::Type" }
258 }"#;
259 let s: EngateSpec = serde_json::from_str(json).unwrap();
260 assert!(s.history_required);
261 assert!(s.attestation_fixture.is_none());
262 }
263
264 #[test]
265 fn engate_spec_clone_equals_original() {
266 let s = EngateSpec {
267 name: "n".into(),
268 producer: TypePath { crate_name: "p".into(), path: "p::T".into() },
269 consumer: TypePath { crate_name: "c".into(), path: "c::T".into() },
270 history_required: false,
271 attestation_fixture: None,
272 };
273 assert_eq!(s, s.clone());
274 }
275
276 #[test]
277 fn type_path_equality_per_field() {
278 let a = TypePath { crate_name: "x".into(), path: "x::Y".into() };
279 let b = TypePath { crate_name: "x".into(), path: "x::Y".into() };
280 let c = TypePath { crate_name: "x".into(), path: "x::Z".into() };
281 let d = TypePath { crate_name: "z".into(), path: "x::Y".into() };
282 assert_eq!(a, b);
283 assert_ne!(a, c);
284 assert_ne!(a, d);
285 }
286
287 #[test]
288 fn all_attach_error_variants_constructible_and_displayable() {
289 let errs = [
290 AttachError::SnapshotFailed("disk full".into()),
291 AttachError::SubscribeFailed("permission".into()),
292 AttachError::NoSuchEntity("pane-x".into()),
293 AttachError::Transport("connection reset".into()),
294 ];
295 for e in errs {
296 let s = e.to_string();
297 assert!(!s.is_empty(), "Display non-empty for {e:?}");
298 }
299 }
300
301 #[test]
302 fn attach_error_round_trips_through_serde() {
303 // AttachError derives Serialize + Deserialize so it can travel
304 // across IPC boundaries (e.g. shigoto's audit chain).
305 let e = AttachError::Transport("connection reset by peer".into());
306 let json = serde_json::to_string(&e).unwrap();
307 let back: AttachError = serde_json::from_str(&json).unwrap();
308 assert_eq!(e.to_string(), back.to_string());
309 }
310
311 #[test]
312 fn snapshot_impl_for_string() {
313 let s: String = "hello".into();
314 assert_eq!(<String as Snapshot>::size_bytes(&s), 5);
315 }
316
317 #[test]
318 fn snapshot_default_size_bytes_is_zero() {
319 // Custom impls that don't override size_bytes() get 0 — the
320 // default. Useful for typed-marker snapshots that carry no
321 // payload (just a "subscribed at" timestamp etc.).
322 struct EmptyMarker;
323 impl Snapshot for EmptyMarker {}
324 assert_eq!(<EmptyMarker as Snapshot>::size_bytes(&EmptyMarker), 0);
325 }
326}