use std::collections::BTreeMap;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use tokio::sync::{Mutex, Notify, watch};
use tracing::{info, warn};
use vti_common::audit::{AuditEvent, CapabilityModuleChangedData};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::registry::{CapabilityModuleChange, RegistryError};
use crate::server::AppState;
pub const STATE_STORAGE_KEY: &[u8] = b"capability-modules/state";
pub const CONFIG_AUTHORITY: &str = "authority";
pub const GIT_TRUST: &str = "git-trust";
pub const DEFAULT_TICK_SECONDS: u64 = 5;
const BACKOFF_BASE_SECONDS: u64 = 5;
const BACKOFF_CAP_SECONDS: u64 = 3600;
static STATE_LOCK: Mutex<()> = Mutex::const_new(());
static WAKE: Notify = Notify::const_new();
#[derive(Debug, Clone)]
pub struct ModuleDefinition {
pub manifest: Value,
pub requires_authority: bool,
}
impl ModuleDefinition {
pub fn slug(&self) -> &str {
self.manifest["capability"].as_str().unwrap_or_default()
}
pub fn version(&self) -> &str {
self.manifest["version"].as_str().unwrap_or_default()
}
}
fn git_trust_manifest() -> Value {
json!({
"capability": GIT_TRUST,
"version": "0.1",
"title": "Git Commit Trust",
"description": "Grant and revoke members' commit-signing authority; CI verifies each PR commit's signer DID against this community's registry.",
"specs": ["git-trust/*"],
"vocabulary": {
"actions": ["git.commit.sign"],
"resourcePattern": "<org>[/<repo>]"
},
"roles": { "grant": ["operator"], "view": ["member"] },
"externalAdapters": [
{ "kind": "github-action", "ref": "OpenVTC/openvtc/.github/actions/verify-trust" }
]
})
}
pub fn available() -> Vec<ModuleDefinition> {
vec![ModuleDefinition {
manifest: git_trust_manifest(),
requires_authority: true,
}]
}
pub fn definition(slug: &str, version: &str) -> Option<ModuleDefinition> {
available()
.into_iter()
.find(|d| d.slug() == slug && d.version() == version)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProjectionStatus {
Pending,
Applied,
Failed,
}
impl ProjectionStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Applied => "applied",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Projection {
pub status: ProjectionStatus,
#[serde(default)]
pub attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
pub next_attempt_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applied_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModuleState {
pub version: String,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_reason: Option<String>,
pub generation: u64,
pub projection: Projection,
}
pub async fn load(ks: &KeyspaceHandle) -> Result<BTreeMap<String, ModuleState>, AppError> {
match ks.get_raw(STATE_STORAGE_KEY.to_vec()).await? {
Some(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| AppError::Internal(format!("capability-module state decode: {e}"))),
None => Ok(BTreeMap::new()),
}
}
async fn save(ks: &KeyspaceHandle, all: &BTreeMap<String, ModuleState>) -> Result<(), AppError> {
let key = String::from_utf8(STATE_STORAGE_KEY.to_vec()).expect("the storage key is ASCII");
ks.insert(key, all).await
}
#[derive(Debug)]
pub enum ModuleError {
UnknownCapability(String),
AlreadyEnabled,
NotEnabled,
ConfigInvalid(String),
Storage(AppError),
}
impl From<AppError> for ModuleError {
fn from(e: AppError) -> Self {
Self::Storage(e)
}
}
#[derive(Debug, Clone)]
pub struct EnablePlan {
pub capability: String,
pub version: String,
pub config: Value,
}
pub async fn plan_enable(
ks: &KeyspaceHandle,
vtc_did: &str,
capability: &str,
version: &str,
config: &Map<String, Value>,
has_delegate: bool,
) -> Result<EnablePlan, ModuleError> {
let Some(def) = definition(capability, version) else {
let served: Vec<String> = available()
.iter()
.map(|d| format!("{}@{}", d.slug(), d.version()))
.collect();
return Err(ModuleError::UnknownCapability(format!(
"this community does not serve the capability module {capability}@{version} \
(it serves {}); companion modules served by another DID are not supported yet",
served.join(", ")
)));
};
if has_delegate {
return Err(ModuleError::ConfigInvalid(format!(
"{capability} is built into this community; it takes no `delegate`"
)));
}
let mut resolved = config.clone();
if let Some(unknown) = resolved.keys().find(|k| *k != CONFIG_AUTHORITY) {
return Err(ModuleError::ConfigInvalid(format!(
"`{unknown}` is not a {capability} enablement setting; the only one is \
`{CONFIG_AUTHORITY}` (role-derived grants are configured in `[hooks.git-trust]`)"
)));
}
if def.requires_authority {
match resolved.get(CONFIG_AUTHORITY) {
None => {
resolved.insert(CONFIG_AUTHORITY.to_string(), json!(vtc_did));
}
Some(Value::String(did)) if did == vtc_did => {}
Some(other) => {
return Err(ModuleError::ConfigInvalid(format!(
"`{CONFIG_AUTHORITY}` must be this community's DID ({vtc_did}): its {capability} \
records are written signed by this community; got {other}"
)));
}
}
}
if load(ks).await?.get(capability).is_some_and(|m| m.enabled) {
return Err(ModuleError::AlreadyEnabled);
}
Ok(EnablePlan {
capability: capability.to_string(),
version: version.to_string(),
config: Value::Object(resolved),
})
}
pub async fn plan_disable(ks: &KeyspaceHandle, capability: &str) -> Result<(), ModuleError> {
if load(ks).await?.get(capability).is_some_and(|m| m.enabled) {
Ok(())
} else {
Err(ModuleError::NotEnabled)
}
}
fn pending_projection(state: &AppState, now: DateTime<Utc>) -> Projection {
Projection {
status: ProjectionStatus::Pending,
attempts: 0,
last_error: state.registry_client.is_none().then(|| {
"no trust registry is configured ([registry] did); the decision is held here and \
projected once one is"
.to_string()
}),
next_attempt_at: now,
applied_at: None,
}
}
pub async fn commit_enable(
state: &AppState,
actor_did: &str,
plan: EnablePlan,
) -> Result<ModuleState, ModuleError> {
let now = Utc::now();
let entry = {
let _guard = STATE_LOCK.lock().await;
let mut all = load(&state.community_ks).await?;
let prior = all.get(&plan.capability);
if prior.is_some_and(|m| m.enabled) {
return Err(ModuleError::AlreadyEnabled);
}
let entry = ModuleState {
version: plan.version,
enabled: true,
config: Some(plan.config),
enabled_at: Some(now),
enabled_by: Some(actor_did.to_string()),
disabled_at: prior.and_then(|p| p.disabled_at),
disabled_by: prior.and_then(|p| p.disabled_by.clone()),
disable_reason: prior.and_then(|p| p.disable_reason.clone()),
generation: prior.map_or(1, |p| p.generation + 1),
projection: pending_projection(state, now),
};
all.insert(plan.capability.clone(), entry.clone());
save(&state.community_ks, &all).await?;
entry
};
WAKE.notify_one();
Ok(entry)
}
pub async fn commit_disable(
state: &AppState,
actor_did: &str,
capability: &str,
reason: Option<String>,
) -> Result<ModuleState, ModuleError> {
let now = Utc::now();
let entry = {
let _guard = STATE_LOCK.lock().await;
let mut all = load(&state.community_ks).await?;
let Some(entry) = all.get_mut(capability).filter(|m| m.enabled) else {
return Err(ModuleError::NotEnabled);
};
entry.enabled = false;
entry.disabled_at = Some(now);
entry.disabled_by = Some(actor_did.to_string());
entry.disable_reason = reason;
entry.generation += 1;
entry.projection = pending_projection(state, now);
let entry = entry.clone();
save(&state.community_ks, &all).await?;
entry
};
WAKE.notify_one();
Ok(entry)
}
pub fn authority_of(entry: &ModuleState) -> Option<String> {
entry
.config
.as_ref()
.and_then(|c| c.get(CONFIG_AUTHORITY))
.and_then(Value::as_str)
.map(str::to_string)
}
pub async fn audit(
state: &AppState,
actor_did: &str,
capability: &str,
entry: &ModuleState,
event: &str,
error: Option<String>,
) {
let Some(writer) = state.audit_writer.as_ref() else {
return;
};
let data = CapabilityModuleChangedData {
capability: capability.to_string(),
version: entry.version.clone(),
event: event.to_string(),
authority: authority_of(entry),
reason: (event == "disabled")
.then(|| entry.disable_reason.clone())
.flatten(),
error,
};
if let Err(e) = writer
.write(actor_did, None, AuditEvent::CapabilityModuleChanged(data))
.await
{
warn!(error = %e, capability, event, "could not audit a capability-module change");
}
}
fn backoff(attempts: u32) -> chrono::Duration {
let secs = BACKOFF_BASE_SECONDS
.saturating_mul(2u64.saturating_pow(attempts.min(16)))
.min(BACKOFF_CAP_SECONDS);
let jitter = rand::random::<u64>() % (secs / 5 + 1);
chrono::Duration::seconds((secs + jitter) as i64)
}
pub struct Projector {
state: AppState,
tick: Duration,
}
impl Projector {
pub fn new(state: AppState) -> Self {
Self {
state,
tick: Duration::from_secs(DEFAULT_TICK_SECONDS),
}
}
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
info!(
tick_seconds = self.tick.as_secs(),
"capability-module projector starting"
);
let mut tick = tokio::time::interval(self.tick);
loop {
tokio::select! {
_ = tick.tick() => {}
_ = WAKE.notified() => {}
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("capability-module projector stopping");
return;
}
continue;
}
}
if let Err(e) = project_due(&self.state).await {
warn!(error = %e, "capability-module projection pass failed");
}
}
}
}
pub async fn project_due(state: &AppState) -> Result<usize, AppError> {
let Some(client) = state.registry_client.clone() else {
return Ok(0);
};
let now = Utc::now();
let due: Vec<(String, ModuleState)> = {
let _guard = STATE_LOCK.lock().await;
load(&state.community_ks)
.await?
.into_iter()
.filter(|(_, m)| {
m.projection.status != ProjectionStatus::Applied
&& m.projection.next_attempt_at <= now
})
.collect()
};
let actor = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_else(|| "vtc-unknown".to_string());
for (slug, sent) in &due {
let change = CapabilityModuleChange {
capability: slug.clone(),
version: sent.version.clone(),
enabled: sent.enabled,
config: sent.config.clone(),
reason: (!sent.enabled)
.then(|| sent.disable_reason.clone())
.flatten(),
};
let outcome = client.project_capability_module(&change).await;
let settled = {
let _guard = STATE_LOCK.lock().await;
let mut all = load(&state.community_ks).await?;
let Some(entry) = all.get_mut(slug) else {
continue;
};
if entry.generation != sent.generation {
continue;
}
let was = entry.projection.status;
let now = Utc::now();
match &outcome {
Ok(()) => {
entry.projection = Projection {
status: ProjectionStatus::Applied,
attempts: entry.projection.attempts + 1,
last_error: None,
next_attempt_at: now,
applied_at: Some(now),
};
}
Err(e) => {
let attempts = entry.projection.attempts + 1;
let permanent = matches!(
e,
RegistryError::Permanent(_) | RegistryError::Incompatible(_)
);
entry.projection = Projection {
status: if permanent {
ProjectionStatus::Failed
} else {
ProjectionStatus::Pending
},
attempts,
last_error: Some(e.to_string()),
next_attempt_at: now
+ if permanent {
chrono::Duration::seconds(BACKOFF_CAP_SECONDS as i64)
} else {
backoff(attempts)
},
applied_at: None,
};
}
}
let entry = entry.clone();
save(&state.community_ks, &all).await?;
(was, entry)
};
let (was, entry) = settled;
let decision = if entry.enabled { "enable" } else { "disable" };
match &outcome {
Ok(()) => {
info!(
capability = %slug,
decision,
attempts = entry.projection.attempts,
"trust registry acknowledged the capability-module decision"
);
audit(state, &actor, slug, &entry, "projected", None).await;
}
Err(e) => {
warn!(
capability = %slug,
decision,
attempts = entry.projection.attempts,
status = entry.projection.status.as_str(),
next_attempt_at = %entry.projection.next_attempt_at,
error = %e,
"trust registry has not taken the capability-module decision"
);
if entry.projection.status == ProjectionStatus::Failed
&& was != ProjectionStatus::Failed
{
audit(
state,
&actor,
slug,
&entry,
"projectionFailed",
Some(e.to_string()),
)
.await;
}
}
}
}
Ok(due.len())
}
#[cfg(test)]
mod tests {
use super::*;
use trust_tasks_rs::specs::governance::capability::list::v0_1 as list_spec;
use trust_tasks_rs::validate::ValidatedPayload;
#[test]
fn every_available_manifest_is_a_schema_valid_capability_manifest() {
for def in available() {
serde_json::from_value::<list_spec::CapabilityManifest>(def.manifest.clone())
.unwrap_or_else(|e| panic!("{}: {e}", def.slug()));
let response =
json!({ "capabilities": [{ "manifest": def.manifest, "enabled": false }] });
list_spec::Response::validate_value(&response)
.unwrap_or_else(|e| panic!("{}: {e}", def.slug()));
}
}
#[test]
fn the_git_trust_manifest_is_the_registrys() {
let registry = json!({
"capability": "git-trust",
"version": "0.1",
"title": "Git Commit Trust",
"description": "Grant and revoke members' commit-signing authority; CI verifies each PR commit's signer DID against this community's registry.",
"specs": ["git-trust/*"],
"vocabulary": { "actions": ["git.commit.sign"], "resourcePattern": "<org>[/<repo>]" },
"roles": { "grant": ["operator"], "view": ["member"] },
"externalAdapters": [
{ "kind": "github-action", "ref": "OpenVTC/openvtc/.github/actions/verify-trust" }
]
});
assert_eq!(git_trust_manifest(), registry);
}
#[test]
fn backoff_is_bounded() {
for attempts in [0, 1, 5, 16, 40, u32::MAX] {
let d = backoff(attempts).num_seconds() as u64;
assert!(d >= BACKOFF_BASE_SECONDS, "{attempts}: {d}");
assert!(
d <= BACKOFF_CAP_SECONDS + BACKOFF_CAP_SECONDS / 5,
"{attempts}: {d}"
);
}
}
}