Skip to main content

perspt_sdk/
admissibility.rs

1//! The five-clause admissibility kernel (PSP-9 system 12, Paper III
2//! Definition 3.2).
3//!
4//! The legacy `check_admissibility` is a useful scope checker, but it
5//! hardcodes `contract_ok` and `barrier_increment_ok` to true and equates a
6//! caller-supplied `risk_cost` with the debit. This module replaces that
7//! shortcut: contract and barrier clauses come from registered evaluators,
8//! and **the barrier allowance and the risk debit are the same number** —
9//! `BarrierWitness::certified_increment` is Definition 3.2's `c_c` in both
10//! numeric clauses, so a budget can never be debited for an increment that
11//! was never certified.
12//!
13//! **No durable effect may occur without a complete witness; an absent
14//! clause is recorded as absent, never as true.** Autonomous `SrbnCertified`
15//! commitment requires all five clauses; `ConfinementOnly` and
16//! `HumanApproved` activate only the named authority and deterministic
17//! contract claims and never Theorems 3–5.
18
19use 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/// A content-addressed, provider-neutral witness of one candidate state.
30#[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    /// Versioned operational barrier channel values measured in this state.
38    pub barrier_channels: BTreeMap<String, f64>,
39}
40
41/// A fully materialized candidate transition `Adm(x, p, x')`.
42#[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    /// Test and read-only helper for transitions without a workspace driver.
63    pub fn unmeasured(proposal: EffectProposal) -> Self {
64        Self::new(
65            proposal,
66            CandidateStateWitness::default(),
67            CandidateStateWitness::default(),
68        )
69    }
70}
71
72/// The five clauses of Definition 3.2.
73#[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/// Contract-clause verdict from a registered evaluator (`perspt-policy` is
84/// invoked inside the runtime's implementation).
85#[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/// Barrier-clause verdict (Paper III Def. 3.2 / Theorem 4).
93///
94/// For a fully materialized deterministic candidate the certified increment
95/// is the exact `c_t = max(0, h(x') − h(x))`; the kernel requires
96/// `h(x') < 1`, `c_t ≤ c_c_max`, and debits exactly `c_t`.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct BarrierWitness {
99    pub h_before: f64,
100    pub expected_h_after_upper: f64,
101    /// The invocation increment `c_t`, bounded by the capability's `c_c^max`.
102    pub certified_increment: f64,
103    /// `1` in Paper III.
104    pub unsafe_threshold: f64,
105    pub evidence_refs: Vec<String>,
106}
107
108impl BarrierWitness {
109    /// Whether the numeric barrier clause holds.
110    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
128/// Evaluates the contract clause for a candidate transition.
129pub trait ContractEvaluator: Send + Sync {
130    fn evaluate(&self, transition: &CandidateTransition) -> ContractWitness;
131}
132
133/// Evaluates the barrier clause for a candidate transition.
134pub trait BarrierEvaluator: Send + Sync {
135    fn evaluate(&self, transition: &CandidateTransition) -> Result<BarrierWitness>;
136}
137
138/// Which claims the completed witness activates.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case", tag = "profile")]
141pub enum AdmissibilityProfile {
142    /// All five clauses of Definition 3.2 are satisfied.
143    SrbnCertified,
144    /// Authority/contract/effect hold, but chance safety is not claimed.
145    ConfinementOnly { missing: Vec<ClauseId> },
146    /// Human authorization does not manufacture missing theorem evidence.
147    HumanApproved { missing: Vec<ClauseId> },
148}
149
150/// The complete Definition 3.2 witness.
151#[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    /// Whether the decision permits applying the effect at all.
161    pub fn allows(&self) -> bool {
162        matches!(self.base.decision, AdmissibilityDecision::Allow)
163    }
164}
165
166/// Evaluate all five clauses.
167///
168/// `c_c_max` is the capability contract's ceiling on a single certified
169/// increment. An absent evaluator leaves its clause **absent** — recorded as
170/// missing in the profile, never defaulted to true (Gate K).
171pub 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    // The legacy checker hardcodes these true; they are decided here.
182    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            // Without c_c the risk clause cannot be evaluated. Recording it as
215            // true would create a certificate with an unbound budget debit.
216            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
242/// Evaluate the barrier-increment and risk-budget clauses against one
243/// witness. Definition 3.2 has one `c_c`: the barrier's certified increment
244/// is also the amount tested against the cumulative risk budget (the legacy
245/// scope checker sees `proposal.risk_cost`, always zero on the governed
246/// path, so its risk verdict is replaced here).
247fn 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
279/// Promote an allowed transition: one kernel transaction that decrements the
280/// call budget, debits the risk budget with **exactly the certified
281/// increment**, and records the spend (PSP-9 system 12).
282///
283/// Spending is monotone: later barrier decrease does not refund budget.
284/// A read-only `check` that never consumes budgets is not sufficient.
285pub 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    // The debit is the barrier's certified increment — Definition 3.2 has one
297    // number, not two. Without a barrier witness nothing is certified and
298    // nothing is debited (the profile already records the clause as absent).
299    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    // All checks passed: apply the whole transaction.
318    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    /// A deterministic barrier whose increment is fixed.
344    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, // ceiling below the certified increment
422        )
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        // Budget 0.5, two promotions of 0.2: a third would need 0.2 more.
464        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}