1use std::collections::BTreeMap;
20
21use serde::{Deserialize, Serialize};
22
23use crate::capability::{
24 check_admissibility, AdmissibilityDecision, AdmissibilityWitness, Capability, EffectProposal,
25 KernelState,
26};
27use crate::error::{Result, SdkError};
28
29#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
31pub struct CandidateStateWitness {
32 pub state_root: String,
33 pub graph_revision: String,
34 pub node_id: String,
35 pub node_generation: u32,
36 pub canonical_scope: Vec<String>,
37 pub barrier_channels: BTreeMap<String, f64>,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct CandidateTransition {
44 pub proposal: EffectProposal,
45 pub before: CandidateStateWitness,
46 pub after: CandidateStateWitness,
47}
48
49impl CandidateTransition {
50 pub fn new(
51 proposal: EffectProposal,
52 before: CandidateStateWitness,
53 after: CandidateStateWitness,
54 ) -> Self {
55 Self {
56 proposal,
57 before,
58 after,
59 }
60 }
61
62 pub fn unmeasured(proposal: EffectProposal) -> Self {
64 Self::new(
65 proposal,
66 CandidateStateWitness::default(),
67 CandidateStateWitness::default(),
68 )
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ClauseId {
76 Authority,
77 Contract,
78 Effect,
79 BarrierIncrement,
80 RiskBudget,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct ContractWitness {
87 pub ok: bool,
88 pub policy_version: String,
89 pub evidence_refs: Vec<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct BarrierWitness {
99 pub h_before: f64,
100 pub expected_h_after_upper: f64,
101 pub certified_increment: f64,
103 pub unsafe_threshold: f64,
105 pub evidence_refs: Vec<String>,
106}
107
108impl BarrierWitness {
109 pub fn clause_holds(&self, c_c_max: f64) -> bool {
111 self.h_before.is_finite()
112 && self.expected_h_after_upper.is_finite()
113 && self.certified_increment.is_finite()
114 && self.unsafe_threshold.is_finite()
115 && c_c_max.is_finite()
116 && self.h_before >= 0.0
117 && self.expected_h_after_upper >= 0.0
118 && self.unsafe_threshold > 0.0
119 && c_c_max >= 0.0
120 && self.h_before < self.unsafe_threshold
121 && self.expected_h_after_upper < self.unsafe_threshold
122 && self.expected_h_after_upper <= self.h_before + self.certified_increment
123 && self.certified_increment >= 0.0
124 && self.certified_increment <= c_c_max
125 }
126}
127
128pub trait ContractEvaluator: Send + Sync {
130 fn evaluate(&self, transition: &CandidateTransition) -> ContractWitness;
131}
132
133pub trait BarrierEvaluator: Send + Sync {
135 fn evaluate(&self, transition: &CandidateTransition) -> Result<BarrierWitness>;
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case", tag = "profile")]
141pub enum AdmissibilityProfile {
142 SrbnCertified,
144 ConfinementOnly { missing: Vec<ClauseId> },
146 HumanApproved { missing: Vec<ClauseId> },
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct FullAdmissibilityWitness {
153 pub base: AdmissibilityWitness,
154 pub contract: Option<ContractWitness>,
155 pub barrier: Option<BarrierWitness>,
156 pub profile: AdmissibilityProfile,
157}
158
159impl FullAdmissibilityWitness {
160 pub fn allows(&self) -> bool {
162 matches!(self.base.decision, AdmissibilityDecision::Allow)
163 }
164}
165
166pub fn check_full_admissibility(
172 transition: &CandidateTransition,
173 capabilities: &[Capability],
174 state: &KernelState,
175 contract: Option<&dyn ContractEvaluator>,
176 barrier: Option<&dyn BarrierEvaluator>,
177 c_c_max: f64,
178) -> Result<FullAdmissibilityWitness> {
179 let proposal = &transition.proposal;
180 let mut base = check_admissibility(proposal, capabilities, state);
181 base.contract_ok = false;
183 base.barrier_increment_ok = false;
184
185 let mut missing: Vec<ClauseId> = Vec::new();
186
187 let contract_witness = match contract {
188 Some(evaluator) => {
189 let witness = evaluator.evaluate(transition);
190 base.contract_ok = witness.ok;
191 if !witness.ok {
192 base.decision = AdmissibilityDecision::Deny {
193 reason: crate::capability::DenyReason::PolicyDenied,
194 };
195 }
196 Some(witness)
197 }
198 None => {
199 missing.push(ClauseId::Contract);
200 None
201 }
202 };
203
204 let barrier_witness = match barrier {
205 Some(evaluator) => Some(evaluate_barrier_clauses(
206 evaluator,
207 transition,
208 capabilities,
209 c_c_max,
210 &mut base,
211 )?),
212 None => {
213 missing.push(ClauseId::BarrierIncrement);
214 missing.push(ClauseId::RiskBudget);
217 base.risk_budget_ok = false;
218 None
219 }
220 };
221
222 let profile = if missing.is_empty()
223 && base.authority_ok
224 && base.contract_ok
225 && base.effect_ok
226 && base.barrier_increment_ok
227 && base.risk_budget_ok
228 {
229 AdmissibilityProfile::SrbnCertified
230 } else {
231 AdmissibilityProfile::ConfinementOnly { missing }
232 };
233
234 Ok(FullAdmissibilityWitness {
235 base,
236 contract: contract_witness,
237 barrier: barrier_witness,
238 profile,
239 })
240}
241
242fn evaluate_barrier_clauses(
248 evaluator: &dyn BarrierEvaluator,
249 transition: &CandidateTransition,
250 capabilities: &[Capability],
251 c_c_max: f64,
252 base: &mut AdmissibilityWitness,
253) -> Result<BarrierWitness> {
254 let witness = evaluator.evaluate(transition)?;
255 base.barrier_increment_ok = witness.clause_holds(c_c_max);
256 if !base.barrier_increment_ok {
257 base.decision = AdmissibilityDecision::Deny {
258 reason: crate::capability::DenyReason::RiskBudgetExhausted,
259 };
260 }
261 base.risk_budget_ok = base
262 .capability_id
263 .as_ref()
264 .and_then(|id| capabilities.iter().find(|cap| &cap.capability_id == id))
265 .map(|cap| {
266 cap.risk_budget
267 .as_ref()
268 .is_none_or(|budget| budget.admits(witness.certified_increment))
269 })
270 .unwrap_or(false);
271 if !base.risk_budget_ok {
272 base.decision = AdmissibilityDecision::Deny {
273 reason: crate::capability::DenyReason::RiskBudgetExhausted,
274 };
275 }
276 Ok(witness)
277}
278
279pub fn promote(capability: &mut Capability, witness: &FullAdmissibilityWitness) -> Result<()> {
286 if !witness.allows() {
287 return Err(SdkError::Domain(
288 "promotion requires an Allow decision".into(),
289 ));
290 }
291 if witness.profile != AdmissibilityProfile::SrbnCertified {
292 return Err(SdkError::Domain(
293 "certified promotion requires all five admissibility clauses".into(),
294 ));
295 }
296 let debit = witness
300 .barrier
301 .as_ref()
302 .map(|b| b.certified_increment)
303 .unwrap_or(0.0);
304
305 if let Some(budget) = capability.risk_budget.as_ref() {
306 if !budget.admits(debit) {
307 return Err(SdkError::Domain(format!(
308 "risk budget exhausted: spent {} + c_t {debit} exceeds {}",
309 budget.spent, budget.limit
310 )));
311 }
312 }
313 if capability.max_calls == Some(0) {
314 return Err(SdkError::Domain("call budget exhausted".into()));
315 }
316
317 if let Some(calls) = capability.max_calls.as_mut() {
319 *calls -= 1;
320 }
321 if let Some(budget) = capability.risk_budget.as_mut() {
322 budget.spent += debit;
323 }
324 Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use crate::capability::{ActorId, EffectKind, RiskBudget};
331
332 struct PassContract;
333 impl ContractEvaluator for PassContract {
334 fn evaluate(&self, _transition: &CandidateTransition) -> ContractWitness {
335 ContractWitness {
336 ok: true,
337 policy_version: "1".into(),
338 evidence_refs: vec!["policy-run".into()],
339 }
340 }
341 }
342
343 struct FixedBarrier {
345 c_t: f64,
346 }
347 impl BarrierEvaluator for FixedBarrier {
348 fn evaluate(&self, _transition: &CandidateTransition) -> Result<BarrierWitness> {
349 Ok(BarrierWitness {
350 h_before: 0.0,
351 expected_h_after_upper: self.c_t,
352 certified_increment: self.c_t,
353 unsafe_threshold: 1.0,
354 evidence_refs: vec!["barrier-eval".into()],
355 })
356 }
357 }
358
359 fn setup() -> (EffectProposal, Vec<Capability>, KernelState) {
360 let actor = ActorId::new("worker");
361 let mut capability = Capability::new(actor.clone(), vec![EffectKind::ApplyPatch]);
362 capability.max_calls = Some(2);
363 capability.risk_budget = Some(RiskBudget {
364 name: "workspace".into(),
365 limit: 0.5,
366 spent: 0.0,
367 });
368 let proposal = EffectProposal::new(actor, "n1", EffectKind::ApplyPatch);
369 (proposal, vec![capability], KernelState::new())
370 }
371
372 fn transition(proposal: &EffectProposal) -> CandidateTransition {
373 CandidateTransition::unmeasured(proposal.clone())
374 }
375
376 #[test]
377 fn all_five_clauses_yield_srbn_certified() {
378 let (proposal, caps, state) = setup();
379 let witness = check_full_admissibility(
380 &transition(&proposal),
381 &caps,
382 &state,
383 Some(&PassContract),
384 Some(&FixedBarrier { c_t: 0.1 }),
385 0.2,
386 )
387 .unwrap();
388 assert!(witness.allows());
389 assert_eq!(witness.profile, AdmissibilityProfile::SrbnCertified);
390 }
391
392 #[test]
393 fn an_absent_barrier_evaluator_is_recorded_absent_never_true() {
394 let (proposal, caps, state) = setup();
395 let witness = check_full_admissibility(
396 &transition(&proposal),
397 &caps,
398 &state,
399 Some(&PassContract),
400 None,
401 0.2,
402 )
403 .unwrap();
404 assert!(!witness.base.barrier_increment_ok, "absent is not true");
405 assert!(matches!(
406 &witness.profile,
407 AdmissibilityProfile::ConfinementOnly { missing }
408 if missing.contains(&ClauseId::BarrierIncrement)
409 ));
410 }
411
412 #[test]
413 fn an_increment_above_the_ceiling_denies() {
414 let (proposal, caps, state) = setup();
415 let witness = check_full_admissibility(
416 &transition(&proposal),
417 &caps,
418 &state,
419 Some(&PassContract),
420 Some(&FixedBarrier { c_t: 0.4 }),
421 0.2, )
423 .unwrap();
424 assert!(!witness.allows());
425 }
426
427 #[test]
428 fn promotion_debits_exactly_the_certified_increment() {
429 let (proposal, mut caps, state) = setup();
430 let witness = check_full_admissibility(
431 &transition(&proposal),
432 &caps,
433 &state,
434 Some(&PassContract),
435 Some(&FixedBarrier { c_t: 0.1 }),
436 0.2,
437 )
438 .unwrap();
439 promote(&mut caps[0], &witness).unwrap();
440 assert_eq!(caps[0].max_calls, Some(1));
441 let budget = caps[0].risk_budget.as_ref().unwrap();
442 assert!(
443 (budget.spent - 0.1).abs() < 1e-12,
444 "debit is c_t, nothing else"
445 );
446 }
447
448 #[test]
449 fn spending_is_monotone_and_finances_no_oscillation() {
450 let (proposal, mut caps, state) = setup();
451 for _ in 0..2 {
452 let witness = check_full_admissibility(
453 &transition(&proposal),
454 &caps,
455 &state,
456 Some(&PassContract),
457 Some(&FixedBarrier { c_t: 0.2 }),
458 0.3,
459 )
460 .unwrap();
461 promote(&mut caps[0], &witness).unwrap();
462 }
463 let witness = check_full_admissibility(
465 &transition(&proposal),
466 &caps,
467 &state,
468 Some(&PassContract),
469 Some(&FixedBarrier { c_t: 0.2 }),
470 0.3,
471 )
472 .unwrap();
473 assert!(!witness.base.risk_budget_ok);
474 assert!(!witness.allows());
475 assert_ne!(witness.profile, AdmissibilityProfile::SrbnCertified);
476 assert!(promote(&mut caps[0], &witness).is_err(), "0.4 + 0.2 > 0.5");
477 }
478
479 #[test]
480 fn missing_barrier_cannot_be_promoted_as_a_zero_cost_fallback() {
481 let (proposal, mut caps, state) = setup();
482 let witness = check_full_admissibility(
483 &transition(&proposal),
484 &caps,
485 &state,
486 Some(&PassContract),
487 None,
488 0.2,
489 )
490 .unwrap();
491 assert!(
492 witness.allows(),
493 "scope policy alone still permits the proposal"
494 );
495 assert!(!witness.base.risk_budget_ok);
496 assert!(promote(&mut caps[0], &witness).is_err());
497 }
498}