pub(crate) mod seal;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use bacnet_ops::safety::desk::Caller;
use bacnet_ops::safety::{DEFAULT_MINUTES, Level, Preview, Tier, hex};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use tokio::time::Instant;
use self::seal::Sealer;
use crate::policy::{ArmMode, PlanGrant, WritePolicy};
pub(crate) struct Writes {
pub(crate) sealer: Sealer,
armed: Mutex<Option<Armed>>,
plan_used: AtomicU32,
}
#[derive(Debug, Clone)]
struct Armed {
until: Instant,
reason: Option<String>,
}
impl Writes {
pub(crate) fn new() -> Self {
Self {
sealer: Sealer::new(),
armed: Mutex::new(None),
plan_used: AtomicU32::new(0),
}
}
fn armed(&self) -> Option<Armed> {
self.armed
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub(crate) fn is_armed(&self) -> bool {
self.armed().is_some_and(|a| Instant::now() < a.until)
}
pub(crate) fn armed_for(&self) -> Option<u64> {
self.armed()
.and_then(|a| a.until.checked_duration_since(Instant::now()))
.map(|left| left.as_secs())
}
pub(crate) fn arm(&self, minutes: u32, reason: Option<String>) {
let until = Instant::now() + Duration::from_secs(u64::from(minutes) * 60);
*self.armed.lock().unwrap_or_else(PoisonError::into_inner) = Some(Armed { until, reason });
}
pub(crate) fn reason(&self) -> Option<String> {
self.armed().and_then(|a| a.reason)
}
pub(crate) fn plan<'a>(&self, policy: &'a WritePolicy) -> Option<&'a PlanGrant> {
let now = unix_now();
policy.plan.as_ref().filter(|plan| {
now < plan.expires_unix
&& plan.expires_unix <= now.saturating_add(crate::policy::MAX_PLAN_SECS)
&& self.plan_used.load(Ordering::Relaxed) < plan.max_writes
})
}
fn spend(&self, plan: &PlanGrant, n: u32) -> bool {
self.plan_used
.try_update(Ordering::AcqRel, Ordering::Acquire, |used| {
used.checked_add(n)
.filter(|total| *total <= plan.max_writes)
})
.is_ok()
}
pub(crate) fn plan_used(&self) -> u32 {
self.plan_used.load(Ordering::Relaxed)
}
pub(crate) fn level(&self, policy: &WritePolicy) -> Level {
if self.is_armed() || self.plan(policy).is_some() {
Level::Armed { dangerous: false }
} else if self.armed().is_some() {
Level::Expired
} else {
Level::ReadOnly
}
}
}
pub(crate) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Via {
Elicitation,
Plan,
}
impl Via {
pub(crate) fn name(self) -> &'static str {
match self {
Self::Elicitation => "elicitation",
Self::Plan => "plan",
}
}
pub(crate) fn by(self) -> &'static str {
match self {
Self::Elicitation => "client",
Self::Plan => "plan",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Approval {
Approved(Via),
Ask { arm: Option<u32> },
Refused {
reason: &'static str,
message: String,
},
}
fn refused(reason: &'static str, message: impl Into<String>) -> Approval {
Approval::Refused {
reason,
message: message.into(),
}
}
pub(crate) trait Approver: Send + Sync {
fn approve(&self, principal: &str, preview: &Preview, tier: Tier) -> Approval;
}
pub(crate) struct Deny;
impl Approver for Deny {
fn approve(&self, _: &str, _: &Preview, _: Tier) -> Approval {
refused("write-not-granted", "this server approves no writes")
}
}
pub(crate) struct ClientElicitation {
pub(crate) arm: ArmMode,
pub(crate) armed: bool,
pub(crate) supported: bool,
}
impl Approver for ClientElicitation {
fn approve(&self, _: &str, _: &Preview, tier: Tier) -> Approval {
if tier != Tier::T1 {
return refused(
"needs-verified-approver",
format!(
"a {tier:?} change needs an approver the server can verify (the TUI, in a \
later release); a client's confirmation approves T1 only"
),
);
}
let arm = match (self.armed, self.arm) {
(true, _) => None,
(false, ArmMode::Elicit) => Some(DEFAULT_MINUTES),
(false, ArmMode::Never) => {
return refused(
"not-armed",
"no write session is armed, and this profile sets mcp_arm = \"never\": only \
a plan grant arms MCP writes on it",
);
}
};
if !self.supported {
return refused(
"elicitation-unsupported",
"confirming a write needs a 2026-07-28 client that takes form elicitations",
);
}
Approval::Ask { arm }
}
}
pub(crate) struct PlanApprover<'a> {
pub(crate) plan: &'a PlanGrant,
pub(crate) writes: &'a Writes,
}
impl Approver for PlanApprover<'_> {
fn approve(&self, _: &str, preview: &Preview, _: Tier) -> Approval {
let outside = preview.items.iter().find(|item| {
!self
.plan
.covers(item.spec.device, item.spec.property, item.priority)
});
if let Some(item) = outside {
return refused(
"outside-plan",
format!("{} is outside the plan grant", item.label()),
);
}
let n = u32::try_from(preview.items.len()).unwrap_or(u32::MAX);
if !self.writes.spend(self.plan, n) {
return refused("plan-spent", "the plan grant's write budget is spent");
}
Approval::Approved(Via::Plan)
}
}
pub(crate) fn preview_digest(preview: &Preview) -> String {
let items: Vec<Value> = preview
.items
.iter()
.map(|item| {
json!([
item.label(),
hex(&item.value.bytes),
item.before.as_ref().map(|b| hex(&b.bytes)),
format!("{:?}", item.occupancy),
item.commandable,
item.gate.name(),
])
})
.collect();
let canonical = json!([preview.device, preview.batch, preview.gate.name(), items]);
hex(&Sha256::digest(canonical.to_string().as_bytes()))
}
pub(crate) struct McpCaller<'a> {
pub(crate) writes: &'a Writes,
pub(crate) policy: &'a WritePolicy,
pub(crate) via: Option<Via>,
pub(crate) fields: Map<String, Value>,
pub(crate) sending: Option<Sending>,
}
pub(crate) type Sending = std::sync::Arc<Mutex<Vec<u64>>>;
impl McpCaller<'_> {
pub(crate) fn new<'a>(
writes: &'a Writes,
policy: &'a WritePolicy,
via: Option<Via>,
fields: Map<String, Value>,
) -> McpCaller<'a> {
McpCaller {
writes,
policy,
via,
fields,
sending: None,
}
}
}
impl Caller for McpCaller<'_> {
fn level(&self) -> Level {
self.writes.level(self.policy)
}
fn refusal(&self) -> Option<String> {
match self.via {
Some(Via::Elicitation) if !self.writes.is_armed() => {
Some("the write session expired before the write went".into())
}
Some(Via::Plan)
if self
.policy
.plan
.as_ref()
.is_none_or(|p| unix_now() >= p.expires_unix) =>
{
Some("the plan grant expired before the write went".into())
}
_ => None,
}
}
fn ledger_changed<'a>(
&'a self,
entries: &'a [bacnet_ops::safety::ledger::Entry],
) -> bacnet_ops::safety::desk::Waiting<'a> {
if let Some(sending) = &self.sending {
let ids = entries
.iter()
.filter(|e| {
e.state == bacnet_ops::safety::ledger::State::Sending
&& e.run == bacnet_ops::safety::run_id()
})
.map(|e| e.id);
*sending.lock().unwrap_or_else(PoisonError::into_inner) = ids.collect();
}
Box::pin(async {})
}
fn fields(&self) -> Map<String, Value> {
let mut fields = self.fields.clone();
if let Some(via) = self.via {
fields.insert("confirmed_via".into(), json!(via.name()));
fields.insert("approved_by".into(), json!(via.by()));
if via == Via::Plan
&& let Some(plan) = &self.policy.plan
{
fields.insert(
"plan".into(),
json!({ "reason": plan.reason, "used": self.writes.plan_used(),
"max_writes": plan.max_writes }),
);
}
}
if let Some(reason) = self.writes.reason() {
fields
.entry("session_reason")
.or_insert_with(|| json!(reason));
}
fields
}
}