Skip to main content

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}