Skip to main content

kyyn_core/
plugin.rs

1//! The `SourcePlugin` contract — what a source is, to the engine.
2//!
3//! A plugin's only job is to land raw material in the run directory the
4//! engine hands it; the curation agent is the universal adapter, so the
5//! engine never parses source payloads. Tap plugins speak these shapes over
6//! stdio RON; [`tap_main`] adapts the wire protocol to the trait.
7
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12/// What a plugin declares about itself.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Describe {
15    /// Plugin name (`local-recordings`, `graph`).
16    pub name: String,
17    /// The link namespace this source's citations carry (`recording`,
18    /// `graph`). Recorded into `sources.ron` at install; collision-checked.
19    pub link_namespace: String,
20    pub fetch_style: FetchStyle,
21    /// FALLBACK credentials realm; prefer `config_auth_realm` (derived from
22    /// validated configuration — tenant/client — so unrelated tenants never
23    /// share a token). `None` = no auth (local sources).
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub auth_realm: Option<String>,
26    /// Plugin protocol version (ADR 0001). v1 = typed items + engine-owned
27    /// checkpoints — the first externally observable wire contract; earlier
28    /// in-process trait shapes are implementation history, not versions.
29    /// The engine refuses to fetch from a plugin declaring any version other
30    /// than [`PROTOCOL`], and the field is REQUIRED on the wire: no plugin
31    /// protocol has ever shipped besides the current one, so a missing
32    /// declaration is a malformed plugin, not an old one — there is no
33    /// legacy to default to.
34    pub protocol: u32,
35}
36
37/// The plugin contract version this engine speaks (ADR 0001).
38pub const PROTOCOL: u32 = 1;
39
40/// How runs of this source are bounded.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum FetchStyle {
43    /// Half-open time windows, engine-computed (`[last end − lookback, now)`).
44    Windowed,
45    /// Current state of something (a tracked file); dedup by version/etag.
46    Snapshot,
47    /// Ingest whatever completed material is waiting (a local spool).
48    Sweep,
49}
50
51/// An interactive sign-in challenge (device-code style): show the code,
52/// send the human to the URL, poll until done. The `handle` is an opaque
53/// continuation for `auth_poll` — the engine holds it server-side.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct AuthChallenge {
56    pub verification_url: String,
57    pub user_code: String,
58    pub expires_in_secs: u64,
59    pub handle: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub enum AuthPollResult {
64    Pending,
65    /// Signed in — the string names the identity where known.
66    Done(String),
67    Failed(String),
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub enum AuthStatus {
72    NotRequired,
73    /// Signed in — the string names the identity ("tom@…").
74    Authenticated(String),
75    /// Not signed in — the string says how to fix it.
76    NotAuthenticated(String),
77}
78
79/// What the engine hands a plugin for AUTH operations. Paths are
80/// engine-owned: plugins never invent storage locations.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Context {
83    /// The instance's config from `sources.ron`, as canonical RON text.
84    pub config: String,
85    /// Credential storage for this plugin's auth realm.
86    pub secrets_dir: PathBuf,
87}
88
89/// One fetch invocation (ADR 0001). The checkpoint is a READ-ONLY snapshot
90/// of the last successfully PUBLISHED run's successor — plugins carry no
91/// writable durable state, so no crash before publication can suppress a
92/// future fetch.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FetchRequest {
95    pub config: String,
96    pub secrets_dir: PathBuf,
97    /// This run's staging directory in the ledger (operation-private).
98    pub out_dir: PathBuf,
99    pub spec: RunSpec,
100    /// Opaque plugin-defined text (RON recommended); engine-persisted.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub checkpoint: Option<String>,
103}
104
105/// A fetch's result. The engine publishes the manifest atomically and only
106/// then treats `next_checkpoint` as the authoritative cursor.
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct FetchResult {
109    pub items: Vec<Item>,
110    #[serde(default, skip_serializing_if = "String::is_empty")]
111    pub notes: String,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub next_checkpoint: Option<String>,
114}
115
116/// What the engine asks a fetch to do (matches the plugin's declared style).
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub enum RunSpec {
119    /// Half-open `[from, to)`, RFC3339 instants.
120    Window {
121        from: String,
122        to: String,
123    },
124    Snapshot,
125    Sweep,
126}
127
128/// One PROVIDER item — a message, an event, a chat message, a recording
129/// session, a file version. Identity is `source instance + kind + id`
130/// (ADR 0001); run ids, filenames and bundle names are never identity.
131/// Serde defaults keep legacy aggregate manifests parseable.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Item {
134    /// Stable provider id — engine-owned cross-run dedup keys on this.
135    pub id: String,
136    /// Provider kind: `email`, `event`, `chat-message`, `transcript`,
137    /// `recording`, `file`… Empty = legacy aggregate-manifest item.
138    #[serde(default, skip_serializing_if = "String::is_empty")]
139    pub kind: String,
140    /// Provider version (an eTag) when the provider has one.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub version: Option<String>,
143    /// sha256 (hex) of the item's canonical PRIMARY content — the locator-
144    /// selected record's compact JSON when a locator names one in a JSON
145    /// bundle, the first file's bytes otherwise. The version of last resort
146    /// and the primary evidence-integrity anchor. Empty = legacy.
147    #[serde(default, skip_serializing_if = "String::is_empty")]
148    pub content_hash: String,
149    /// Files written, relative to the run dir.
150    pub files: Vec<String>,
151    /// sha256 (hex) of each SECONDARY file — every entry of `files` beyond
152    /// the first — keyed by its run-relative path (ADR 0006). Extends the
153    /// primary anchor's integrity discipline to transcripts, attendance
154    /// reports, attachments: the engine refuses a fetch whose digests don't
155    /// match the bytes, and they join the item-version fingerprint (see
156    /// [`Item::version_fingerprint`]). REQUIRED for multi-file items; empty
157    /// on single-file items and on manifests that predate per-file hashes,
158    /// whose secondary files read as unverified until re-fetched.
159    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
160    pub file_hashes: std::collections::BTreeMap<String, String>,
161    /// Where the item lives WITHIN a bundle file (e.g. the record's
162    /// provider id in a JSON array) — bundles are storage, not identity.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub locator: Option<String>,
165    /// Item metadata (a recording's start instant, a subject line…).
166    #[serde(default, skip_serializing_if = "String::is_empty")]
167    pub meta: String,
168}
169
170impl Item {
171    /// Construct the required core of a provider item. Optional wire fields
172    /// are deliberately initialised here so adding another optional field does
173    /// not force every honest plugin and test fixture to patch a struct
174    /// literal. Publication still validates every invariant independently.
175    pub fn new(
176        kind: impl Into<String>,
177        id: impl Into<String>,
178        content_hash: impl Into<String>,
179        files: impl IntoIterator<Item = impl Into<String>>,
180    ) -> Item {
181        Item {
182            id: id.into(),
183            kind: kind.into(),
184            version: None,
185            content_hash: content_hash.into(),
186            files: files.into_iter().map(Into::into).collect(),
187            file_hashes: std::collections::BTreeMap::new(),
188            locator: None,
189            meta: String::new(),
190        }
191    }
192
193    pub fn with_version(mut self, version: impl Into<String>) -> Item {
194        self.version = Some(version.into());
195        self
196    }
197
198    pub fn with_locator(mut self, locator: impl Into<String>) -> Item {
199        self.locator = Some(locator.into());
200        self
201    }
202
203    pub fn with_meta(mut self, meta: impl Into<String>) -> Item {
204        self.meta = meta.into();
205        self
206    }
207
208    /// Add the verified digest for one secondary file. Fetch validation still
209    /// refuses paths that are not present after the primary file in `files`.
210    pub fn with_file_hash(mut self, path: impl Into<String>, digest: impl Into<String>) -> Item {
211        self.file_hashes.insert(path.into(), digest.into());
212        self
213    }
214
215    /// The item-VERSION fingerprint — what "same version" means for cross-
216    /// run dedup and evaluation keys. An aggregate of the primary anchor
217    /// (`content_hash`, a LOGICAL record digest for bundle items — never the
218    /// physical bundle file, which would bump every sibling record when one
219    /// changes) and the secondary-file digests in canonical (sorted) path
220    /// order, so a plugin reordering its files fabricates nothing. A changed
221    /// transcript is therefore a NEW version (new curation work) even while
222    /// the primary record stands. With no secondary digests the fingerprint
223    /// IS the bare `content_hash`, keeping single-file items' versions and
224    /// evaluation keys exactly as they were before per-file hashes.
225    pub fn version_fingerprint(&self) -> String {
226        version_fingerprint(&self.content_hash, &self.file_hashes)
227    }
228}
229
230/// See [`Item::version_fingerprint`] — shared with engine projections that
231/// store the components rather than the item.
232pub fn version_fingerprint(
233    content_hash: &str,
234    file_hashes: &std::collections::BTreeMap<String, String>,
235) -> String {
236    if file_hashes.is_empty() {
237        return content_hash.to_string();
238    }
239    use sha2::Digest;
240    let mut h = sha2::Sha256::new();
241    h.update(content_hash.as_bytes());
242    for (path, digest) in file_hashes {
243        h.update([0u8]);
244        h.update(path.as_bytes());
245        h.update([0u8]);
246        h.update(digest.as_bytes());
247    }
248    format!("{:x}", h.finalize())
249}
250
251/// The contract implemented by tap plugins (ADR 0001 / ADR 0005); the stdio
252/// harness wraps these calls in [`PluginRequest`] / [`PluginResponse`].
253pub trait SourcePlugin {
254    fn describe(&self) -> Describe;
255    /// Pure, offline config validation — run at PROPOSAL validation so a
256    /// malformed sources.ron cannot be accepted (SOL finding 35).
257    fn validate_config(&self, config: &str) -> Result<(), String>;
258    /// The auth realm this CONFIGURATION requires (tenant/client-derived —
259    /// SOL finding 33). `None` falls back to `Describe::auth_realm`.
260    fn config_auth_realm(&self, _config: &str) -> Result<Option<String>, String> {
261        Ok(None)
262    }
263    fn auth_status(&self, ctx: &Context) -> Result<AuthStatus, String>;
264    fn authenticate(&self, ctx: &Context) -> Result<(), String>;
265    /// Begin an interactive sign-in (device-code style). Default: not
266    /// supported — surfaces degrade to "run the CLI" with a live status
267    /// poll, so plugins without an interactive flow still work in the web.
268    fn auth_begin(&self, _ctx: &Context) -> Result<AuthChallenge, String> {
269        Err("interactive sign-in is not supported for this source — use the CLI".into())
270    }
271    /// One poll of a pending interactive sign-in.
272    fn auth_poll(&self, _ctx: &Context, _handle: &str) -> Result<AuthPollResult, String> {
273        Err("interactive sign-in is not supported for this source".into())
274    }
275    fn fetch(&self, req: &FetchRequest) -> Result<FetchResult, String>;
276}
277
278// --- the tap harness wire (ADR 0005) ----------------------------------------
279
280/// One request to a tap binary: `<binary> --plugin <name>` with this as RON
281/// on stdin, one [`PluginResponse`] as RON on stdout. Mirrors the schema
282/// protocol: a fresh process per call, no session state — checkpoints and
283/// ledgers are engine-owned, so plugins have nothing to keep alive.
284#[derive(Debug, Serialize, Deserialize)]
285pub enum PluginRequest {
286    Describe,
287    ValidateConfig { config: String },
288    ConfigAuthRealm { config: String },
289    AuthStatus { ctx: Context },
290    Authenticate { ctx: Context },
291    AuthBegin { ctx: Context },
292    AuthPoll { ctx: Context, handle: String },
293    Fetch { req: FetchRequest },
294}
295
296#[derive(Debug, Serialize, Deserialize)]
297pub enum PluginResponse {
298    Describe(Describe),
299    Ok,
300    Realm(Option<String>),
301    Status(AuthStatus),
302    Challenge(AuthChallenge),
303    Poll(AuthPollResult),
304    Fetched(FetchResult),
305    /// Any verb's failure — the message the trait method returned.
306    Error(String),
307}
308
309/// The whole main() of a tap binary: parse `--plugin <name>`, resolve it
310/// through the tap's own table, serve one request. Progress goes to stderr
311/// (the harness forwards it to the host's progress sink).
312pub fn tap_main(select: impl Fn(&str) -> Option<Box<dyn SourcePlugin>>) {
313    use std::io::Read;
314    let respond = |r: &PluginResponse| match crate::ronfmt::to_ron(r) {
315        Ok(text) => print!("{text}"),
316        Err(e) => {
317            eprintln!("serializing response: {e}");
318            std::process::exit(2);
319        }
320    };
321    let args: Vec<String> = std::env::args().collect();
322    let name = match args.iter().position(|a| a == "--plugin") {
323        Some(i) if i + 1 < args.len() => args[i + 1].clone(),
324        _ => {
325            respond(&PluginResponse::Error("usage: --plugin <name>".into()));
326            std::process::exit(2);
327        }
328    };
329    let Some(plugin) = select(&name) else {
330        respond(&PluginResponse::Error(format!(
331            "this tap does not serve a plugin named '{name}'"
332        )));
333        std::process::exit(2);
334    };
335    let mut input = String::new();
336    if let Err(e) = std::io::stdin().read_to_string(&mut input) {
337        respond(&PluginResponse::Error(format!("reading stdin: {e}")));
338        std::process::exit(2);
339    }
340    let request = match ron::from_str::<PluginRequest>(&input) {
341        Ok(r) => r,
342        Err(e) => {
343            respond(&PluginResponse::Error(format!("parsing request: {e}")));
344            std::process::exit(2);
345        }
346    };
347    let response = match request {
348        PluginRequest::Describe => PluginResponse::Describe(plugin.describe()),
349        PluginRequest::ValidateConfig { config } => match plugin.validate_config(&config) {
350            Ok(()) => PluginResponse::Ok,
351            Err(e) => PluginResponse::Error(e),
352        },
353        PluginRequest::ConfigAuthRealm { config } => match plugin.config_auth_realm(&config) {
354            Ok(r) => PluginResponse::Realm(r),
355            Err(e) => PluginResponse::Error(e),
356        },
357        PluginRequest::AuthStatus { ctx } => match plugin.auth_status(&ctx) {
358            Ok(s) => PluginResponse::Status(s),
359            Err(e) => PluginResponse::Error(e),
360        },
361        PluginRequest::Authenticate { ctx } => match plugin.authenticate(&ctx) {
362            Ok(()) => PluginResponse::Ok,
363            Err(e) => PluginResponse::Error(e),
364        },
365        PluginRequest::AuthBegin { ctx } => match plugin.auth_begin(&ctx) {
366            Ok(c) => PluginResponse::Challenge(c),
367            Err(e) => PluginResponse::Error(e),
368        },
369        PluginRequest::AuthPoll { ctx, handle } => match plugin.auth_poll(&ctx, &handle) {
370            Ok(p) => PluginResponse::Poll(p),
371            Err(e) => PluginResponse::Error(e),
372        },
373        PluginRequest::Fetch { req } => match plugin.fetch(&req) {
374            Ok(r) => PluginResponse::Fetched(r),
375            Err(e) => PluginResponse::Error(e),
376        },
377    };
378    respond(&response);
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn item_builder_keeps_required_and_optional_wire_fields_explicit() {
387        let item = Item::new("meeting", "m1", "primary", ["record.json", "notes.vtt"])
388            .with_version("etag-1")
389            .with_locator("m1")
390            .with_meta("Planning")
391            .with_file_hash("notes.vtt", "secondary");
392
393        assert_eq!(item.kind, "meeting");
394        assert_eq!(item.id, "m1");
395        assert_eq!(item.content_hash, "primary");
396        assert_eq!(item.files, ["record.json", "notes.vtt"]);
397        assert_eq!(item.version.as_deref(), Some("etag-1"));
398        assert_eq!(item.locator.as_deref(), Some("m1"));
399        assert_eq!(item.meta, "Planning");
400        assert_eq!(item.file_hashes["notes.vtt"], "secondary");
401    }
402}