use std::{collections::HashSet, fmt};
use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
use serde_json::Value;
use crate::PROTOCOL_VERSION;
#[derive(Serialize, Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ModuleManifest {
pub module_id: String,
pub module_version: String,
pub protocol_ver: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trust_tier: Option<TrustTier>,
pub provides: Vec<ProviderRole>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub consumes: Vec<ConsumerRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bindings: Option<Bindings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<CapabilityDeclarations>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub self_signals: Option<Vec<SelfSignalDeclaration>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<ManifestProvenance>,
}
#[derive(Debug, Clone)]
pub struct ModuleManifestBuilder {
module_id: String,
module_version: String,
protocol_ver: u8,
trust_tier: Option<TrustTier>,
provides: Vec<ProviderRole>,
consumes: Vec<ConsumerRole>,
bindings: Option<Bindings>,
capabilities: Option<CapabilityDeclarations>,
self_signals: Option<Vec<SelfSignalDeclaration>>,
provenance: Option<ManifestProvenance>,
}
impl ModuleManifest {
pub fn builder(
module_id: impl Into<String>,
module_version: impl Into<String>,
) -> ModuleManifestBuilder {
ModuleManifestBuilder {
module_id: module_id.into(),
module_version: module_version.into(),
protocol_ver: PROTOCOL_VERSION,
trust_tier: None,
provides: Vec::new(),
consumes: Vec::new(),
bindings: None,
capabilities: None,
self_signals: None,
provenance: None,
}
}
}
impl ModuleManifestBuilder {
pub fn protocol_ver(mut self, protocol_ver: u8) -> Self {
self.protocol_ver = protocol_ver;
self
}
pub fn trust_tier(mut self, trust_tier: Option<TrustTier>) -> Self {
self.trust_tier = trust_tier;
self
}
pub fn provides(mut self, provides: Vec<ProviderRole>) -> Self {
self.provides = provides;
self
}
pub fn consumes(mut self, consumes: Vec<ConsumerRole>) -> Self {
self.consumes = consumes;
self
}
pub fn bindings(mut self, bindings: Option<Bindings>) -> Self {
self.bindings = bindings;
self
}
pub fn capabilities(mut self, capabilities: Option<CapabilityDeclarations>) -> Self {
self.capabilities = capabilities;
self
}
pub fn self_signals(mut self, self_signals: Option<Vec<SelfSignalDeclaration>>) -> Self {
self.self_signals = self_signals;
self
}
pub fn provenance(mut self, provenance: Option<ManifestProvenance>) -> Self {
self.provenance = provenance;
self
}
pub fn build(self) -> ModuleManifest {
ModuleManifest {
module_id: self.module_id,
module_version: self.module_version,
protocol_ver: self.protocol_ver,
trust_tier: self.trust_tier,
provides: self.provides,
consumes: self.consumes,
bindings: self.bindings,
capabilities: self.capabilities,
self_signals: self.self_signals,
provenance: self.provenance,
}
}
}
#[derive(Deserialize)]
struct ModuleManifestWire {
module_id: String,
module_version: String,
protocol_ver: u8,
#[serde(default)]
trust_tier: Option<TrustTier>,
provides: Vec<ProviderRole>,
#[serde(default)]
consumes: Vec<ConsumerRole>,
#[serde(default)]
bindings: Option<Bindings>,
#[serde(default)]
capabilities: Option<CapabilityDeclarations>,
#[serde(default)]
self_signals: Option<Vec<SelfSignalDeclaration>>,
#[serde(default)]
provenance: Option<ManifestProvenance>,
#[serde(default)]
runtime_computed: Option<Value>,
}
impl<'de> Deserialize<'de> for ModuleManifest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = ModuleManifestWire::deserialize(deserializer)?;
validate_runtime_computed(wire.runtime_computed.as_ref(), "runtime_computed")
.map_err(D::Error::custom)?;
let manifest = Self::builder(wire.module_id, wire.module_version)
.protocol_ver(wire.protocol_ver)
.trust_tier(wire.trust_tier)
.provides(wire.provides)
.consumes(wire.consumes)
.bindings(wire.bindings)
.capabilities(wire.capabilities)
.self_signals(wire.self_signals)
.provenance(wire.provenance)
.build();
manifest
.validate_capability_grammar()
.map_err(D::Error::custom)?;
Ok(manifest)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelfSignalDeclarationError {
module_id: String,
entry_index: usize,
field: &'static str,
}
impl fmt::Display for SelfSignalDeclarationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"module_id '{}' self_signals[{}] is missing required field '{}'",
self.module_id.escape_debug(),
self.entry_index,
self.field
)
}
}
pub fn validate_hello_self_signal_declarations(
hello: &Value,
) -> Result<(), SelfSignalDeclarationError> {
let Some(manifest) = hello.get("manifest").and_then(Value::as_object) else {
return Ok(());
};
let module_id = manifest
.get("module_id")
.and_then(Value::as_str)
.unwrap_or("<unknown>");
let Some(entries) = manifest.get("self_signals").and_then(Value::as_array) else {
return Ok(());
};
for (entry_index, entry) in entries.iter().enumerate() {
let Some(entry) = entry.as_object() else {
continue;
};
for field in ["effect", "anchored_to"] {
if !entry.contains_key(field) {
return Err(SelfSignalDeclarationError {
module_id: module_id.to_string(),
entry_index,
field,
});
}
}
}
Ok(())
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CapabilityDeclarations {
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub requires: Vec<CapabilityRequirement>,
#[serde(default)]
pub must_never_reach: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct SelfSignalDeclaration {
pub name: String,
pub kind: SelfSignalKind,
pub effect: SelfSignalEffect,
pub anchored_to: SignalAnchor,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cadence: Option<SignalCadence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelfSignalKind {
Keepalive,
Poller,
Cron,
Sweep,
Watchdog,
Heartbeat,
Other(String),
}
impl SelfSignalKind {
fn wire_name(&self) -> &str {
match self {
Self::Keepalive => "keepalive",
Self::Poller => "poller",
Self::Cron => "cron",
Self::Sweep => "sweep",
Self::Watchdog => "watchdog",
Self::Heartbeat => "heartbeat",
Self::Other(value) => value,
}
}
}
impl Serialize for SelfSignalKind {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.wire_name())
}
}
impl<'de> Deserialize<'de> for SelfSignalKind {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(match value.as_str() {
"keepalive" => Self::Keepalive,
"poller" => Self::Poller,
"cron" => Self::Cron,
"sweep" => Self::Sweep,
"watchdog" => Self::Watchdog,
"heartbeat" => Self::Heartbeat,
_ => Self::Other(value),
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SelfSignalEffect {
Observe,
Mutate,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SignalAnchor {
FixedInterval,
Event { event: String },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SignalCadence {
Literal { interval_ms: u64 },
Derived { source: String },
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub struct ManifestProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_git_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_lock_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wire_crate_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store_schema_version: Option<String>,
}
const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
#[derive(Deserialize)]
struct ManifestProvenanceWire {
#[serde(default)]
build_git_sha: Option<String>,
#[serde(default)]
build_lock_digest: Option<String>,
#[serde(default)]
wire_crate_version: Option<String>,
#[serde(default)]
store_schema_version: Option<String>,
}
impl<'de> Deserialize<'de> for ManifestProvenance {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = ManifestProvenanceWire::deserialize(deserializer)?;
let provenance = Self {
build_git_sha: wire.build_git_sha,
build_lock_digest: wire.build_lock_digest,
wire_crate_version: wire.wire_crate_version,
store_schema_version: wire.store_schema_version,
};
provenance.validate().map_err(D::Error::custom)?;
Ok(provenance)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvenanceFormError {
field: &'static str,
length: usize,
canonical_form: &'static str,
}
impl ProvenanceFormError {
fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
Self {
field,
length,
canonical_form,
}
}
pub fn field(&self) -> &str {
self.field
}
pub fn length(&self) -> usize {
self.length
}
pub fn canonical_form(&self) -> &str {
self.canonical_form
}
}
impl fmt::Display for ProvenanceFormError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid manifest provenance form: field {} has length {}; canonical form is {}",
self.field, self.length, self.canonical_form
)
}
}
impl std::error::Error for ProvenanceFormError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestProvenanceError {
field: String,
value: String,
reason: &'static str,
}
impl ManifestProvenanceError {
fn new(field: &str, value: &str, reason: &'static str) -> Self {
Self {
field: field.to_string(),
value: safe_error_value(value),
reason,
}
}
pub fn field(&self) -> &str {
&self.field
}
}
impl fmt::Display for ManifestProvenanceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid manifest provenance: field {} has {} (value {:?})",
self.field, self.reason, self.value
)
}
}
impl std::error::Error for ManifestProvenanceError {}
impl ManifestProvenance {
pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
for (field, value) in [
("build_git_sha", self.build_git_sha.as_deref()),
("build_lock_digest", self.build_lock_digest.as_deref()),
("wire_crate_version", self.wire_crate_version.as_deref()),
("store_schema_version", self.store_schema_version.as_deref()),
] {
let Some(value) = value else { continue };
if value.is_empty() {
return Err(ManifestProvenanceError::new(
field,
value,
"must not be empty",
));
}
if value.len() > MAX_PROVENANCE_VALUE_BYTES {
return Err(ManifestProvenanceError::new(
field,
value,
"exceeds the 128-byte maximum",
));
}
if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
return Err(ManifestProvenanceError::new(
field,
value,
"contains non-printable ASCII",
));
}
}
Ok(())
}
}
pub fn build_provenance(
build_git_sha: Option<&str>,
build_lock_digest: Option<&str>,
store_schema_version: Option<&str>,
) -> Result<ManifestProvenance, ProvenanceFormError> {
let build_git_sha = normalize_provenance_fact(build_git_sha);
validate_provenance_form(
"build_git_sha",
build_git_sha.as_deref(),
BUILD_GIT_SHA_CANONICAL_FORM,
40,
)?;
let build_lock_digest = normalize_provenance_fact(build_lock_digest);
validate_provenance_form(
"build_lock_digest",
build_lock_digest.as_deref(),
BUILD_LOCK_DIGEST_CANONICAL_FORM,
64,
)?;
Ok(ManifestProvenance {
build_git_sha,
build_lock_digest,
wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
store_schema_version: normalize_provenance_fact(store_schema_version),
})
}
fn validate_provenance_form(
field: &'static str,
value: Option<&str>,
canonical_form: &'static str,
expected_length: usize,
) -> Result<(), ProvenanceFormError> {
let Some(value) = value else { return Ok(()) };
if value.len() != expected_length
|| !value
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
{
return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
}
Ok(())
}
pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() {
return None;
}
let lowered = value.to_ascii_lowercase();
if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
return None;
}
Some(value.to_string())
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CapabilityRequirement {
pub capability: String,
pub need: CapabilityNeed,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityNeed {
Required,
Optional,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityGrammarError {
field: String,
value: String,
}
impl CapabilityGrammarError {
fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
Self {
field: field.into(),
value: safe_error_value(value.as_ref()),
}
}
pub fn field(&self) -> &str {
&self.field
}
pub fn value(&self) -> &str {
&self.value
}
}
impl fmt::Display for CapabilityGrammarError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid capability grammar: field {} has offending value {:?}",
self.field, self.value
)
}
}
impl std::error::Error for CapabilityGrammarError {}
impl ModuleManifest {
pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
let Some(capabilities) = &self.capabilities else {
return Ok(());
};
validate_capability_list("capabilities.provides", &capabilities.provides)?;
validate_requires(&capabilities.requires)?;
validate_capability_list(
"capabilities.must_never_reach",
&capabilities.must_never_reach,
)
}
}
pub fn validate_manifest_capability_grammar(
manifest: &Value,
) -> Result<(), CapabilityGrammarError> {
let Some(object) = manifest.as_object() else {
return Ok(());
};
validate_capabilities_value(object.get("capabilities"))?;
validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
}
pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
let Some(object) = hello.as_object() else {
return Ok(());
};
if let Some(manifest) = object.get("manifest") {
validate_manifest_capability_grammar(manifest)?;
}
validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
}
pub fn is_valid_capability_identifier(identifier: &str) -> bool {
if identifier.chars().any(char::is_whitespace) {
return false;
}
let Some((name, version)) = identifier.split_once("/v") else {
return false;
};
if name.is_empty() || name.len() > 64 || version.is_empty() {
return false;
}
let name_bytes = name.as_bytes();
if !name_bytes[0].is_ascii_lowercase()
|| (name.len() > 1
&& !name_bytes[name.len() - 1].is_ascii_lowercase()
&& !name_bytes[name.len() - 1].is_ascii_digit())
|| name_bytes.windows(2).any(|pair| pair == b"--")
{
return false;
}
if !name_bytes
.iter()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
{
return false;
}
if version.len() > 1 && version.starts_with('0')
|| !version.bytes().all(|byte| byte.is_ascii_digit())
{
return false;
}
matches!(
version.parse::<u64>(),
Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
)
}
fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
let Some(value) = value else {
return Ok(());
};
let Some(object) = value.as_object() else {
return Err(CapabilityGrammarError::new(
"capabilities",
value_description(value),
));
};
for (key, value) in object {
if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
return Err(CapabilityGrammarError::new(
field_child("capabilities", key),
value_description(value),
));
}
}
validate_capability_list_value("capabilities.provides", object.get("provides"))?;
validate_requires_value(object.get("requires"))?;
validate_capability_list_value(
"capabilities.must_never_reach",
object.get("must_never_reach"),
)
}
fn validate_capability_list_value(
field: &str,
value: Option<&Value>,
) -> Result<(), CapabilityGrammarError> {
let Some(value) = value else {
return Ok(());
};
let Some(values) = value.as_array() else {
return Err(CapabilityGrammarError::new(field, value_description(value)));
};
let mut seen = HashSet::new();
for (index, value) in values.iter().enumerate() {
let field = format!("{field}[{index}]");
let Some(identifier) = value.as_str() else {
return Err(CapabilityGrammarError::new(field, value_description(value)));
};
validate_capability_identifier(&field, identifier)?;
if !seen.insert(identifier) {
return Err(CapabilityGrammarError::new(field, identifier));
}
}
Ok(())
}
fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
let Some(value) = value else {
return Ok(());
};
let Some(values) = value.as_array() else {
return Err(CapabilityGrammarError::new(
"capabilities.requires",
value_description(value),
));
};
let mut seen = HashSet::new();
for (index, value) in values.iter().enumerate() {
let entry_field = format!("capabilities.requires[{index}]");
let Some(object) = value.as_object() else {
return Err(CapabilityGrammarError::new(
entry_field,
value_description(value),
));
};
for (key, value) in object {
if !matches!(key.as_str(), "capability" | "need") {
return Err(CapabilityGrammarError::new(
field_child(&entry_field, key),
value_description(value),
));
}
}
let capability_field = format!("{entry_field}.capability");
let Some(capability) = object.get("capability").and_then(Value::as_str) else {
return Err(CapabilityGrammarError::new(
capability_field,
object
.get("capability")
.map_or("<missing>".to_string(), value_description),
));
};
validate_capability_identifier(&capability_field, capability)?;
let need_field = format!("{entry_field}.need");
let Some(need) = object.get("need").and_then(Value::as_str) else {
return Err(CapabilityGrammarError::new(
need_field,
object
.get("need")
.map_or("<missing>".to_string(), value_description),
));
};
if !matches!(need, "required" | "optional") {
return Err(CapabilityGrammarError::new(need_field, need));
}
if !seen.insert(capability) {
return Err(CapabilityGrammarError::new(entry_field, capability));
}
}
Ok(())
}
fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
let mut seen = HashSet::new();
for (index, identifier) in values.iter().enumerate() {
let field = format!("{field}[{index}]");
validate_capability_identifier(&field, identifier)?;
if !seen.insert(identifier) {
return Err(CapabilityGrammarError::new(field, identifier));
}
}
Ok(())
}
fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
let mut seen = HashSet::new();
for (index, requirement) in values.iter().enumerate() {
let field = format!("capabilities.requires[{index}].capability");
validate_capability_identifier(&field, &requirement.capability)?;
if !seen.insert(&requirement.capability) {
return Err(CapabilityGrammarError::new(
format!("capabilities.requires[{index}]"),
&requirement.capability,
));
}
}
Ok(())
}
fn validate_capability_identifier(
field: &str,
identifier: &str,
) -> Result<(), CapabilityGrammarError> {
if is_valid_capability_identifier(identifier) {
Ok(())
} else {
Err(CapabilityGrammarError::new(field, identifier))
}
}
fn validate_runtime_computed(
value: Option<&Value>,
field: &str,
) -> Result<(), CapabilityGrammarError> {
let Some(value) = value else {
return Ok(());
};
let Some(pointers) = value.as_array() else {
return Err(CapabilityGrammarError::new(field, value_description(value)));
};
for (index, pointer) in pointers.iter().enumerate() {
let field = format!("{field}[{index}]");
let Some(pointer) = pointer.as_str() else {
return Err(CapabilityGrammarError::new(
field,
value_description(pointer),
));
};
let Some(tokens) = parse_json_pointer(pointer) else {
return Err(CapabilityGrammarError::new(field, pointer));
};
if tokens.first().is_some_and(|token| token == "capabilities") {
return Err(CapabilityGrammarError::new(field, pointer));
}
}
Ok(())
}
fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
if pointer.is_empty() {
return Some(Vec::new());
}
let raw_tokens = pointer.strip_prefix('/')?;
raw_tokens
.split('/')
.map(unescape_json_pointer_token)
.collect()
}
fn unescape_json_pointer_token(token: &str) -> Option<String> {
let mut output = String::with_capacity(token.len());
let mut characters = token.chars();
while let Some(character) = characters.next() {
if character != '~' {
output.push(character);
continue;
}
match characters.next()? {
'0' => output.push('~'),
'1' => output.push('/'),
_ => return None,
}
}
Some(output)
}
fn field_child(parent: &str, child: &str) -> String {
let child = safe_error_value(child);
format!("{parent}.{child}")
}
fn value_description(value: &Value) -> String {
match value {
Value::String(value) => safe_error_value(value),
Value::Null => "null".to_string(),
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::Array(_) => "<array>".to_string(),
Value::Object(_) => "<object>".to_string(),
}
}
fn safe_error_value(value: &str) -> String {
let lower = value.to_ascii_lowercase();
if ["secret", "password", "api_key"]
.iter()
.any(|marker| lower.contains(marker))
|| lower.starts_with("sk-")
|| lower.starts_with("akia")
|| lower.starts_with("bearer ")
|| lower.starts_with("token=")
|| lower.starts_with("credential=")
{
"<redacted>".to_string()
} else {
value.to_string()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TrustTier {
FirstParty,
Reviewed,
Untrusted,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ProviderRole {
ToolProvider {
tools: Vec<Tool>,
identity_scope: Vec<IdentityScope>,
concurrency: Concurrency,
emits_push: bool,
sub_supervises: bool,
},
PipelineStage {
stage: PipelineStageKind,
applies_to: PipelineAppliesTo,
interface: String,
declares_frozen_floor: bool,
needs_signals: Vec<String>,
conformance_class: String,
},
ManagementSurface {
operations: Vec<ManagementOperation>,
config_schema: Value,
observability: Vec<ObservabilitySurface>,
identity_scope: Vec<IdentityScope>,
#[serde(default)]
concurrency: Concurrency,
},
InternalService {
service_id: String,
transport: InternalTransport,
agent_facing: bool,
operations: Vec<String>,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionMode {
Pure,
Mutating,
Unfenceable,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Tool {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub execution_mode: ExecutionMode,
pub schema: Value,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Concurrency {
Serial,
ModuleManaged,
StatelessParallel,
}
#[allow(clippy::derivable_impls)]
impl Default for Concurrency {
fn default() -> Self {
Self::ModuleManaged
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum IdentityScope {
Session,
Project,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PipelineStageKind {
Transform,
Codec,
Auth,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PipelineAppliesTo {
pub provider: String,
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct ManagementOperation {
pub name: String,
pub kind: ManagementOperationKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ManagementOperationKind {
Query,
Mutate,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct ObservabilitySurface {
pub name: String,
pub kind: ObservabilityKind,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ObservabilityKind {
Snapshot,
Stream,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum InternalTransport {
Bulk,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ConsumerRole {
ToolClient { of: Vec<String> },
LlmClient { via: String, auth: String },
ServiceClient { of: Vec<String> },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Bindings {
pub storage: StorageBinding,
pub vault_grants: Vec<VaultGrant>,
pub identity: IdentityBinding,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct StorageBinding {
pub kind: StorageKind,
pub scope: StorageScope,
pub owns_schema: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum StorageKind {
Sqlite,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum StorageScope {
Project,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct VaultGrant {
pub secret: String,
pub reason: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct IdentityBinding {
pub requires: Vec<IdentityScope>,
pub optional: Vec<IdentityScope>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn aft_manifest_fixture() -> ModuleManifest {
ModuleManifest::builder("aft", "0.39.2")
.trust_tier(Some(TrustTier::FirstParty))
.bindings(Some(Bindings {
storage: StorageBinding {
kind: StorageKind::Sqlite,
scope: StorageScope::Project,
owns_schema: true,
},
vault_grants: vec![VaultGrant {
secret: "provider_api_key".to_string(),
reason: "cortexkit_native auth".to_string(),
}],
identity: IdentityBinding {
requires: vec![IdentityScope::Project],
optional: vec![IdentityScope::Session],
},
}))
.protocol_ver(1)
.provides(vec![ProviderRole::ToolProvider {
tools: vec![
Tool {
name: "read".to_string(),
description: None,
execution_mode: ExecutionMode::Pure,
schema: json!({"type": "object"}),
},
Tool {
name: "grep".to_string(),
description: None,
execution_mode: ExecutionMode::Pure,
schema: json!({"type": "object"}),
},
Tool {
name: "outline".to_string(),
description: None,
execution_mode: ExecutionMode::Pure,
schema: json!({"type": "object"}),
},
Tool {
name: "semantic_search".to_string(),
description: None,
execution_mode: ExecutionMode::Pure,
schema: json!({"type": "object"}),
},
Tool {
name: "edit".to_string(),
description: None,
execution_mode: ExecutionMode::Mutating,
schema: json!({"type": "object"}),
},
Tool {
name: "write".to_string(),
description: None,
execution_mode: ExecutionMode::Mutating,
schema: json!({"type": "object"}),
},
Tool {
name: "bash".to_string(),
description: None,
execution_mode: ExecutionMode::Unfenceable,
schema: json!({"type": "object"}),
},
],
identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
concurrency: Concurrency::ModuleManaged,
emits_push: true,
sub_supervises: true,
}])
.consumes(vec![ConsumerRole::ServiceClient {
of: vec!["embedding.v2".to_string()],
}])
.build()
}
#[test]
fn serde_round_trips_representative_manifest() {
let manifest = aft_manifest_fixture();
let serialized = serde_json::to_string_pretty(&manifest).unwrap();
let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
assert_eq!(manifest, decoded);
}
#[test]
fn builder_defaults_additions_to_honest_absence_and_round_trips() {
let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
assert_eq!(manifest.module_id, "builder-defaults");
assert_eq!(manifest.module_version, "2.0.0");
assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
assert_eq!(manifest.trust_tier, None);
assert!(manifest.provides.is_empty());
assert!(manifest.consumes.is_empty());
assert_eq!(manifest.bindings, None);
assert_eq!(manifest.capabilities, None);
assert_eq!(manifest.self_signals, None);
assert_eq!(manifest.provenance, None);
let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
for optional in [
"trust_tier",
"consumes",
"bindings",
"capabilities",
"self_signals",
"provenance",
] {
assert!(
encoded.get(optional).is_none(),
"an absent {optional} declaration must stay absent on the wire"
);
}
let decoded: ModuleManifest =
serde_json::from_value(encoded).expect("builder manifest round-trips");
assert_eq!(decoded, manifest);
}
#[test]
fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
let manifest = ModuleManifest::builder("full-builder", "2.0.0")
.trust_tier(Some(TrustTier::Reviewed))
.bindings(Some(Bindings {
storage: StorageBinding {
kind: StorageKind::Sqlite,
scope: StorageScope::Project,
owns_schema: false,
},
vault_grants: Vec::new(),
identity: IdentityBinding {
requires: vec![IdentityScope::Project],
optional: Vec::new(),
},
}))
.provides(vec![ProviderRole::ToolProvider {
tools: vec![Tool {
name: "read".to_string(),
description: None,
execution_mode: ExecutionMode::Pure,
schema: json!({"type": "object"}),
}],
identity_scope: vec![IdentityScope::Project],
concurrency: Concurrency::Serial,
emits_push: false,
sub_supervises: false,
}])
.consumes(vec![ConsumerRole::ServiceClient {
of: vec!["embedding.v2".to_string()],
}])
.capabilities(Some(CapabilityDeclarations {
provides: vec!["embedding/v2".to_string()],
requires: Vec::new(),
must_never_reach: Vec::new(),
}))
.self_signals(Some(vec![SelfSignalDeclaration {
name: "usage_poller".to_string(),
kind: SelfSignalKind::Poller,
effect: SelfSignalEffect::Observe,
anchored_to: SignalAnchor::FixedInterval,
cadence: Some(SignalCadence::Literal {
interval_ms: 60_000,
}),
domain: Some("provider-usage".to_string()),
note: None,
}]))
.provenance(Some(ManifestProvenance {
build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
build_lock_digest: Some(
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
),
wire_crate_version: Some("0.16.0".to_string()),
store_schema_version: Some("42".to_string()),
}))
.build();
assert_eq!(
serde_json::to_vec(&manifest).expect("builder manifest serializes"),
include_bytes!("../tests/golden/module_manifest_builder_full.json"),
"the builder must preserve the prior fully populated literal wire bytes"
);
}
#[test]
fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
let decoded: ModuleManifest =
serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
assert!(!decoded.consumes.is_empty());
assert!(decoded.bindings.is_some());
let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
assert_eq!(
reencoded, raw,
"old manifest relay stays byte-for-byte verbatim"
);
}
#[test]
fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
let decoded: ModuleManifest =
serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
assert_eq!(decoded.trust_tier, None);
assert!(decoded.consumes.is_empty());
assert_eq!(decoded.bindings, None);
let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
assert_eq!(
pretty.as_bytes(),
raw,
"new manifest matches golden byte-for-byte without unread keys"
);
let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
assert!(
as_val.get("trust_tier").is_none(),
"no trust_tier on wire for new manifest"
);
assert!(
as_val.get("consumes").is_none(),
"no consumes on wire for empty consumes"
);
assert!(
as_val.get("bindings").is_none(),
"no bindings on wire for new manifest"
);
}
#[test]
fn aft_manifest_fixture_matches_v1_contract() {
let manifest = aft_manifest_fixture();
assert_eq!(manifest.module_id, "aft");
let ProviderRole::ToolProvider {
tools,
identity_scope,
concurrency,
emits_push,
sub_supervises,
} = &manifest.provides[0]
else {
panic!("AFT fixture must expose one tool_provider role");
};
assert_eq!(*concurrency, Concurrency::ModuleManaged);
assert!(*emits_push);
assert!(*sub_supervises);
assert_eq!(
identity_scope,
&vec![IdentityScope::Session, IdentityScope::Project]
);
assert_eq!(
tools
.iter()
.map(|tool| (tool.name.as_str(), tool.execution_mode))
.collect::<Vec<_>>(),
vec![
("read", ExecutionMode::Pure),
("grep", ExecutionMode::Pure),
("outline", ExecutionMode::Pure),
("semantic_search", ExecutionMode::Pure),
("edit", ExecutionMode::Mutating),
("write", ExecutionMode::Mutating),
("bash", ExecutionMode::Unfenceable),
]
);
}
#[test]
fn tool_provider_role_tag_serializes_as_snake_case() {
let manifest = aft_manifest_fixture();
let value = serde_json::to_value(&manifest).unwrap();
assert_eq!(value["provides"][0]["role"], "tool_provider");
}
#[test]
fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
let manifest = aft_manifest_fixture();
let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
assert!(encoded.get("capabilities").is_none());
let decoded: ModuleManifest =
serde_json::from_value(encoded).expect("legacy manifest parses");
assert_eq!(decoded.capabilities, None);
}
#[test]
fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
for identifier in [
"a/v1",
"credentials-provider/v1",
"a1-b2/v4294967295",
"a123456789012345678901234567890123456789012345678901234567890123/v1",
] {
assert!(
is_valid_capability_identifier(identifier),
"identifier must be accepted: {identifier}"
);
}
for identifier in [
"credentials-Provider/v1",
"credentials-provider/v01",
"credentials-provider-/v1",
"credentials--provider/v1",
"Credentials-provider/v1",
"credentials-provider/1",
"credentials provider/v1",
"credentials-provider/v0",
"credentials-provider/v4294967296",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
] {
assert!(
!is_valid_capability_identifier(identifier),
"identifier must be rejected: {identifier}"
);
}
}
#[test]
fn capability_grammar_errors_redact_secret_shaped_values() {
let error = validate_manifest_capability_grammar(&json!({
"capabilities": { "provides": ["sk-secret-value/v0"] }
}))
.expect_err("secret-shaped capability identifier is malformed");
assert_eq!(error.field(), "capabilities.provides[0]");
assert_eq!(error.value(), "<redacted>");
assert!(!error.to_string().contains("sk-secret-value"));
}
#[test]
fn provenance_builder_sentinels_become_field_omission() {
for sentinel in [
"unknown",
"UNKNOWN",
"Unknown",
"unavailable",
"none",
"None",
" unknown ",
"",
] {
let p = build_provenance(Some(sentinel), Some(sentinel), Some(sentinel))
.expect("sentinels are omitted before form validation");
assert_eq!(
(p.build_git_sha, p.build_lock_digest, p.store_schema_version),
(None, None, None),
"sentinel {sentinel:?} must be omitted, not published"
);
}
let real = build_provenance(
Some("0123456789abcdef0123456789abcdef01234567"),
None,
Some("9"),
)
.expect("canonical build revision is accepted");
assert_eq!(
real.build_git_sha.as_deref(),
Some("0123456789abcdef0123456789abcdef01234567")
);
assert_eq!(real.store_schema_version.as_deref(), Some("9"));
assert_eq!(
real.wire_crate_version.as_deref(),
Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
);
}
#[test]
fn build_provenance_accepts_canonical_sha_and_lock_digest() {
let provenance = build_provenance(
Some(" 0123456789abcdef0123456789abcdef01234567 "),
Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
Some(" schema-v3 "),
)
.expect("canonical build facts are accepted");
assert_eq!(
provenance,
ManifestProvenance {
build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
build_lock_digest: Some(
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
),
wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
store_schema_version: Some("schema-v3".to_string()),
}
);
}
#[test]
fn build_provenance_refuses_an_abbreviated_git_sha() {
let error = build_provenance(Some("0123456789ab"), None, None)
.expect_err("a 12-character abbreviation is not canonical");
assert_eq!(error.field(), "build_git_sha");
assert_eq!(error.length(), 12);
assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
assert_eq!(
error.to_string(),
"invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
);
}
#[test]
fn build_provenance_refuses_an_abbreviated_lock_digest() {
let error = build_provenance(None, Some("0123456789abcdef"), None)
.expect_err("a 16-character digest is not canonical");
assert_eq!(error.field(), "build_lock_digest");
assert_eq!(error.length(), 16);
assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
}
#[test]
fn build_provenance_refuses_uppercase_hex() {
let uppercase_sha = "A".repeat(40);
let error = build_provenance(Some(&uppercase_sha), None, None)
.expect_err("uppercase hexadecimal is not canonical");
assert_eq!(error.field(), "build_git_sha");
assert_eq!(error.length(), 40);
assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
}
#[test]
fn build_provenance_refuses_dirty_revision_stamp() {
let error = build_provenance(
Some("0123456789abcdef0123456789abcdef01234567-dirty"),
None,
None,
)
.expect_err("a dirty stamp is not a canonical build revision");
assert_eq!(error.field(), "build_git_sha");
assert_eq!(error.length(), 46);
assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
}
#[test]
fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
let provenance = build_provenance(
Some("unavailable"),
Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
None,
)
.expect("sentinel SHA is omitted before the valid lock digest is checked");
assert_eq!(provenance.build_git_sha, None);
assert_eq!(
provenance.build_lock_digest,
Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
);
assert_eq!(
provenance.wire_crate_version,
Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
);
}
#[test]
fn build_provenance_omits_fully_unavailable_inputs() {
let provenance = build_provenance(None, Some(" unavailable "), Some(" "))
.expect("omitted and sentinel inputs are not form errors");
assert_eq!(provenance.build_git_sha, None);
assert_eq!(provenance.build_lock_digest, None);
assert_eq!(provenance.store_schema_version, None);
assert_eq!(
provenance.wire_crate_version,
Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
);
}
}