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}