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