Skip to main content

scc_engine/
plugins.rs

1//! Engine plugins namespace: discovery, custom-operation dispatch, cache keys.
2//!
3//! Custom operations (spec 31): a plugin operation like `acme.echo` is
4//! callable through `invoke()`, RPC, HTTP, SDKs, and FFI with no per-transport
5//! code — the fallback arm in `invoke` routes unknown `acme.*` ids here.
6//! Provenance (spec 25): every plugin output is wrapped with its origin.
7//! Failure policy (spec 26): required = hard error, warn/optional = skip
8//! with a structured diagnostic. Cache keys (spec 27) include the plugin
9//! lock so upgrades invalidate ranking-dependent caches automatically.
10
11use std::path::Path;
12
13// trace:exempt reason=internal-detail
14pub struct ActivePlugins {
15    pub plugins: Vec<scc_plugin_host::LoadedPlugin>,
16    pub diagnostics: Vec<scc_plugin_host::PluginDiagnostic>,
17}
18
19// trace:exempt reason=internal-detail
20pub fn active(root: &Path, config: &scc_indexer::Config) -> ActivePlugins {
21    let mut ap = ActivePlugins { plugins: Vec::new(), diagnostics: Vec::new() };
22    let mut plugins = scc_plugin_host::discover(root);
23    // Project allow-list: only `plugins.enabled` run when non-empty.
24    if !config.plugins.enabled.is_empty() {
25        plugins.retain(|p| config.plugins.enabled.contains(&p.manifest.id));
26    }
27    // Per-plugin config from the project file.
28    for p in &mut plugins {
29        if let Some(c) = config.plugins.config.get(&p.manifest.id) {
30            p.config = c.clone();
31        }
32    }
33    // Grants: explicit project grants narrow manifest defaults. Unknown
34    // grant names are diagnostics (fail-loud: a typo must not silently
35    // narrow the grant set and misdirect the later denied error).
36    let mut grants: std::collections::BTreeMap<String, Vec<scc_plugin_api::Permission>> =
37        std::collections::BTreeMap::new();
38    for (k, v) in &config.plugins.grants {
39        let mut perms = Vec::new();
40        for s in v {
41            match scc_plugin_api::Permission::parse(s) {
42                Ok(p) => perms.push(p),
43                Err(e) => ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
44                    plugin: k.clone(), operation: "grants".into(),
45                    error: e, action: "skipped".into(),
46                }),
47            }
48        }
49        grants.insert(k.clone(), perms);
50    }
51    scc_plugin_host::apply_grants(&mut plugins, &grants);
52    ap.plugins = plugins;
53    ap
54}
55
56// trace:exempt reason=internal-detail
57pub fn lock_entries(ap: &ActivePlugins) -> Vec<serde_json::Value> {
58    ap.plugins.iter().map(scc_plugin_host::lock_entry).collect()
59}
60
61// trace:v1 id=impl.scc-engine-plugins.cache-key-fragment work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
62pub fn cache_key_fragment(ap: &ActivePlugins) -> String {
63    // Plugin-affecting state that must invalidate caches (spec 27).
64    let mut h = blake3::Hasher::new();
65    for e in lock_entries(ap) {
66        h.update(e.to_string().as_bytes());
67    }
68    format!("plugins:{}", &h.finalize().to_hex()[..16])
69}
70
71/// Dispatch a plugin-provided operation. Returns `(output, diagnostic)`:
72/// warn/optional failures yield the engine error + a diagnostic instead of
73/// failing the call — the host records what was skipped (spec 26).
74// trace:v1 id=impl.scc-engine-plugins.call-operation work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
75pub fn call_operation(ap: &mut ActivePlugins, operation: &str, input: serde_json::Value) -> crate::Result<serde_json::Value> {
76    let plugin = scc_plugin_host::provider_for(&ap.plugins, operation).map_err(|e| crate::EngineError::Other(e.to_string()))?;
77    let id = plugin.manifest.id.clone();
78    let policy = plugin.manifest.failure_policy.clone();
79    match scc_plugin_host::call(plugin, operation, input, None) {
80        Ok(output) => Ok(with_provenance(&id, &plugin.manifest.version, operation, output)),
81        Err(e) => {
82            let action = if policy == "required" { "failed" } else { "skipped" };
83            ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
84                plugin: id.clone(), operation: operation.into(), error: e.to_string(), action: action.into(),
85            });
86            if policy == "required" {
87                Err(crate::EngineError::Other(format!("plugin {id} {operation}: {e}")))
88            } else {
89                Ok(serde_json::json!({"skipped": true, "plugin": id, "error": e.to_string()}))
90            }
91        }
92    }
93}
94
95// trace:exempt reason=internal-detail
96fn with_provenance(plugin_id: &str, version: &str, operation: &str, mut output: serde_json::Value) -> serde_json::Value {
97    // Mandatory provenance (spec 25): every plugin output carries origin.
98    if let Some(obj) = output.as_object_mut() {
99        obj.insert("_origin".into(), serde_json::json!({
100            "kind": "plugin", "plugin_id": plugin_id,
101            "plugin_version": version, "extension": operation,
102        }));
103    }
104    output
105}
106
107/// Startup-section contributions (§124 item 29): every `startup-section`
108/// extension renders one markdown section into the startup pack.
109///
110/// Mirrors [`context_sections`]: input carries no goal (startup is
111/// goal-free); output `section` markdown is spliced verbatim under a
112/// provenance header (`PLUGIN STARTUP SECTION <id> from <plugin>`).
113/// Failures follow policy: required = hard error, else skip silently —
114/// startup has no warnings channel, so skips are recorded in OMISSIONS by
115/// the caller via the returned notes.
116// trace:v1 id=impl.scc-engine-plugins.startup-sections work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
117pub fn startup_sections(
118    root: &std::path::Path,
119    config: &scc_indexer::Config,
120) -> (String, Vec<String>) {
121    let ap = active(root, config);
122    let mut out = String::new();
123    let mut notes: Vec<String> = Vec::new();
124    let specs: Vec<(String, String, String)> = ap
125        .plugins
126        .iter()
127        .flat_map(|p| {
128            p.manifest.extensions.iter().filter(|e| e.extension_type == "startup-section").map(|e| {
129                (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
130            })
131        })
132        .collect();
133    for (pid, ext_id, policy) in specs {
134        let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
135            Some(p) => p,
136            None => continue,
137        };
138        let input = serde_json::json!({"section": ext_id});
139        match scc_plugin_host::call(&plug, "startup.section", input, None) {
140            Ok(v) => {
141                let text = v.get("section").and_then(|s| s.as_str()).unwrap_or("");
142                if !text.trim().is_empty() {
143                    out.push_str(&format!("\n# PLUGIN STARTUP SECTION {ext_id} (from {pid} — plugin content, not verified facts)\n"));
144                    out.push_str(text.trim());
145                    out.push('\n');
146                }
147            }
148            Err(e) => {
149                if policy == "required" {
150                    out.push_str(&format!("\nPLUGIN STARTUP SECTION {ext_id} from {pid} FAILED: {e}\n"));
151                } else {
152                    notes.push(format!("startup section {ext_id} from {pid} skipped ({e})"));
153                }
154            }
155        }
156    }
157    (out, notes)
158}
159
160/// Verify-diagnostic contributions (§124 item 30): every
161/// `verify-diagnostic:*` extension renders one findings section into the
162/// verify pack via the plugin's `verify.diagnostic` op (no input).
163/// Output `diagnostic` markdown is spliced verbatim under a provenance
164/// header. Failures follow policy: required = hard error text inline,
165/// else skipped with a diagnostic note.
166// trace:v1 id=impl.scc-engine-plugins.verify-diagnostics work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
167pub fn verify_diagnostics(
168    root: &std::path::Path,
169    config: &scc_indexer::Config,
170) -> (String, Vec<String>) {
171    let ap = active(root, config);
172    let mut out = String::new();
173    let mut notes: Vec<String> = Vec::new();
174    let specs: Vec<(String, String, String)> = ap
175        .plugins
176        .iter()
177        .flat_map(|p| {
178            p.manifest.extensions.iter().filter(|e| e.extension_type == "verify-diagnostic").map(|e| {
179                (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
180            })
181        })
182        .collect();
183    for (pid, ext_id, policy) in specs {
184        let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
185            Some(p) => p,
186            None => continue,
187        };
188        let input = serde_json::json!({"diagnostic": ext_id});
189        match scc_plugin_host::call(&plug, "verify.diagnostic", input, None) {
190            Ok(v) => {
191                let text = v.get("diagnostic").and_then(|s| s.as_str()).unwrap_or("");
192                if !text.trim().is_empty() {
193                    out.push_str(&format!("\n# PLUGIN VERIFY DIAGNOSTIC {ext_id} (from {pid} — plugin content, not verified facts)\n"));
194                    out.push_str(text.trim());
195                    out.push('\n');
196                }
197            }
198            Err(e) => {
199                if policy == "required" {
200                    out.push_str(&format!("\n# PLUGIN VERIFY DIAGNOSTIC {ext_id} from {pid} FAILED: {e}\n"));
201                } else {
202                    notes.push(format!("verify diagnostic {ext_id} from {pid} skipped ({e})"));
203                }
204            }
205        }
206    }
207    (out, notes)
208}
209
210/// Viewer-panel contributions (§124 item 32): every `viewer-panel:*`
211/// extension contributes one structured data panel to the viewer via the
212/// plugin's `viewer.panel` op (input: panel id). Output is data the CLI
213/// renders into the viewer nav/pages — never arbitrary JS (spec §104).
214/// Each panel carries provenance (plugin id + extension); required-panel
215/// failure is an inline error panel, else the panel is skipped with a note.
216// trace:v1 id=impl.scc-engine-plugins.viewer-panels work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
217pub fn viewer_panels(
218    root: &std::path::Path,
219    config: &scc_indexer::Config,
220) -> (Vec<serde_json::Value>, Vec<String>) {
221    let ap = active(root, config);
222    let mut panels = Vec::new();
223    let mut notes = Vec::new();
224    let specs: Vec<(String, String, String)> = ap
225        .plugins
226        .iter()
227        .flat_map(|pl| {
228            pl.manifest.extensions.iter()
229                .filter(|e| e.extension_type == "viewer-panel")
230                .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
231        })
232        .collect();
233    for (pid, ext_id, policy) in specs {
234        let plug = match ap.plugins.iter().find(|pl| pl.manifest.id == pid).cloned() {
235            Some(pl) => pl,
236            None => continue,
237        };
238        match scc_plugin_host::call(&plug, "viewer.panel", serde_json::json!({"panel": ext_id}), None) {
239            Ok(v) => {
240                let title = v.get("title").and_then(|x| x.as_str()).unwrap_or(&ext_id).to_string();
241                let html = v.get("html").and_then(|x| x.as_str()).unwrap_or("").to_string();
242                // Spec §104: no arbitrary plugin JS in the viewer by
243                // default. Script-bearing panels are dropped loudly.
244                if html.to_lowercase().contains("<script") {
245                    notes.push(format!("viewer panel {ext_id} from {pid} skipped (inline <script> not allowed)"));
246                } else if !html.trim().is_empty() {
247                    panels.push(serde_json::json!({
248                        "id": ext_id, "plugin": pid, "title": title, "html": html,
249                    }));
250                }
251            }
252            Err(e) => {
253                if policy == "required" {
254                    panels.push(serde_json::json!({
255                        "id": ext_id, "plugin": pid, "title": ext_id,
256                        "html": format!("panel failed: {e}"),
257                    }));
258                } else {
259                    notes.push(format!("viewer panel {ext_id} from {pid} skipped ({e})"));
260                }
261            }
262        }
263    }
264    (panels, notes)
265}
266
267/// Quota-policy overrides (§124 item 25): every `quota-policy:*`
268/// extension may override per-kind quota fractions via the plugin's
269/// `selection.quotas` op (input: ranked ids + request quotas; output
270/// `quotas: [{kind, fraction}]`). Returned pairs merge over the request
271/// quotas (plugin wins per kind). Failures follow policy: required =
272/// hard error, else skipped with a diagnostic. Fractions clamp to
273/// [0,1] at apply time (apply_quotas already clamps).
274// trace:v1 id=impl.scc-engine-plugins.quota-overrides work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
275pub fn quota_overrides(
276    ap: &ActivePlugins,
277    req: &scc_api::SelectionRequest,
278) -> (Vec<(String, f64)>, Vec<scc_plugin_host::PluginDiagnostic>) {
279    let mut out = Vec::new();
280    let mut notes = Vec::new();
281    let specs: Vec<(String, String, String)> = ap
282        .plugins
283        .iter()
284        .flat_map(|pl| {
285            pl.manifest.extensions.iter()
286                .filter(|e| e.extension_type == "quota-policy")
287                .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
288        })
289        .collect();
290    for (pid, ext_id, policy) in specs {
291        let plug = match ap.plugins.iter().find(|pl| pl.manifest.id == pid).cloned() {
292            Some(pl) => pl,
293            None => continue,
294        };
295        let ranked: Vec<serde_json::Value> = req.ranked.iter().map(|e| {
296            serde_json::json!({"id": e.id, "kind": e.kind, "value": e.value, "token_cost": e.token_cost})
297        }).collect();
298        let quotas: Vec<serde_json::Value> = req.quotas.clone().unwrap_or_default().into_iter().map(|q| {
299            serde_json::json!({"kind": q.kind, "fraction": q.fraction})
300        }).collect();
301        let input = serde_json::json!({"policy": ext_id, "ranked": ranked, "quotas": quotas});
302        match scc_plugin_host::call(&plug, "selection.quotas", input, None) {
303            Ok(v) => {
304                if let Some(arr) = v.get("quotas").and_then(|x| x.as_array()) {
305                    for q in arr {
306                        let kind = q.get("kind").and_then(|x| x.as_str()).unwrap_or("");
307                        let frac = q.get("fraction").and_then(|x| x.as_f64()).unwrap_or(-1.0);
308                        if !kind.is_empty() && (0.0..=1.0).contains(&frac) {
309                            out.push((kind.to_string(), frac));
310                        }
311                    }
312                }
313            }
314            Err(e) => {
315                if policy == "required" {
316                    notes.push(scc_plugin_host::PluginDiagnostic {
317                        plugin: pid, operation: "selection.quotas".into(),
318                        error: format!("quota policy {ext_id} failed: {e}"), action: "failed".into(),
319                    });
320                }
321            }
322        }
323    }
324    (out, notes)
325}
326
327/// Budget-optimizer selection (§124 item 27): the single declaring
328/// `budget-optimizer:*` extension replaces budget selection via the
329/// plugin's `selection.optimize` op (input: ranked ids + budget; output
330/// `selected: [ids]`). Zero declarers = None (caller runs the default).
331/// Two+ declarers = hard error (spec §32: no silent last-wins for
332/// exclusive policy slots). Unknown ids in the plugin answer fail
333/// loudly; an empty answer is honored (select nothing).
334// trace:v1 id=impl.scc-engine-plugins.budget-selection work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
335pub fn budget_selection(
336    ap: &ActivePlugins,
337    req: &scc_api::SelectionRequest,
338    items: &[scc_core::ContextItem],
339    budget: usize,
340) -> crate::Result<Option<Vec<String>>> {
341    let specs: Vec<(String, String, String)> = ap
342        .plugins
343        .iter()
344        .flat_map(|pl| {
345            pl.manifest.extensions.iter()
346                .filter(|e| e.extension_type == "budget-optimizer")
347                .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
348        })
349        .collect();
350    if specs.is_empty() {
351        return Ok(None);
352    }
353    if specs.len() > 1 {
354        let who: Vec<String> = specs.iter().map(|(pid, eid, _)| format!("{eid} from {pid}")).collect();
355        return Err(crate::EngineError::Other(format!(
356            "multiple budget-optimizer extensions compete ({}) — exclusive policy slot needs explicit configuration (spec §32)",
357            who.join(", ")
358        )));
359    }
360    let (pid, ext_id, policy) = &specs[0];
361    let plug = ap.plugins.iter().find(|pl| &pl.manifest.id == pid).cloned()
362        .ok_or_else(|| crate::EngineError::Other(format!("budget optimizer plugin {pid} vanished")))?;
363    let ranked: Vec<serde_json::Value> = req.ranked.iter().map(|e| {
364        serde_json::json!({"id": e.id, "kind": e.kind, "value": e.value, "token_cost": e.token_cost})
365    }).collect();
366    let input = serde_json::json!({"optimizer": ext_id, "ranked": ranked, "budget": budget});
367    match scc_plugin_host::call(&plug, "selection.optimize", input, None) {
368        Ok(v) => {
369            let known: std::collections::BTreeSet<&str> =
370                items.iter().map(|i| i.id.as_str()).collect();
371            let mut sel = Vec::new();
372            if let Some(arr) = v.get("selected").and_then(|x| x.as_array()) {
373                for x in arr {
374                    let id = x.as_str().unwrap_or("");
375                    if !known.contains(id) {
376                        return Err(crate::EngineError::Other(format!(
377                            "budget optimizer {ext_id} from {pid} selected unknown id '{id}'"
378                        )));
379                    }
380                    sel.push(id.to_string());
381                }
382            }
383            Ok(Some(sel))
384        }
385        Err(e) => {
386            if policy == "required" {
387                Err(crate::EngineError::Other(format!(
388                    "budget optimizer {ext_id} from {pid} failed: {e}"
389                )))
390            } else {
391                Ok(None)
392            }
393        }
394    }
395}
396
397/// Diversity-policy selection (§124 item 24): the single declaring
398/// `diversity-policy:*` extension replaces MMR via the plugin's
399/// `selection.diversify` op (input: ranked ids + lambda; output
400/// `selected: [ids]`, honored verbatim). Zero declarers = None (caller
401/// runs default MMR). Two+ declarers = hard error (spec §32: exclusive
402/// policy slots never silently last-win). Unknown ids fail loudly; an
403/// empty answer is honored (select nothing).
404// trace:v1 id=impl.scc-engine-plugins.diversity-selection work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
405pub fn diversity_selection(
406    ap: &ActivePlugins,
407    req: &scc_api::SelectionRequest,
408) -> crate::Result<Option<Vec<String>>> {
409    let specs: Vec<(String, String, String)> = ap
410        .plugins
411        .iter()
412        .flat_map(|pl| {
413            pl.manifest.extensions.iter()
414                .filter(|e| e.extension_type == "diversity-policy")
415                .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
416        })
417        .collect();
418    if specs.is_empty() {
419        return Ok(None);
420    }
421    if specs.len() > 1 {
422        let who: Vec<String> = specs.iter().map(|(pid, eid, _)| format!("{eid} from {pid}")).collect();
423        return Err(crate::EngineError::Other(format!(
424            "multiple diversity-policy extensions compete ({}) — exclusive policy slot needs explicit configuration (spec §32)",
425            who.join(", ")
426        )));
427    }
428    let (pid, ext_id, policy) = &specs[0];
429    let plug = ap.plugins.iter().find(|pl| &pl.manifest.id == pid).cloned()
430        .ok_or_else(|| crate::EngineError::Other(format!("diversity plugin {pid} vanished")))?;
431    let ranked: Vec<serde_json::Value> = req.ranked.iter().map(|e| {
432        serde_json::json!({"id": e.id, "value": e.value})
433    }).collect();
434    let input = serde_json::json!({
435        "policy": ext_id, "ranked": ranked,
436        "lambda": req.lambda.unwrap_or(0.5),
437    });
438    match scc_plugin_host::call(&plug, "selection.diversify", input, None) {
439        Ok(v) => {
440            let known: std::collections::BTreeSet<&str> =
441                req.ranked.iter().map(|e| e.id.as_str()).collect();
442            let mut sel = Vec::new();
443            if let Some(arr) = v.get("selected").and_then(|x| x.as_array()) {
444                for x in arr {
445                    let id = x.as_str().unwrap_or("");
446                    if !known.contains(id) {
447                        return Err(crate::EngineError::Other(format!(
448                            "diversity policy {ext_id} from {pid} selected unknown id '{id}'"
449                        )));
450                    }
451                    sel.push(id.to_string());
452                }
453            }
454            Ok(Some(sel))
455        }
456        Err(e) => {
457            if policy == "required" {
458                Err(crate::EngineError::Other(format!(
459                    "diversity policy {ext_id} from {pid} failed: {e}"
460                )))
461            } else {
462                Ok(None)
463            }
464        }
465    }
466}
467
468/// Sidecar promotion (§124 item 36): promote selected sidecar findings
469/// into canonical relationships through the NORMAL contribution path
470/// (validate + atomic commit) — never a side door. Each assertion needs
471/// existing subject/object entity ids (no invented endpoints), a CORE
472/// ontology predicate (custom semantics stay `plugin:<id>/...` via
473/// `plugins.contribute`), and a confidence in [0,1]. Provenance stamps
474/// `plugin:<id>`; RESOLVED only on explicit `exact: true` (spec §43: a
475/// plugin may not label guesses RESOLVED), else INFERRED. Evidence ids
476/// supplied by the caller are preserved; the plugin extractor tag is
477/// added at commit time by the normal path.
478// trace:v1 id=impl.scc-engine-plugins.promote-sidecar work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
479pub fn promote_sidecar(
480    store: &scc_store::Store,
481    plugin_id: &str,
482    assertions: &serde_json::Value,
483) -> crate::Result<serde_json::Value> {
484    let arr = assertions.as_array().cloned().unwrap_or_default();
485    let mut rels = Vec::new();
486    for (i, a) in arr.iter().enumerate() {
487        let sub = a.get("subject").and_then(|v| v.as_str()).unwrap_or("");
488        let pred = a.get("predicate").and_then(|v| v.as_str()).unwrap_or("");
489        let obj = a.get("object").and_then(|v| v.as_str()).unwrap_or("");
490        if sub.is_empty() || pred.is_empty() || obj.is_empty() {
491            return Err(crate::EngineError::Other(format!(
492                "promotion assertion #{i}: subject/predicate/object required"
493            )));
494        }
495        if !scc_core::predicates::ALL.contains(&pred) {
496            return Err(crate::EngineError::Other(format!(
497                "promotion assertion #{i}: predicate '{pred}' is not core ontology (custom predicates stay 'plugin:<id>/...' via plugins.contribute)"
498            )));
499        }
500        let conf = a.get("confidence").and_then(|v| v.as_f64()).unwrap_or(-1.0);
501        if !(0.0..=1.0).contains(&conf) {
502            return Err(crate::EngineError::Other(format!(
503                "promotion assertion #{i}: confidence {conf} outside [0,1]"
504            )));
505        }
506        let exact = a.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
507        let prov = if exact { "RESOLVED" } else { "INFERRED" };
508        let ev: Vec<serde_json::Value> = a.get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default();
509        rels.push(serde_json::json!({
510            "id": format!("plugin:{plugin_id}/promoted/{i}"),
511            "subject": sub, "predicate": pred, "object": obj,
512            "provenance": prov, "confidence": conf, "evidence": ev,
513        }));
514    }
515    let batch = serde_json::json!({
516        "entities": [], "relationships": rels,
517        "evidence": [], "diagnostics": [],
518    });
519    commit_contribution(store, plugin_id, &batch)
520}
521
522/// Deterministic extension order (§19): priority ascending, plugin id
523/// ascending, then before/after DAG edges. Unknown references and cycles
524/// are startup errors — never silent misordering.
525// trace:exempt reason=internal-detail
526pub struct ExtensionOrder {
527    pub extension_type: String,
528    pub id: String,
529    pub priority: i32,
530    pub after: Vec<String>,
531    pub before: Vec<String>,
532}
533
534// trace:exempt reason=internal-detail
535impl ExtensionOrder {
536    // trace:exempt reason=internal-detail
537    pub fn key(&self) -> String { format!("{}:{}", self.extension_type, self.id) }
538}
539
540// trace:v1 id=impl.scc-engine-plugins.order-extensions work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
541pub fn order_extensions(extensions: &[ExtensionOrder]) -> crate::Result<Vec<usize>> {
542    use std::collections::{BTreeMap, BTreeSet};
543    let n = extensions.len();
544    // Node key: canonical `type:id`.
545    let key_of = |i: usize| -> String { extensions[i].key() };
546    let mut index: BTreeMap<String, usize> = BTreeMap::new();
547    for i in 0..n {
548        let k = key_of(i);
549        if index.insert(k.clone(), i).is_some() {
550            return Err(crate::EngineError::Other(format!("duplicate extension registration '{k}'")));
551        }
552    }
553    // Edges: after(X) means X -> self; before(Y) means self -> Y.
554    let mut preds: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
555    let mut succs: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
556    for i in 0..n {
557        for a in &extensions[i].after {
558            let j = *index.get(a).ok_or_else(|| {
559                crate::EngineError::Other(format!("extension '{}' orders after unknown '{}'", key_of(i), a))
560            })?;
561            if j != i {
562                preds[i].insert(j);
563                succs[j].insert(i);
564            }
565        }
566        for b in &extensions[i].before {
567            let j = *index.get(b).ok_or_else(|| {
568                crate::EngineError::Other(format!("extension '{}' orders before unknown '{}'", key_of(i), b))
569            })?;
570            if j != i {
571                succs[i].insert(j);
572                preds[j].insert(i);
573            }
574        }
575    }
576    // Kahn with (priority, plugin-id, index) tie-break — deterministic.
577    let mut ready: Vec<usize> = (0..n).filter(|&i| preds[i].is_empty()).collect();
578    let sort_key = |i: &usize| (extensions[*i].priority, extensions[*i].id.clone(), *i);
579    ready.sort_by_key(sort_key);
580    let mut out = Vec::with_capacity(n);
581    while let Some(i) = ready.first().cloned() {
582        ready.remove(0);
583        out.push(i);
584        let mut newly: Vec<usize> = Vec::new();
585        for &j in &succs[i] {
586            preds[j].remove(&i);
587            if preds[j].is_empty() {
588                newly.push(j);
589            }
590        }
591        ready.extend(newly);
592        ready.sort_by_key(sort_key);
593    }
594    if out.len() != n {
595        let stuck: Vec<String> = (0..n).filter(|i| !out.contains(i)).map(key_of).collect();
596        return Err(crate::EngineError::Other(format!("extension ordering cycle: {}", stuck.join(", "))));
597    }
598    Ok(out)
599}
600
601/// Manifest extensions flattened to ordering entries, in plugin order.
602// trace:exempt reason=internal-detail
603pub(crate) fn collect_extensions(ap: &ActivePlugins) -> Vec<ExtensionOrder> {
604    let mut out = Vec::new();
605    for p in &ap.plugins {
606        for e in &p.manifest.extensions {
607            out.push(ExtensionOrder { extension_type: e.extension_type.clone(), id: e.id.clone(), priority: e.priority, after: e.after.clone(), before: e.before.clone() });
608        }
609    }
610    out
611}
612
613/// Validate a plugin contribution batch (§24): entity ids and kinds present,
614/// relationship endpoints resolve within (batch entities + graph), custom
615/// ontology namespaced `plugin:<id>/...`, evidence ids present.
616///
617/// Returns the normalized batch on success; broken plugins fail BEFORE any
618/// write — the model is never left half-committed.
619// trace:v1 id=impl.scc-engine-plugins.validate-contribution work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
620pub fn validate_contribution(
621    store: &scc_store::Store,
622    plugin_id: &str,
623    batch: &serde_json::Value,
624) -> crate::Result<serde_json::Value> {
625    // Fail loudly on unknown top-level keys: silently dropping a `flows`
626    // or `invariants` array would let a plugin believe it contributed
627    // facts SCC never stored. Flows/invariants/contracts derive from
628    // entities at graph-compile time — contribute entities, not rows.
629    if let Some(obj) = batch.as_object() {
630        let known = ["entities", "relationships", "evidence", "diagnostics"];
631        let unknown: Vec<&str> = obj.keys().filter(|k| !known.contains(&k.as_str())).map(|k| k.as_str()).collect();
632        if !unknown.is_empty() {
633            return Err(crate::EngineError::Other(format!(
634                "contribution has unsupported top-level keys [{}] (supported: entities, relationships, evidence, diagnostics); flows/invariants/contracts derive from entities at graph-compile time",
635                unknown.join(", ")
636            )));
637        }
638    }
639    let entities = batch.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
640    let relationships = batch.get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default();
641    let evidence = batch.get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default();
642    let mut ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
643    for e in store.all_entities()? {
644        ids.insert(e.id);
645    }
646    for (i, e) in entities.iter().enumerate() {
647        let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("");
648        let kind = e.get("kind").and_then(|v| v.as_str()).unwrap_or("");
649        if id.is_empty() {
650            return Err(crate::EngineError::Other(format!("contribution entity #{i}: missing id")));
651        }
652        if kind.is_empty() {
653            return Err(crate::EngineError::Other(format!("contribution entity '{id}': missing kind")));
654        }
655        if kind.contains('/') && !kind.starts_with("plugin:") {
656            return Err(crate::EngineError::Other(format!(
657                "contribution entity '{id}': custom kind '{kind}' must be namespaced 'plugin:<id>/...'"
658            )));
659        }
660        ids.insert(id.to_string());
661    }
662    for (i, r) in relationships.iter().enumerate() {
663        let (sub, pred, obj) = (
664            r.get("subject").and_then(|v| v.as_str()).unwrap_or(""),
665            r.get("predicate").and_then(|v| v.as_str()).unwrap_or(""),
666            r.get("object").and_then(|v| v.as_str()).unwrap_or(""),
667        );
668        if sub.is_empty() || pred.is_empty() || obj.is_empty() {
669            return Err(crate::EngineError::Other(format!("contribution relationship #{i}: subject/predicate/object required")));
670        }
671        if pred.contains('/') && !pred.starts_with("plugin:") {
672            return Err(crate::EngineError::Other(format!(
673                "contribution relationship #{i}: custom predicate '{pred}' must be namespaced 'plugin:<id>/...'"
674            )));
675        }
676        for end in [sub, obj] {
677            if !ids.contains(end) {
678                return Err(crate::EngineError::Other(format!(
679                    "contribution relationship #{i}: dangling endpoint '{end}'"
680                )));
681            }
682        }
683    }
684    for (i, e) in evidence.iter().enumerate() {
685        if e.get("id").and_then(|v| v.as_str()).unwrap_or("").is_empty() {
686            return Err(crate::EngineError::Other(format!("contribution evidence #{i}: missing id")));
687        }
688    }
689    // Provenance-stamp every record (§25) before commit.
690    let stamp = |mut v: serde_json::Value| -> serde_json::Value {
691        if let Some(obj) = v.as_object_mut() {
692            obj.insert("_origin".into(), serde_json::json!({
693                "kind": "plugin", "plugin_id": plugin_id,
694            }));
695        }
696        v
697    };
698    let diagnostics = batch.get("diagnostics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
699    Ok(serde_json::json!({
700        "entities": entities.into_iter().map(stamp).collect::<Vec<_>>(),
701        "relationships": relationships.into_iter().map(stamp).collect::<Vec<_>>(),
702        "evidence": evidence.into_iter().map(stamp).collect::<Vec<_>>(),
703        "diagnostics": diagnostics,
704    }))
705}
706
707/// Commit a validated batch: entities, relationships, evidence in order.
708/// Validate-then-commit keeps broken plugins from half-writing the model.
709// trace:v1 id=impl.scc-engine-plugins.commit-contribution work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
710pub fn commit_contribution(
711    store: &scc_store::Store,
712    plugin_id: &str,
713    batch: &serde_json::Value,
714) -> crate::Result<serde_json::Value> {
715    // Spec 24: validate everything BEFORE writing, then commit inside one
716    // batch so a mid-batch failure rolls back instead of leaving half a
717    // graph. Decode errors also abort before any write.
718    let checked = validate_contribution(store, plugin_id, batch)?;
719    let entities: Vec<scc_core::Entity> = checked
720        .get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default()
721        .into_iter().map(serde_json::from_value)
722        .collect::<std::result::Result<Vec<_>, _>>()
723        .map_err(|e| crate::EngineError::Other(format!("contribution entity decode: {e}")))?;
724    let rels: Vec<scc_core::Relationship> = checked
725        .get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default()
726        .into_iter().map(serde_json::from_value)
727        .collect::<std::result::Result<Vec<_>, _>>()
728        .map_err(|e| crate::EngineError::Other(format!("contribution relationship decode: {e}")))?;
729    let mut evs: Vec<scc_core::Evidence> = checked
730        .get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default()
731        .into_iter().map(serde_json::from_value)
732        .collect::<std::result::Result<Vec<_>, _>>()
733        .map_err(|e| crate::EngineError::Other(format!("contribution evidence decode: {e}")))?;
734    for ev in evs.iter_mut() {
735        // Provenance stamp survives the typed decode via extractor tag.
736        ev.extractor = Some(format!("plugin:{plugin_id}"));
737    }
738    store.batch_begin().map_err(crate::EngineError::Store)?;
739    let mut counts = (0usize, 0usize, 0usize);
740    for e in &entities {
741        if let Err(err) = store.insert_entity(e, &[format!("plugin:{plugin_id}")]) {
742            store.batch_abort();
743            return Err(crate::EngineError::Store(err));
744        }
745        counts.0 += 1;
746    }
747    for r in &rels {
748        if let Err(err) = store.insert_relationship(r, &format!("plugin:{plugin_id}")) {
749            store.batch_abort();
750            return Err(crate::EngineError::Store(err));
751        }
752        counts.1 += 1;
753    }
754    for ev in &evs {
755        if let Err(err) = store.insert_evidence(ev) {
756            store.batch_abort();
757            return Err(crate::EngineError::Store(err));
758        }
759        counts.2 += 1;
760    }
761    store.batch_end().map_err(crate::EngineError::Store)?;
762    let n_diag = checked.get("diagnostics").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
763    Ok(serde_json::json!({"entities": counts.0, "relationships": counts.1, "evidence": counts.2, "diagnostics": n_diag}))
764}
765
766/// Context-section contributions (§17 Context): every `context-section:*`
767/// extension renders one markdown section into the task pack.
768///
769/// Input carries goal/files/symbols; output `section` markdown is spliced
770/// verbatim under a provenance header (`PLUGIN SECTION <id> from <plugin>`).
771/// Failures follow policy: required = hard error, else skip with diagnostic
772/// recorded in the pack warnings channel via the returned notes.
773// trace:v1 id=impl.scc-engine-plugins.context-sections work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
774pub fn context_sections(
775    root: &std::path::Path,
776    config: &scc_indexer::Config,
777    goal: &str,
778    files: &[String],
779    symbols: &[String],
780) -> String {
781    let mut ap = active(root, config);
782    // Deterministic order: registration order (plugin discovery is sorted).
783    let mut out = String::new();
784    let specs: Vec<(String, String, String)> = ap
785        .plugins
786        .iter()
787        .flat_map(|p| {
788            p.manifest.extensions.iter().filter(|e| e.extension_type == "context-section").map(|e| {
789                (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
790            })
791        })
792        .collect();
793    for (pid, ext_id, policy) in specs {
794        let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
795            Some(p) => p,
796            None => continue,
797        };
798        let input = serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "section": ext_id});
799        match scc_plugin_host::call(&plug, "context.section", input, None) {
800            Ok(v) => {
801                if let Some(section) = v.get("section").and_then(|s| s.as_str()) {
802                    if !section.trim().is_empty() {
803                        out.push_str(&format!("\n# PLUGIN SECTION {ext_id} (from {pid} — plugin content, not verified facts)\n"));
804                        out.push_str(section.trim());
805                        out.push('\n');
806                    }
807                }
808            }
809            Err(e) => {
810                if policy == "required" {
811                    out.push_str(&format!("\n# PLUGIN SECTION {ext_id} FAILED (from {pid}): {e}\n"));
812                }
813                ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
814                    plugin: pid, operation: "context.section".into(), error: e.to_string(),
815                    action: if policy == "required" { "failed".into() } else { "skipped".into() },
816                });
817            }
818        }
819    }
820    // Diagnostics outlive the call: stash count in a trailing marker the
821    // pack warnings channel can surface (no silent skips).
822    let _ = ap;
823    out
824}