Files
inbuxa-server/crates/features/src/ai/explain/schema.rs
T
jcoffey-dev d9a6db025b
ci / fork-checks (pull_request) Successful in 48s
ci / build (pull_request) Successful in 9m4s
Explain this: the local model reads delivery failures, verdicts, logs and settings
A new method, inbuxa:Explanation/set, asks the node's local model for a
short plain-words reading of one thing an administrator is looking at:
a failed recipient in the queue, a Classify verdict, a log line or trace
event, or one setting with its saved value. The server builds the prompt
itself from stored data and the registry schema, never from text the
console sends, and grounds SMTP replies in RFC 3463 and RFC 5321.

What the model is never shown: secrets (including ones nested inside a
setting, like an AI model's HTTP auth), raw protocol events, and the
contents of any other event. A tag name that doesn't have a tag's shape is
refused before a model is asked.

Calls share the AI gate with spam classification, but mail always keeps
its slot, and Explain has its own hourly count per account and its own
on/off switch in inbuxa:AiLimits. The permission is sysAiExplain,
superuser only; tenant administrators can't use it. The session carries
an aiExplain flag so a console knows when to offer the button.

An install whose roles were stored before the permission existed gets it
added once, at start-up, to the roles that are administrators' alone,
not the User role their defaults share with every account. An operator
who removes it later isn't overruled.

Tests: unit tests in inbuxa-features and jmap, and ai_explain_tests
(run with --ignored) covering the acceptance tests and the upgrade.
2026-09-26 00:52:51 -07:00

228 lines
8.9 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Reference text from the registry schema (EX-7, EX-9): what an event
//! means, and what a setting is, its default and allowed values, and whether
//! it holds a secret anywhere inside it.
use serde_json::Value;
use std::collections::HashSet;
/// The registry schema, as the console downloads it.
pub struct Schema(Value);
/// What the schema says about one property of one object.
#[derive(Debug, Clone, PartialEq)]
pub struct PropertyInfo {
pub description: String,
pub label: Option<String>,
pub default: Option<Value>,
/// Allowed values of an enum, as "name (label)".
pub allowed: Vec<String>,
/// The property is a secret, or an object with a secret inside (EX-9).
pub secret: bool,
}
impl Schema {
pub fn new(json: Value) -> Self {
Schema(json)
}
/// An event's label and explanation, by its name (`smtp.spf-ehlo-fail`).
pub fn event(&self, name: &str) -> Option<(String, String)> {
self.0["enums"]["EventType"]
.as_array()?
.iter()
.find(|e| e["name"] == name)
.map(|e| {
(
e["label"].as_str().unwrap_or_default().to_string(),
e["explanation"].as_str().unwrap_or_default().to_string(),
)
})
}
/// The field sets an object's properties are defined in: its own, or
/// those of each of its variants.
fn field_sets(&self, object: &str) -> Vec<String> {
let schema = &self.0["schemas"][object];
let mut names = Vec::new();
match schema["type"].as_str() {
Some("single") => {
if let Some(name) = schema["schemaName"].as_str() {
names.push(name.to_string());
}
}
Some("multiple") => {
for variant in schema["variants"].as_array().into_iter().flatten() {
if let Some(name) = variant["schemaName"].as_str()
&& !names.iter().any(|n| n == name)
{
names.push(name.to_string());
}
}
}
_ => {}
}
if names.is_empty() {
names.push(object.to_string());
}
names
}
/// One property of one object (`x:Domain`, `dnsManagement`).
pub fn property(&self, object: &str, property: &str) -> Option<PropertyInfo> {
for set in self.field_sets(object) {
let fields = &self.0["fields"][&set];
let Some(definition) = fields["properties"].get(property) else {
continue;
};
let kind = &definition["type"];
let allowed = match kind["enumName"].as_str() {
Some(name) if kind["type"] == "enum" => self.0["enums"][name]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let name = e["name"].as_str()?;
Some(match e["label"].as_str() {
Some(label) => format!("{name} ({label})"),
None => name.to_string(),
})
})
.collect(),
_ => Vec::new(),
};
let label = [object, set.as_str()]
.iter()
.find_map(|form| self.label(form, property));
return Some(PropertyInfo {
description: definition["description"].as_str().unwrap_or_default().to_string(),
label,
default: fields["defaults"].get(property).cloned(),
allowed,
secret: self.holds_secret(kind, &mut HashSet::new()),
});
}
None
}
fn label(&self, form: &str, property: &str) -> Option<String> {
self.0["forms"][form]["sections"]
.as_array()?
.iter()
.flat_map(|section| section["fields"].as_array().into_iter().flatten())
.find(|field| field["name"] == property)
.and_then(|field| field["label"].as_str())
.map(str::to_string)
}
/// Whether a type is a secret or embeds one, following embedded objects
/// (not references to other records).
fn holds_secret(&self, kind: &Value, seen: &mut HashSet<String>) -> bool {
match kind {
Value::Object(map) => {
if map.get("format").and_then(Value::as_str) == Some("secret") {
return true;
}
let embeds = matches!(
map.get("type").and_then(Value::as_str),
Some("object" | "objectList")
);
if embeds
&& let Some(name) = map.get("objectName").and_then(Value::as_str)
&& seen.insert(name.to_string())
{
for set in self.field_sets(name) {
let properties = &self.0["fields"][&set]["properties"];
for definition in properties.as_object().into_iter().flat_map(|p| p.values()) {
if self.holds_secret(&definition["type"], seen) {
return true;
}
}
}
}
map.iter()
.filter(|(key, _)| key.as_str() != "objectName")
.any(|(_, value)| self.holds_secret(value, seen))
}
Value::Array(items) => items.iter().any(|item| self.holds_secret(item, seen)),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn schema() -> Schema {
Schema::new(json!({
"schemas": {
"x:Domain": {"type": "single", "schemaName": "x:Domain"},
"x:HttpAuth": {"type": "multiple", "variants": [
{"name": "Unauthenticated"},
{"name": "Bearer", "schemaName": "x:HttpAuthBearer"}]},
"x:AiModel": {"type": "single", "schemaName": "x:AiModel"}
},
"fields": {
"x:Domain": {"properties": {
"isEnabled": {"description": "Whether the domain is on", "type": {"type": "boolean"}},
"dnsManagement": {"description": "How DNS is managed",
"type": {"type": "enum", "enumName": "DnsManagement"}},
"tenantId": {"description": "Owner", "type": {"type": "objectId", "objectName": "x:AiModel"}}
}, "defaults": {"isEnabled": true}},
"x:HttpAuthBearer": {"properties": {
"bearerToken": {"description": "Token", "type": {"type": "string", "format": "secret"}}}},
"x:AiModel": {"properties": {
"httpAuth": {"description": "Auth", "type": {"type": "object", "objectName": "x:HttpAuth"}},
"apiKey": {"description": "Key", "type": {"type": "string", "format": "secret", "nullable": true}},
"name": {"description": "Name", "type": {"type": "string"}}
}}
},
"forms": {"x:Domain": {"sections": [{"fields": [{"name": "isEnabled", "label": "Enabled"}]}]}},
"enums": {
"DnsManagement": [{"name": "Manual", "label": "Manual"}, {"name": "Automatic"}],
"EventType": [{"name": "smtp.spf-ehlo-fail", "label": "SPF EHLO check failed",
"explanation": "The EHLO name failed SPF."}]
}
}))
}
#[test]
fn describes_a_property() {
let s = schema();
let enabled = s.property("x:Domain", "isEnabled").unwrap();
assert_eq!(enabled.label.as_deref(), Some("Enabled"));
assert_eq!(enabled.default, Some(json!(true)));
assert!(!enabled.secret);
let dns = s.property("x:Domain", "dnsManagement").unwrap();
assert_eq!(dns.allowed, vec!["Manual (Manual)", "Automatic"]);
assert!(s.property("x:Domain", "nothing").is_none());
assert!(s.property("x:Nothing", "isEnabled").is_none());
}
#[test]
fn finds_secrets_even_nested() {
let s = schema();
assert!(s.property("x:AiModel", "apiKey").unwrap().secret);
// A secret inside one variant of an embedded object
assert!(s.property("x:AiModel", "httpAuth").unwrap().secret);
assert!(!s.property("x:AiModel", "name").unwrap().secret);
// A reference to another record isn't followed
assert!(!s.property("x:Domain", "tenantId").unwrap().secret);
}
#[test]
fn describes_an_event() {
let (label, text) = schema().event("smtp.spf-ehlo-fail").unwrap();
assert_eq!(label, "SPF EHLO check failed");
assert!(text.contains("SPF"));
assert!(schema().event("nope").is_none());
}
}